OCEAN EXPLORERは、左右へ舵を切りながら宝と島を集め、岩礁を避ける60秒のブラウザゲームです。船は止まれず、海は絶えず手前へ流れてきます。操作は単純ですが、「安全な中央を進むか、岩礁の近くにある宝へ寄るか」という判断が数秒おきに発生します。
航海の面白さは、速く動くことより、次の数秒を先に読むことから生まれます。
この記事では、動いているように見せる仕組みを最初から順に解きほぐします。専門用語はその場で説明し、実装経験がある人向けには生成確率、差分時間、衝突領域、DOM更新の負荷まで踏み込みます。
1. 「取る」と「避ける」を同じ左右移動に載せる
プレイヤーができることは左へ動く、右へ動く、指で行き先を示す、の3つです。船は自動で前進しているように見えます。画面上から流れてくるものは、宝、島、岩礁の3種類です。宝と島に触れると得点が入り、岩礁に触れると8点を失ってコンボが0へ戻ります。
宝の基礎点は10点、島は25点です。連続して回収すると、2コンボ目から2点ずつボーナスが増え、最大20点まで加算されます。この仕組みにより、1個を安全に取るだけでなく、その次の回収物まで見て航路を選ぶようになります。
ゲームを始めて最初の数秒は、宝を取ればよいと直感で理解できます。その後、岩礁が混ざると「取りたい」と「避けたい」が同じ左右移動の中で衝突します。新しいボタンや説明を増やさずに判断だけを増やす、というのが最初の設計目標でした。
60秒という長さもこの判断に合っています。1回の失敗をすぐ取り返せる一方、コンボを最後まで維持できれば記録に大きな差が出ます。残り30秒からは夕方の背景へ切り替え、残り24秒では流れの速さを演出でも強調します。数字を凝視しなくても、終盤へ入ったことが分かります。
2. 船ではなく世界を動かす無限スクロール
画面では船が沖へ進んで見えますが、内部のY座標は画面下部のほぼ同じ位置に保たれます。代わりに宝、島、岩礁を上から下へ移動させます。背景も少しずつずらすと、船の速度と海面の流れが結びつき、限られた画面の中に長い航海が生まれます。

船は3つの時点で同じ位置にいます。島、宝、岩礁だけが下へ移り、画面外へ出た要素は破棄され、次の要素が上端から生成されます。外周の線は循環する世界の見え方を示します。
毎フレームの移動量は、速度に前回描画からの経過時間 delta を掛けて求めます。これを差分時間方式と呼びます。端末が1秒に60回描画しても30回描画しても、1秒間に進む距離をほぼ同じにできます。
function updateWorld(delta) {
const elapsed = 60 - remaining;
const speed = 150 + elapsed * 2.7;
for (const entity of entities) {
const rate = entity.type === "treasure" ? 1.08 : 1;
entity.y += speed * delta * rate;
entity.node.style.transform =
`translate3d(${entity.x - entity.size / 2}px, ${entity.y}px, 0)`;
}
}開始時の速度は毎秒150ピクセル相当で、60秒後には約312まで上がります。宝だけを1.08倍で流すのは、きらめく小さな対象を少し素早く見せ、目で追う時間を短くするためです。厳密な遠近法ではありませんが、ゲームの手触りを作るための意図的な差です。
背景は大きく動かし続けず、ステージ高の約7%の範囲でオフセットを変えます。背景画像自体の端が見えるのを防ぎながら、海が流れている感覚だけを足します。prefers-reduced-motion が有効な環境ではこの移動を止め、回収物の移動だけでゲーム情報を保ちます。
3. すべての航海物を一つの配列で管理する
宝、島、岩礁は見た目も効果も違いますが、位置を更新して画面外で消す処理は共通です。そこで、すべてを entities という一つの配列に入れ、type で振る舞いを分けています。1個の要素が持つのは、DOMノード、種類、X/Y座標、大きさ、回収済みかどうかです。
// 種類を確率で選び、共通形式の要素として追加します。
const random = Math.random();
const type = random < 0.46
? "treasure"
: random < 0.68
? "island"
: "reef";
entities.push({
node,
type,
x,
y: -size - 10,
size,
collected: false,
});現在の割合は宝46%、島22%、岩礁32%です。X座標は左右の余白を除いた範囲から一様乱数で選びます。一様乱数とは、その範囲のどこも同じ確率で選ぶ方法です。実装は軽く予測しにくい一方、偶然同じ位置へ重なることがあります。そのため、生成密度を上げすぎず、船が横断できる速度との組み合わせで公平さを確保します。
回収した要素には即座に collected を立てます。消えるアニメーションの約0.22秒間に当たり判定が再実行されても、得点を二重加算しないためです。見た目から消す時刻と、ゲーム上の効力を止める時刻は分けて考えます。
画面外へ出た要素はDOMからも配列からも削除します。見えない要素を残すと、航海時間が長いほど更新対象が増えます。60秒ゲームでもこの片付けを徹底しておくと、低性能なスマートフォンで終盤だけ重くなる問題を避けられます。
4. 入力を座標へ統合し、当たり判定に余裕を作る
キーボードでは左右キーまたはA/Dキー、画面ボタンでは押している方向、ステージ上のドラッグでは指のX座標を受け取ります。入力源は違っても、最後は playerX を更新する処理へ集約します。キーやボタンは一定速度、ドラッグは目標位置との差を毎フレーム縮める方式です。
船の横移動速度は Math.max(260, stage.clientWidth * 0.58) で決めています。画面が広いほど速く動けるため、同じ「端から端までの所要時間」を保ちやすくなります。ドラッグではその1.25倍まで近づけますが、指の位置へ瞬間移動はしません。瞬間移動すると岩礁を飛び越えられ、避けるゲームとしての連続性が壊れるからです。
当たり判定は、画像の透明部分を1ピクセルずつ調べる方式ではなく、中心の横距離と縦距離を別々に比較します。これは軸平行境界に近い簡易判定です。
const horizontal = Math.abs(entity.x - playerX);
const vertical = Math.abs(entityCenterY - playerCenterY);
const horizontalLimit = (entity.size + playerSize) * 0.25;
const verticalLimit = (entity.size + playerSize) * 0.25;
if (horizontal <= horizontalLimit && vertical <= verticalLimit) {
entity.type === "reef" ? hitReef(entity) : collectEntity(entity);
}実際には岩礁の横判定だけ係数を0.28へ少し広げています。危険物が視覚的に大きく見えることと一致させるためです。ただし船や岩礁の画像全体よりは小さく、帆の先が触れただけでは衝突しません。「見た目では避けられたのに当たる」という不信感を防ぐことを優先しました。
押している途中で指が外れた場合に備え、pointerup だけでなく pointercancel と lostpointercapture でも移動方向を解除します。タブが非表示になったら自動でポーズします。入力が届かない時間に船だけ進む事故を、ゲームのルールではなく実装側で防いでいます。
5. 安全な航路と高得点の航路を分ける
難易度を考えるときは、障害物の数だけを増やすと失敗します。避ける場所がなくなれば、技術ではなく乱数で結果が決まるからです。そこで調整用の図では、序盤、中盤、終盤の3つの航路を描き、安全領域の幅と宝の置き方を別々に考えました。

