6本目のゲーム「ほしつなぎ」を公開しました。
暗い画面に星が5つ散らばっていて、ワイヤーを撃って引っかけ、振り子のように振られながら順に集めていきます。集め終わると、通った跡が星座になります。
作り始めたときは、まったく違うものでした。横に進み続けるだけの、星も星座も無いものだったのです。そこから今の形になるまでの調整に、これまでの5本で一番時間がかかりました。
今回は、その調整の話を書きます。ゲームの話ですが、中身はほとんど数値の設計の話です。私は業務システムを33年書いてきた人間なので、そういう目で見てしまいがちなのかもしれません。
ボールが暴れて、手に負えなくなった

まず、遊んでいて起きたことを書きます。
このゲームのボールはピンポン玉のように跳ねます。壁に当たると跳ね返り、床でも跳ね返る。よく跳ねるほど星に届きやすくなるので、跳ね返りは強いほうが遊びやすい。
そう思って跳ね返りを強くしていったら、ある日から手に負えなくなりました。
ボールがものすごい勢いで飛び回って、狙って撃つどころではない。星は見えているのに、当てられない。かといって跳ね返りを弱くすると、今度は床で止まってしまって、そこから何もできなくなります。
よく跳ねるほど遊びやすいが、跳ねすぎると遊べない。
この両立が、今回いちばん苦労したところでした。
原因は、自分で塞いだ穴でした
調べてみて、原因が分かりました。
もともとこのゲームには空気抵抗が入っていました。速度が少しずつ減る仕組みです。ところが、その空気抵抗のせいでボールが床にすぐ止まってしまう問題が起きていました。止まった場所から届く星が無いと、そこで詰みます。
そこで空気抵抗をゼロにしました。宇宙が舞台なので、理屈も付きます。
その結果、盤面から「速度が減る場所」が一つも無くなりました。
跳ね返りは0.97、つまり1回あたり3%しか減りません。一度速くなったボールは、いつまでも速いまま。詰みは無くなったが、代わりに暴走が生まれた。片方を潰したら、もう片方が出てきたわけです。
速いときだけ減らす

空気抵抗を戻せば暴走は収まります。でも、それだと元の詰みが戻ります。しかも、ふわふわ漂っている感じが削られてしまう。そこが気持ちよかったので、失いたくありませんでした。
考えた末に、こうしました。
壁の跳ね返りを、速さに応じて変える。
ゆっくり当たったとき … 0.97(よく跳ねる。今までどおり)
速く当たったとき … 0.70(大きく減る)
ゆっくり漂っているときは何も変わりません。暴走したときだけ、壁がブレーキをかける。
減らす場所を「時間」から「壁」に移した、という言い方もできます。空気抵抗は常に効きますが、壁は当たったときだけ効く。効かせたい場面だけを選べます。
実際に測ると、こうなりました。
| 落ち着くまで | 壁に当たった回数 | |
|---|---|---|
| 減衰なし | 31.4秒 | 34回 |
| 減衰あり | 4.2秒 | 4回 |
制御不能の時間が8分の1になりました。そして詰みは戻っていません。何も操作せずに180秒放置しても、ボールは止まりませんでした。
閾値を下げると、閾値の位置が動く
ここからが、今回いちばん調整をしていて面白かったところです。
「速いとき」の境目をどこに置くか。この数字を決めるのに、思ったより手間がかかりました。
最初は700にしていました。ゲーム中の速度分布を見ると、700は上位10%あたりに位置していて、ちょうどよさそうに見えたのです。
ところが、あとから重力を弱くする調整を入れました。すると全体がゆっくりになり、同じ700が上位5%の位置に上がってしまいました。 20回に1回しか効かない。
そこで下げようとして、おかしなことに気づきます。
閾値を下げると、壁がよく食うようになってボールが遅くなる。すると速度分布そのものが下にずれて、下げたはずの閾値の位置が押し戻される。
実際の数字がこうでした。
| 閾値 | 分布の中の位置 |
|---|---|
| 700 | 95.4% |
| 640 | 92.8% |
| 600 | 91.2% |
| 560 | 88.2% |
測ろうとしている対象が、測ることで変わってしまう。 だから一度の計算では決まりません。候補を入れた状態で測り直す、を繰り返して、目標の帯(75〜90%)に入るいちばん高い値として560を選びました。
業務システムでも似たことがありますよね。性能を改善したら負荷の分布が変わって、ボトルネックが別の場所に移る。あるいは、キャッシュのヒット率を上げたらアクセスの傾向が変わって、また測り直しになる。
系に手を入れると、系そのものが変わる。 頭では分かっていたことですが、自分で作ったものでこれを踏むと、感触が違いました。
計算の順番を変えるだけで、誤差が溜まらなくなる

