nun_game0

ブラウザで遊べるテトリス風パズルをTypeScriptで作った話

テトリス風ブロックパズル

ポートフォリオサイトにブラウザゲームを追加した。最初に作ったのはテトリス風のブロックパズル。実際にゲームページで遊べるので、よければ先に触ってみてほしい。

なぜブラウザゲームを作ったか

サイトにブログとYouTubeの埋め込みはあるけど、「来てくれた人がその場で遊べるもの」が欲しかった。ゲーム配信者のポートフォリオなのに遊べるものがないのは寂しい。

ブラウザゲームなら追加のインストールもアカウント登録も不要。ページを開けばすぐ遊べる。テトリスを選んだのは、ルールが広く知られていて実装の難易度も手頃だったから。

技術スタック

  • 描画: HTML5 Canvas API
  • 言語: TypeScript
  • フレームワーク: Next.js 16(React 19)
  • ホスティング: Cloudflare Workers

Canvasを選んだのはパフォーマンスの理由。DOM要素を200個並べてCSSで動かすより、Canvasに直接描画した方がフレーム落ちしにくい。特にモバイルで差が出る。

アーキテクチャ:エンジンとUIの分離

一番こだわったのはゲームエンジンの設計。DOM非依存のピュアTypeScriptでゲームロジックを書いて、React側はその状態を受け取って描画するだけにした。

Snapshotパターン

エンジンとレンダラーの間にSnapshotという中間データ構造を挟んでいる。

エンジンは毎フレーム「今の盤面の状態」をSnapshotとして吐き出す。Reactコンポーネントはそのスナップショットを受け取ってCanvasに描画する。エンジンはCanvasの存在を知らないし、Reactはゲームのルールを知らない。

この分離のメリットは大きい。

テストしやすい。エンジン単体でユニットテストが書ける。「T字ブロックを右に2回移動して回転したら、盤面がこうなる」というテストにDOMは不要。

差し替えやすい。Canvas描画をWebGLに変えたくなっても、エンジンには一切触らない。逆にゲームのルールを変えても、描画ロジックは影響を受けない。

デバッグしやすい。Snapshotをconsole.logに出せば、任意のフレームの盤面状態が確認できる。バグの再現がやりやすい。

ゲームループの構造

requestAnimationFrameでループを回しつつ、落下速度はレベルに応じたインターバルで制御している。入力処理とゲームロジックの更新と描画を分離して、入力は即座に反映、落下はタイマーベースで処理する。

ありがちな失敗として、入力処理をsetIntervalに紐づけてしまうパターンがある。これだと操作感がもっさりする。入力とゲーム更新は別のタイミングで処理するのが快適な操作感の鍵。

操作性の改善

テトリスの面白さは操作の気持ちよさに直結する。ここにはかなり時間をかけた。

回転操作をEnterキーに

一般的なテトリスクローンでは上矢印キーで回転するものが多い。自分も最初はそうしていたけど、上キーだとハードドロップと誤爆しやすい。特に高速落下中に焦って上を押してしまう事故が多かった。

Enter回転にしたら誤操作が激減した。左手で方向キー、右手でEnter。役割が物理的に分かれるので混乱しにくい。

入力のバッファリング

格ゲーでいう「入力バッファ」に近い仕組みを入れている。ブロックが接地する直前に回転キーを押した場合、接地判定の数フレーム前であれば回転を受け付ける。

これがないと「回転しようとしたのに間に合わなかった」という不満が頻発する。人間の反応速度を考慮した猶予を持たせることで、意図通りの操作が通りやすくなった。

モバイル対応のバーチャルパッド

スマホでも遊べるように、画面下部にバーチャルパッドを配置した。左右移動、回転、ソフトドロップ、ハードドロップの4操作をタッチで行える。

バーチャルパッドで気をつけたのはボタンサイズ。小さすぎると押し間違える。大きすぎるとゲーム画面が見えない。何度か調整して、ゲーム画面とパッドの比率を決めた。

ネオン演出のこだわり

サイト全体のデザインテーマが「ネオン」なので、ゲームもそれに合わせている。背景は黒(#0a0a0a)、アクセントカラーは紫(#a855f7)とシアン(#06b6d4)。

ライン消しエフェクト

ラインが揃った瞬間、その行がネオンカラーでフラッシュする。白→紫→シアンのグラデーションで数フレーム光らせてから消去する。地味だけど、これがあるとないとで爽快感が全然違う。

Canvas APIのglobalCompositeOperationlighterに設定して、加算合成でネオンの光のにじみを表現している。通常の描画に加えて、ぼかしたレイヤーを重ねることで「光っている」感を出せる。

ブロックの質感

単色のべた塗りだとのっぺりするので、各ブロックに微妙なグラデーションとエッジのハイライトを入れている。上辺と左辺を少し明るく、下辺と右辺を少し暗くすることで、フラットデザインながら立体感を持たせた。

スコアとレベルの仕組み

スコアリングは消したライン数に応じて加点。1ラインより2ライン同時消し、2ラインより3ライン同時消しの方がポイントが高い。4ライン同時消し(いわゆるテトリス)は大幅ボーナス。

レベルは一定ライン数を消すごとに上がる。レベルが上がると落下速度が上昇する。最初はのんびりだけど、レベル10を超えたあたりから忙しくなる。

ハイスコアはlocalStorageに保存しているので、ブラウザを閉じても記録は残る。サーバーサイドのランキング機能は入れていない。個人サイトなので、自分のベストスコアを更新するモチベーションがあれば十分。

遊び方

操作方法をまとめておく。

PC(キーボード)

  • ← → : 左右移動
  • ↓ : ソフトドロップ(高速落下)
  • Enter : 回転
  • Space : ハードドロップ(即座に設置)

スマートフォン

  • 画面下部のバーチャルパッドで操作

ブロックを横一列に隙間なく並べると、その行が消える。できるだけ多くの行を同時に消して、ハイスコアを狙ってほしい。

ゲームページはこちら

ブラウザゲーム開発のTips

実装してみて得た知見をいくつか。

Canvasのサイズはデバイスピクセル比を考慮する。Retinaディスプレイで描画がぼやける問題はwindow.devicePixelRatioでCanvasの実サイズを倍にして、CSSで表示サイズを指定すると解決する。

requestAnimationFrameの引数を活用する。コールバックに渡されるタイムスタンプを使えば、前回フレームからの経過時間がわかる。固定フレームレートではなく経過時間ベースで更新することで、端末のスペック差を吸収できる。

ゲーム状態はイミュータブルに扱う。盤面を直接書き換えるのではなく、新しい状態を生成して返す設計にすると、状態の巻き戻しや比較が簡単になる。Snapshotパターンとも相性が良い。

まとめ

テトリスは「ルールが単純だから実装も簡単」と思いがちだけど、操作の気持ちよさやエフェクトのクオリティまで追求すると奥が深い。

特にエンジンとUIの分離は、ブラウザゲームに限らずフロントエンド開発全般で使えるパターン。ビジネスロジックとビューを分離するのと同じ考え方がゲームにも適用できるのは面白い発見だった。

次はまた別のゲームも追加したい。ゲームページで遊んでみて、感想があれば教えてほしい。

関連記事: 横スクロールアクション「ネオンランナー」をTypeScriptで作った話

SharePost

他の記事