海賊船の打ち返し係
敵船が撃つ音を聞いて、飛んでくる砲弾を拍で打ち返す1ボタンのリズムゲーム。曲も効果音も海も船も砲弾も、すべてこのページのコードが組み立てている。画像も音源も1つも読み込まず、3Dはライブラリを使わない自前の透視投影で描いている
制作メモ
- AIに任せた
- 音の時計を正本にする実装。拍の位置は必ず AudioContext.currentTime から逆算し、音の予約は25msごとに100ms先まで入れる。押した時刻は getOutputTimestamp() で音の時計へ直すので、機器の出力遅れが自動で吸収される/曲と効果音を発振器だけで作った。ニ短調・4小節進行の低音と和音とふし、カウントイン、砲弾・二連弾・カモメの3種類の合図音、打った音、船に当たる音、空を切る音。音源ファイルは1つも使っていない/3Dを自前で書いた。透視投影・霧・面の並べ替え・簡易な陰影と、砲弾(八面体を分割した32面の球)・カモメ・敵船・自分の船首・櫂のモデル。頂点はすべてコードで置いている/譜面を拍だけで持つ設計。発射だけを並べて打点は飛行時間から計算する。飛行時間を変えても書き直しが要らない/判定・拍の変換・譜面の妥当性・投影・補正値の読み取りをユニット40件で固定した。隣り合う打点が判定の届く範囲より離れていることも、実時間に直して検査している/E2E 12件。4状態が実際に通ること、キーボードだけで通せること、外部への通信が0本であること、そして画素が実際に描かれていること/権利の線引きの調査と反映。ゲームのルールや仕組みはアイデアなので著作権では保護されないが、名称・キャラクター・曲・効果音・画面構成という表現は保護される。過去には、既存のリズムゲームを再現するツールが権利者の申し立てでGitHubから削除された例(forkを含めて250件以上)もある。そこで、素材を1つも借りず全部その場で作り、公開物に既存作品の固有名を書かない方針にした/敵船が段階的に壊れる演出。ドンピシャで打ち返した弾が「実際に船へ届いた瞬間」に段が進む。帆が裂ける→上の帆が落ちる→マストが折れる→帆をすべて失う→船体が傾く→大破の7段で、遠い小さなシルエットでも輪郭が変わるように壊す順を決めた/おしいの弾は失速して海へ落とし、ドンピシャの弾だけが船へ届くようにした。判定の差を点数ではなく絵で見せるため/体験評価ハーネスを回した。別セッションの評価者が実際にブラウザで遊んで10項目を採点し、返ってきた改善指示を、実装前にコードと実測で1件ずつ裏取りしてから直した/体験評価ハーネスを3周回した(別セッションの評価者が実際にブラウザで遊んで10項目を採点。13→15→16点で合格)。返ってきた指摘は、実装前に必ずコードと実測で裏を取ってから直した/デモ動画に音を付ける道具(tools/render-demo-audio.mjs)。Playwrightは音を録らないので、アプリと同じ音符データ(伴奏・発射・打点)から同じ波形と包絡線でWAVを組み立て直し、ffmpegで重ねている。映像と音のズレが30ms以内であることを、音の立ち上がりと譜面の時刻を突き合わせて実測した
- 自分でやった
- 作るものの指定と、途中での方針転換(海賊船のシミュレーション → 音ゲー)/遊びの型・題材・公開の枠の3点を、提示した選択肢から選ぶ判断/「必要ならネットから商用利用可の素材を拾ってよい。足りない3Dモデルは自作せよ」という素材方針の指定/リサーチを厚くしてよいという指示/「ドンピシャだったら船が崩れていく。打ち返せば打ち返すほど崩れる」という演出の指定/体験評価ハーネスを回すという進め方の指示
- 失敗と修正
- テストが全部通っているのに、画面には何も描かれていなかった。スクショを撮って初めて気づいた。原因は、遊んでいない間は海の画面が display:none で大きさが0になること。起動時に1回だけ測って終わりにしていたので、遊び始めても描画用の大きさが 1×1 のままで、その1画素が引き伸ばされて単色の四角になっていた → ResizeObserver で大きさを見張るようにし、0のときは測り直さないようにした。あわせて『画素が実際に描かれていること』を見るE2Eを足した(描画用の大きさが実寸か・単色で塗りつぶされていないか)。状態だけ見るテストは、真っ白な画面を素通りさせる
打ち返した砲弾が跳ね返らず、当てても外してもそのまま自分に向かって飛び続けていた。手応えがまったく無い → 物体から打点の番号を引けるようにして、当たった弾は打点から元来た方へ戻すようにした。外した弾はそのまま飛んできて船を通り過ぎる
砲弾が水平線と敵船にぴったり重なって、どこにいるのか読めなかった。打点の高さを目線とほぼ同じ(1.32m/目線1.6m)にしていたため、飛んでいる間ずっと遠くの船と同じ高さにいた → 打点を0.85mまで下げ、飛び方を山なりにして空を背負わせた。近くに来るほど水平線から離れて、船首の上に降りてくる
奥行きをまっすぐ割っていたので、飛行時間のほとんどで砲弾が点にしか見えず、最後の数%で急に膨らんだ。見かけの大きさは奥行きの逆数なので、等速で近づけると等速には見えない → 同じ割合ずつ近づく形(幾何級数)にして、見かけの大きさが一定の速さで大きくなるようにした
船首が横に広すぎて、海を隠す茶色い箱に見えていた → 幅を半分以下にし、甲板と舷(ふなべり)と舳先を分けて、輪郭で船だと分かる形にした
ランクの判定で、から振りを1回するだけで最上位から2段落ちる作りになっていた。拍と関係ない入力は減点でよいが、砲弾を通してしまうことより重く扱うのはおかしい → 『通してしまった数』を主に見て、から振りは2回まで大目に見るようにした。ユニットテストを書いていて食い違いに気づいた
E2Eが3件落ちて、原因を実装のせいだと読み違えた。実際には dist/ を配って検査する作りなのに、直したあと再ビルドせずに走らせていたので、テストは古いコードを見ていた → ビルドしてから走らせ直した。画面の状態と音の時計を1秒ごとに書き出す小さな検査を先に回して、『短い譜面のはずが本編の31打点になっている』と分かったのが決め手だった
drawObjects の閉じ括弧が1つ落ちていたのに、node --check は「構文OK」と答えた。ページは丸ごと読み込みに失敗していて、その状態でビルドまで通った → E2Eが13件中11件落ちて気づいた。ブラウザのコンソールを見て『Unexpected end of input』を確認し、括弧を補った。構文検査を通ったことは、ブラウザで動くことの証明にならない
押した時刻を音の時計へ直す仕組みが、最初の1回だけ「遅れ0ms」を返していた。実測すると2回目以降は33msで安定するので、1発目だけ判定が33msぶんずれる → 遅れは物理的に正の値にしかならないので、0以下を返してきたら信用せず outputLatency で補うようにした。体験評価者が『+60ms遅らせて押すと1発目だけ判定が違う』と報告してきたのが発端で、実測で裏を取ってから直した
体験評価1周目が13/20点で不合格。当てたときの返り(船が崩れる・連打が通用しない)は既に強いが、入口と自己把握が落ちていた。①遊べる状態になってから初弾まで0.9秒しかなく、1回も成功しないまま本編が始まる ②早いのか遅いのかを一度も教えない ③21/31当てた回と、でたらめ連打の回が同じ見出し・同じ助言 ④HUDの「のこり NN」が残り秒数に読める(実際は残りの砲弾数) ⑤結果の「もう一度」が1200×943で画面の下端で切れて押せない → ①カウントインを4拍から8拍に伸ばし、前半で『練習の1発』を撃つようにした(点にもランクにも入らない)。カウントは画面外の小さな文字ではなく海の上に大きく描く ②判定の真下に「はやい/おそい」を出し、結果に平均のズレと補正の目安値を出す ③ランクを4段から6段にし、助言文も段ごとに変えた ④「砲弾 のこり NN発」に変え、画面上端に時間の進捗バーを足した ⑤結果では海を小さくし、結果パネルを画面内へスクロールする。⑤は1200×943で下端967pxと実測してから直した
体験評価2周目(15/20・不合格)で、順位の物差しそのものが違っていたと分かった。敵船を大破させた回(ドンピシャ29)と、1発も届かなかった回(ドンピシャ0・おしい29)が、同じ「操舵手」・同じ助言で返っていた。おしいをドンピシャと同じに数えていたため。画面には「届くのはドンピシャで返した弾だけ」と書いてあるのに、順位だけがその宣言を裏切っていた。1周目に『刻みが少ない』と指摘されて段を増やしたが、それは症状で、原因は物差しだった → 順位を「敵船に届いた弾の数(ドンピシャ)」だけで決めるように作り直した。おしいは順位を上げない。から振りは順位に混ぜない(から振り1回でキャプテンから落ちる段差もやめた)。評価者が挙げた実例そのものを単体テストに固定した。あわせて助言文を全段書き直し(『一発も船に当てていない』は、敵船に当てていないと読めてしまう)、連続数もドンピシャ限定にした
曲が終わってもループが回り続け、finish() が毎フレーム呼ばれていた。結果の中身を毎フレーム作り直していたうえ、結果画面から補正画面へ移ろうとしても次のフレームで結果へ引き戻され、遷移できなかった → 結果と補正の状態では finish() を素通りさせた。体験評価2周目の『結果から音のズレを直す画面へ行けない』を直そうとして、ボタンを足しても動かず、そこで初めて気づいた
モバイルの余白を直したつもりが、海が伸びていなかった。枠(.stage)は694pxまで広がっていたのに、canvas は178pxのままだった。高さが flex で決まる親に対して height:100% は解決できず、canvas 自身の width/height 属性に落ちる。その属性は resize() が測った値なので、小さいまま固まって二度と戻らない → canvas を絶対配置にして、親の高さの決まり方に関係なく埋めるようにした。390×844で海が224px→692px、余白は画面の61%→6%になった。あわせて『海が画面を埋めているか』『canvasが枠を埋めているか』を見るE2Eを足した
上の修正でループを止めたら、今度は結果画面のcanvasが真っ黒になった。結果に移ると海の枠が縮み、ResizeObserverがcanvasの大きさを入れ直す=中身が消える。描き直す者がいないので、50秒かけて沈めた敵船という一番の見せ場が空の箱になっていた(体験評価3周目が発見) → ループは止めず、二重に結果を作らない番だけを残した。止まっている時に大きさが変わった場合に備えて、resize() 側でも描き直すようにした。E2Eで『結果画面のcanvasが単色で塗りつぶされていないこと』を固定した
CSSのセレクタを `[data-state="playing"] .page` と書いていた。data-state を持っているのは .page 自身なので、子孫セレクタでは当たらない。モバイルの余白が半分残ったまま、直ったつもりでいた → `.page[data-state="playing"]` に直した。他の `[data-state] .stage` 等は .stage が子孫なので正しく効いていた。同じ書き方に見えて、当たるものと当たらないものが混ざっていた










