はじめに
個人開発で横スクロールランナー Zero Run(GitHub)を作って公開した。
Phaser 3 + Vite + TypeScript という構成。ブラウザで即プレイできる、インストール不要のゲームだ。
Zenn にも同内容を掲載しているが、Qiita では実装判断の記録として残しておく。
この記事では技術選定の判断軸に絞って書く。設計ミス(垂直レイヤー問題)の詳細は別記事に切り出し予定なので、ここでは触れない。
ブラウザゲームに Phaser 3 + Vite + TypeScript を選んで正解
スタック選定の結論から先に出す。
| 技術 | 選んだ理由 |
|---|---|
| Phaser 3 | 2D ゲームに必要なもの(物理・入力・シーン管理)が揃っている |
| TypeScript | 規模が小さくても後から読める。型があると Phaser の API ミスが早期にわかる |
| Vite |
npm run dev で HMR 付きのサーバーが立ち上がり、コード変更が即反映される |
| 独自ホスティング(play.showlabo.com) | 静的ファイルを置くだけ。デプロイに余計な手順がない |
この4つを選んで、結果として公開までの往復コストが低い開発ができた。詳しくは後の節で書く。
Unityを選ばなかった理由
「ブラウザゲームを作る」と決めた時点で Unity はほぼ外れた。理由は一つで、Unity WebGL ビルドの初回ロードが重いからだ。
Unity WebGL は Wasm ランタイムやアセットを含むため、ビルドサイズが大きくなりがちだ。モバイル回線でのプレイを想定すると、ロードを待たせること自体がハードルになる。
Phaser + Vite のビルドは軽い。Phaser 本体込みで dist/ は数 MB 程度に収まり、独自サーバーに置けば数秒でアクセスできる。
「宣伝ゲー」として知ってもらうことが目的だったので、到達コストの低さを優先した。ゲームエンジンの機能よりも、プレイヤーがゲームを開く前に諦めないかどうかを気にした。
HoldReleaseControls でタッチとキーボードを一本化
Zero Run の操作は「ホールドで加速、離してジャンプ」の1ボタンだ。この入力を HoldReleaseControls クラス一つで管理している。
type HoldReleaseHandlers = {
onJump: () => void;
};
export class HoldReleaseControls {
private pointerHeld = false;
private spaceHeld = false;
private pointerDisabled = false;
private spaceDisabled = false;
constructor(
private scene: Phaser.Scene,
private handlers: HoldReleaseHandlers,
) {
scene.input.on('pointerdown', this.onPointerDown, this);
scene.input.on('pointerup', this.onPointerUp, this);
const keyboard = scene.input.keyboard;
if (keyboard) {
keyboard.on('keydown-SPACE', this.onSpaceDown, this);
keyboard.on('keyup-SPACE', this.onSpaceUp, this);
}
}
isAccelerating(): boolean {
return (
(this.pointerHeld && !this.pointerDisabled) ||
(this.spaceHeld && !this.spaceDisabled)
);
}
private onPointerDown(): void {
if (this.spaceHeld) {
this.spaceDisabled = true;
}
this.pointerHeld = true;
}
private onPointerUp(): void {
if (this.pointerHeld && !this.pointerDisabled) {
this.handlers.onJump();
}
this.pointerHeld = false;
if (this.spaceHeld) this.spaceDisabled = false;
}
private onSpaceDown(event: KeyboardEvent): void {
event.preventDefault();
if (this.pointerHeld) {
this.pointerDisabled = true;
}
this.spaceHeld = true;
}
private onSpaceUp(): void {
if (this.spaceHeld && !this.spaceDisabled) {
this.handlers.onJump();
}
this.spaceHeld = false;
if (this.pointerHeld) this.pointerDisabled = false;
}
}
実際のコードでは、シーン終了時にイベントリスナーを外す destroy() も用意している。
タッチは pointerdown / pointerup、キーボードは keydown-SPACE / keyup-SPACE をそれぞれ拾う。Phaser の pointer イベントはタッチとマウスを統一して扱うので、スマホ・PC ともに追加実装なしで動く。
チャンネル排他
「スペースを押しながらタップ」のような誤操作を防ぐため、後から入力したチャンネルが先のチャンネルを無効化する排他制御を入れた。
- Space を押している最中にタップ → pointer チャンネルを無効化
- タップ中に Space を押す → Space チャンネルを無効化
- 後から入力したチャンネルを離したとき、先のチャンネルを再有効化する
このルールにより、どちらの操作でも「ホールドで加速、離してジャンプ」が一貫して動く。
GameScene.update() では controls.isAccelerating() を呼ぶだけでよく、入力源の違いをゲームロジックが気にする必要がない。
seedベースのコース共有を localStorage だけで実装
Zero Run のコースは seed(数値)から完全に決定論的に生成される。同じ seed なら何回遊んでも同じ配置になる。
URL でコースを渡す
https://play.showlabo.com/zero-run/?seed=1234
getStageSeed() が URL の ?seed= パラメータを読む。未指定の場合はランダムな seed を使い、その値で遊ぶ。
export function getStageSeed(): number {
const raw = new URLSearchParams(window.location.search).get('seed');
if (raw == null || raw === '') return randomStageSeed();
return parseStageSeed(raw);
}
ゲームオーバー後にシェア
ゲームオーバー画面に「𝕏 でシェア」ボタンを置いた。タップすると現在の seed・スコアを含む X の intent URL が開く。
const shareText =
`Zero Run で ${points} points を記録!\n` +
`seed: ${STAGE_SEED}\n` +
`同じステージに挑戦 👇\n` +
shareUrl + '\n' +
'#ZeroRun #ShowLabo';
受け取った人が同じ URL を開けば、同じコースで遊べる。サーバーもランキング機能も不要で、共有の仕組みが完結する。
ベストスコアは localStorage のみ
スコアのサーバー保存は作らなかった。localStorage に zeroRunBestPoints というキーで保存するだけだ。「宣伝ゲー」として割り切ったので、リーダーボードは今回の優先度に入らなかった。
個人開発の公開コストを下げるスタック選びの考え方
振り返ると、選んだスタックに共通する判断軸は「次の一手を打つまでの時間を短くする」だった。
- Vite の HMR → コードを変えて保存すれば、ブラウザリロード不要で結果が見える。「直す→確認」の往復が速い
- TypeScript → Phaser の型定義が充実しているので、API ミスがエディタ上で気づける。実機でハマる前に潰せる
- 独自ホスティング → ビルドして静的ファイルを置くだけ。CI を整えなくても出せる
- Phaser → 物理・入力・テクスチャ生成・シーン管理が同じライブラリに収まっているので、「あの機能はどのライブラリにあったか」を調べる時間が減る
どれも「高機能だから」ではなく「詰まったときに立て直しやすいから」という観点で選んでいる。個人開発は完成させないと意味がないので、公開までの抵抗を下げることを優先した。
失敗談: FIT スケーリングと iOS スクロール防止
詰まった箇所を2つ記録しておく。
DOM ボタンの位置ずれ
ゲームオーバー画面のシェアボタンは、Phaser Canvas の上に透明な <a> タグを DOM で重ねる方式を使っている。iOS Safari で window.open() がブロックされるため、ユーザーが直接タップする <a> リンクにする必要があった。
問題は、シーンの create() が実行された時点では Phaser の FIT スケーリングが最終位置に収まっておらず、DOM ボタンの座標がずれることだった。
解決は requestAnimationFrame で1フレーム遅らせて再計算することだった。
updatePos();
requestAnimationFrame(() => updatePos());
さらに window resize / visualViewport resize / Phaser.Scale.Events.RESIZE のいずれかが起きるたびに updatePos() を呼ぶことで、画面回転後もずれない。
iOS スクロール防止
今回の構成では、touch-action: none を body に指定して解決した。
body {
touch-action: none;
}
ゲーム中にスワイプするとページがスクロールして操作が破綻するので、この対応は必須だった。少なくとも今回の構成では、Phaser の input.addPointer() や Phaser 側の設定だけでは防げなかった。
他にも問題は発生した。内容は後日記事にまとめる。
おわりに
Phaser 3 + Vite + TypeScript の組み合わせは、個人開発のブラウザゲームに向いていると感じた。
環境構築で詰まることがほぼなく、「動かしながら作る」サイクルを維持できる。大きなゲームを作るなら別の選択肢もあるが、まず公開することを目標にするなら、このスタックは選びやすい。
ゲームは Zero Run でプレイできる。コードは GitHub に置いている。
技術寄りではない制作背景は note に書いた。
この記事は Zenn にも掲載しています。