もうひとつ、コードを読み返していて驚いたところがあります。
このゲームの振り子は、物理エンジンを使っていません。角度と角速度を持って、三角関数で回しているだけです。1秒に120回、こういう計算をしています。
加速度 = -(重力 / ロープの長さ) × sin(角度)
角速度 += 加速度 × 刻み
角度 += 角速度 × 刻み
小学校で習う「速さ×時間=距離」を、細かく刻んで繰り返しているだけです。
こういう計算は、誤差が溜まるのが普通です。刻むたびに少しずつずれて、長く回すほど狂っていく。振り子なら、勝手に振れ幅が大きくなったり、逆に止まっていったりします。
ところが実際に測ってみると、こうでした。
| 回した時間 | エネルギーの変動幅 |
|---|---|
| 10秒 | ±0.6% |
| 600秒 | ±0.6% |
| 3600秒 | ±0.6% |
1時間回しても、幅が広がりません。
理由は、計算の順番にありました。
上の3行をよく見ると、角速度を先に更新して、その新しい角速度で角度を進めています。 これを逆にして、角度を先に進めてから角速度を更新すると、誤差が一方向に溜まっていきます。
順番が違うだけです。書く行数も、計算量も同じ。それだけで、誤差が溜まるか、有限の幅の中で振動するだけかが変わる。
これには名前が付いていて、半陰的オイラー法(シンプレクティック・オイラー法)と呼ばれるそうです。名前を知ったのは今日でした。
そして、この誤差は気にしなくてよかった
もうひとつ、実務的な話を付け加えます。
この振り子には、意図的に減衰を入れています。放っておくと永遠に振れ続けてしまうので、少しずつ勢いが落ちる仕組みです。
その減衰の強さを測ると、10秒で推進するエネルギーが元の10.3%まで落ちます。
一方、計算の誤差は±0.6%。
桁が2つ違います。 精度を心配していた誤差は、自分で入れた減衰に完全に埋もれていました。
これも、業務システムでよく見る形です。マイクロ秒を削る改善に時間をかけたら、その処理の前後で秒単位の待ちが入っていた、というような。
気にすべき場所を、数字で確かめてから決める。 分かっているつもりで、やはり間違えてしまいました。
遊んでみてください
急ぐ必要はありません。ゆっくり揺れながら回っても、ちゃんとクリアできます。ただし、上のほうにある星に届くには、振り子で勢いをつけないといけません。
少ない手数で回れたら上手い、というだけのゲームです。
6本目にして

最後に、少しだけ自分の話を書かせてください。
私は長いあいだ「ゲームが作れない人」でした。Unityを触ってはアセットストアで素材を集め、集めたところで満足して、作らないまま終わる。何度もやりました。
去年から、やり方を変えました。素材からではなく、遊びから入る。 何を作りたいかを先に決めて、それが面白いかどうかを最小限のもので確かめてから、先へ進む。
今回もそうでした。最初に作ったのは「ワイヤーを撃って、振られて、離して飛ぶ」だけのものです。得点もゴールもありません。それが気持ちいいかどうか、それだけを確かめました。
そこから、横に進む形をやめ、壁で囲み、星を集める形になり、星座になりました。最初に作ったものは、ほとんど残っていません。 でも、あの最小限のものが無ければ、どこへ向かえばいいかも分からなかったはずです。
6本目です。まだ「ゲームが作れる人」だとは思いませんが、作れないと思っていた頃の自分からは、少し離れたところにいる気がしています。
そして今回のものは、これまでで一番手応えがあります。UnityやGodotで作り直したら、どうなるだろうか。そんなことを考えています。
50代から始めて、この速度です。それでも、前へは進んでいます。