左から右へ、航路は狭くなります。ただし完全には塞ぎません。白い点線は安全重視の進路、金色の列は岩礁へ近づく代わりに得点を伸ばせる進路です。
現在の軽量な実装では、図の通りに固定コースを配置するのではなく、生成間隔と種類の確率で同じ判断密度を作っています。生成間隔は開始時の約0.76秒から、終盤の約0.49秒へ縮みます。移動速度も同時に上がるため、画面内の要素数は急増しすぎず、判断できる時間だけが少しずつ短くなります。
調整では、次の指標をプレイごとに記録しました。
- 最初の宝を取るまでの秒数
- 10秒ごとの回収数と衝突数
- 岩礁が左右移動で避けられない配置になった回数
- コンボが5以上続いたプレイの割合
- 画面幅ごとの、端から端まで移動する時間
- 終盤に何も選ばず中央へいるだけで得られる点数
最後の指標が高すぎると、プレイヤーは舵を切らなくなります。逆に低すぎると、常に激しく動くことだけが正解になります。中央にいても最低限は進めるが、記録を狙うなら危険側へ寄る、という差を目指しました。
6. Canvasを使わずDOMで60秒を安定して描く
OCEAN EXPLORERの回収物は、Canvasの中へ描くのではなく、HTML要素としてステージへ追加しています。小規模なゲームなら、CSSの背景、疑似要素、クラス切り替えを使って、読みやすい実装にできます。回収時の拡大やフェード、衝突時の揺れもCSSクラスで管理できます。
DOM方式で注意するのは、毎フレーム left や top を変更するとレイアウト計算が増えやすいことです。本作では位置を transform: translate3d(...) で更新します。transformは多くのブラウザで合成処理へ回しやすく、他の要素の配置計算を巻き込みにくいためです。
フレーム差分には最大0.055秒の上限を設けます。処理落ちやタブ復帰で大きな値が入っても、岩礁が船を通り抜けません。requestAnimationFrame の時刻を使い、残り時間、距離、生成用の累積時間を同じ delta から進めることで、表示とゲーム判定のずれも抑えています。
画面サイズが変わったときは、船の実寸とステージ幅を取り直し、X座標を範囲内へ制限します。スマートフォンを回転した直後に船が画面外へ残らないための処理です。また、動きを減らす設定では背景スクロールを止めますが、ゲームに必要な回収物の移動は維持します。装飾的な動きと、ルールを伝える動きを区別しています。
7. 記録の保存と「公平だったか」の検証
航海終了時にはベストスコアと最長距離を保存します。ただし、ゲームのiframe内でLocal Storageを直接使いません。親サイトへ、ゲームID、保存バージョン、要求ID、データを含むメッセージを送り、親側のセーブブリッジに保存を任せます。
送信先のオリジンは明示し、返信も送信元オリジンと親ウィンドウの一致を確認します。要求IDがあるので、複数の保存要求が近いタイミングで返ってきても対応を取り違えません。3秒で応答がなければ待機を終えますが、ゲーム結果はそのまま表示します。保存機能が止まっても航海を完了できる、疎結合な設計です。
検証では高得点が出るかだけでなく、失敗が納得できるかを見ます。衝突直前の画面を録画し、船の見た目と判定範囲、入力開始から移動までの時間、別の進路が残っていたかを確認します。PC幅とスマートフォン幅では横断距離が違うため、両方で同じ指標を取ります。
さらに、ポーズ中に残り時間が減らないこと、タブを戻した直後に要素が飛ばないこと、回収アニメーション中に二重得点しないこと、リサイズ後に船が範囲外へ出ないこと、保存失敗時にも再挑戦できることを個別に試しました。短いゲームは周回数が多いため、小さな不具合ほど何度も目に入ります。
良い難易度は、失敗の直後に「次はあそこを通ろう」と具体策が浮かぶ難易度です。
OCEAN EXPLORERでは、無限の海を作るために無限のデータを用意したわけではありません。船を安定した位置へ置き、少数の要素を生成して流し、画面外で片付ける。その小さな循環へ、コンボと徐々に縮む判断時間を重ねました。シンプルな左右移動でも、見える未来と選べる航路を丁寧に設計すれば、もう一度出航したくなるゲームになります。

