1. 本題について
この記事は、自作シューティングゲーム・編集ツールのアップデート内容を毎年投稿しており、
2025年も数多くのアップデートしましたが、そのうちの一部を振り返りたいと思います
自作シューティングゲームとは
TypeScriptで実装したグラディウス風の横スクロールシューティングのブラウザゲームです
一部制約はありますが、基本的にPC・スマホでゲームを楽しめます
GamepadAPIを使っており、幾つかのゲームパッドやアーケードコントローラーなどと接続してゲームを楽しむこともできます
この制作目的は
- 好きなシューティングゲームを自分で楽しむ
- 変遷の大きいフロントエンドの技術等を学習する
です
| フレームワーク | Hono |
|---|---|
| ステータス管理 | Zustand |
| バージョン管理 | Proto |
| ビルド環境 | Vite |
| テストフレームワーク | Bun |
| 言語 | TypeScript Scss |
| WebAPI | CanvasAPI WebAudioAPI GamepadAPI |
| lintツール | Biome Stylelint |
| 対応機種 | Windows PC(Windows 11) Mac(latest macOS) iPhone(latest iOS) iPad(latest iPadOS) |
| 対応ブラウザ | Chrome Edge Safari |
| 接続可能外部コントローラー | 8BitDo Arcade Stick 8BitDo ZEROゲームパッド コントローラー Xbox Eliteワイヤレス コントローラー シリーズ 2 Backbone One PlayStation®版 |
自作ステージ編集ツールとは
Nuxtで実装したシューティングゲームのステージを作成・編集するツールです
現時点ではPCのみの対応です
| フレームワーク | Nuxt4 |
|---|---|
| ステータス管理 | Pinia |
| バージョン管理 | Proto |
| テストフレームワーク | Vitest |
| lintツール | Eslint Stylelint |
| 対応機種 | Windows PC(Windows 11) Mac(latest macOS) |
| 対応ブラウザ | Chrome Edge Safari |
構成
このシューティングゲームは、Nuxtで実装したステージ編集ツールと連携してます
今年も幾つか改修してますが、主だった内容だけピックアップします
過去記事は以下になります
2. ゲーム側:コントローラーのボタンマッピング対応
開発者自身が「一番気持ちよく」遊ぶために
2022年にGamepad APIに対応した当初、ボタン配置はコード内で固定(ハードコーディング)していました。 しかし、開発検証やテストプレイを重ねる中で、「Xbox配列(下がA)」のコントローラーを使う日もあれば、「任天堂配列(右がA)」のものを使う日もあります。そのたびに「決定ボタン」や「ショット」の感覚がズレてしまい、開発者である私自身が誤操作によるストレスを感じていました。
そこで、「自分がプレイする時に、どんなコントローラーでも一番しっくりくる配置で遊びたい」 という、純粋なプレイ環境の快適さを求めてキーコンフィグ機能を実装しました。
手持ちの複数のコントローラーで動作検証を行いました。
▲ 検証に使用した環境。左上から時計回りに、Backbone One、Xbox Elite ワイヤレス コントローラー Series 2、8BitDo Zero 2、8BitDo Arcade Stick
特にアーケードスティックでの操作は、往年のシューティングゲームの雰囲気が出て開発のモチベーションも上がりました。Webブラウザ上のゲームですが、これら全てのコントローラーで遅延なく快適に動作することを確認できています。
実際のコンフィグ画面
実装のポイント
1. ライブラリに頼らず標準APIで実装
既存のラッパーライブラリは使用せず、標準の Gamepad API を用いて実装しています。 デフォルトの割り当ては以下のように設定しつつ、変更された設定はブラウザの LocalStorage に保存しました。
| ボタン | 操作 |
|---|---|
| Aボタン | SHOT / MISSILE(ショット / ミサイルショット) |
| Bボタン | POWER UP(パワーアップ) |
| LB | MENU(メニュー開閉) |
2. コントローラーごとの「ボタン表記」の出し分け
今回のこだわりの一つが、接続されたコントローラーに応じたボタンアイコンの自動切り替えです。 gamepad.id の文字列を解析し、以下の3パターンを出し分けています。
| コントローラー | 表記 |
|---|---|
| PlayStation系 | ×, ○, □, △ 表記 |
| Nintendo Switch系 | 右が A、下が B の配置 |
| その他(Xbox準拠) | 下が A、右が B の配置 |
実装イメージ
// ゲームコントローラー定義
export const gamepadDeviceProfiles: GamepadDeviceProfiles = {
Xbox: {
type: 'xbox', // XBOX用のボタンタイプ
buttonMappingClass: DefaultGamepad, // ボタンマッピング定義
},
'Joy-Con': {
type: 'nintendo', // Nintendo Switch用のボタンタイプ
buttonMappingClass: DefaultGamepad, // ボタンマッピング
},
'DUALSHOCK 4': {
type: 'playstation',
buttonMappingClass: DefaultGamepad,
},
...
};
import { DefaultGamepad } from '@/components/device-handler/gamepad/profiles/default';
import { gamepadDeviceProfiles } from '@/components/device-handler/gamepad/gamepad-device-profiles';
// ボタンタイプを取得
export const getGamepadType = (gamepadId: GamepadID): ButtonType => {
// 対応デバイス名がある場合は、gamepadTypeからゲームパッドのタイプを取得
for (const [deviceName, gamepadDeviceProfile] of Object.entries(
gamepadDeviceProfiles,
)) {
if (gamepadId.includes(deviceName)) {
// 対応デバイス名が見つかった(部分一致含む)場合、そのタイプを返す
return gamepadDeviceProfile?.type ?? 'xbox';
}
}
// 対応デバイス名がない場合は、デフォルトタイプ
return 'xbox';
};
import { GamepadProfile } from '@/components/device-handler/gamepad/profiles/abstract';
import type {
ButtonKey,
DirectionKey,
} from '@/components/device-handler/gamepad/type';
export class DefaultGamepad extends GamepadProfile {
protected axesDeadzone = 0.5 as const;
// ゲームパッドのボタンの状態を取得する
public getButtonStatus(gp: Gamepad): Record<ButtonKey, boolean> {
return {
A: gp.buttons[0]?.pressed ?? false,
B: gp.buttons[1]?.pressed ?? false,
X: gp.buttons[2]?.pressed ?? false,
Y: gp.buttons[3]?.pressed ?? false,
LB: gp.buttons[4]?.pressed ?? false,
RB: gp.buttons[5]?.pressed ?? false,
LT: gp.buttons[6]?.pressed ?? false,
RT: gp.buttons[7]?.pressed ?? false,
START: gp.buttons[9]?.pressed ?? false,
HOME: gp.buttons[16]?.pressed ?? false,
};
}
// ゲームパッドの方向キーの状態を取得する
public getDirectionalStatus(gp: Gamepad): DirectionKey | null {
const x = gp.axes[0] ?? 0; // right = 1, left = -1
const y = gp.axes[1] ?? 0; // down = 1, up = -1
const mag = Math.hypot(x, y);
// ヒステリシス: 以前方向がある場合は exit閾値で解除
if (this.lastDirection) {
if (mag < this.axesDeadzoneExit) {
this.lastDirection = null;
return null;
}
} else {
// 初回入力は enter閾値で判定
if (mag < this.axesDeadzoneEnter) {
return null;
}
}
// ラジアンで8分割(octant)に丸める方法 — シンプルで高速
const rad = Math.atan2(-y, x);
const octant = Math.round(rad / (Math.PI / 4)); // -4..4
const dir = ((): DirectionKey => {
switch (octant) {
case 0:
return 'DIRECTION_R';
case 1:
return 'DIRECTION_RU';
case 2:
return 'DIRECTION_U';
case 3:
return 'DIRECTION_LU';
case 4:
case -4:
return 'DIRECTION_L';
case -3:
return 'DIRECTION_LD';
case -2:
return 'DIRECTION_D';
case -1:
return 'DIRECTION_RD';
default:
return 'DIRECTION_R';
}
})();
this.lastDirection = dir;
return dir;
}
}
gamepad.id はブラウザやOSによって返す文字列が曖昧なため現在も調整中ですが、「自分の手元のコントローラーと同じアイコンが画面に出る」だけで、操作の迷いがなくなり、没入感が大きく変わりました。
3. ステージ編集ツールのプレビュー画面リサイズ
もう一点、開発体験を向上させる機能として、ステージ編集ツール上のプレビュー画面をリサイズできるようにしました。 具体的には、プレビュー領域をダブルクリックすると画面いっぱいに拡大し、もう一度ダブルクリックすると元のサイズに戻る仕様です。
▲ 実際の挙動。編集作業からシームレスに没入してテストプレイが可能です
GitHub Copilotによる実装と活用
この機能の実装には GitHub Copilot を活用しました。 プレビュー表示用のタグ一式を用意した状態で、「ダブルクリックでの拡大・縮小」の挙動を追加するよう指示を出しました。
■ GitHub Copilot活用の所感 正直なところ、一発で完璧なコードが生成されたわけではありません。挙動がイマイチで、最初から指示を出し直してやり直させる場面もありました。 しかし、それでも 「自力でゼロから改修するよりは早い場面はある」 というのが実感です。
4. PlaywrightによるE2Eテストの導入
ゲームロジック部分は以前からユニットテストでカバーしていましたが、 「ゲーム起動から描画まで」のテストが薄く、Reactのステート変更やスタイル調整時に意図しないレイアウト崩れが発生しても気づけないことがありました。
これを改善するため、E2Eテストツールの Playwright を導入しました。
テストのスコープとデバイス網羅
現在は「ゲーム起動」→「PC・スマホ別」→「オープニング画面表示」までのフローを自動テストしています。 特にレスポンシブ対応が重要なため、以下のようにデスクトップからiPhone、iPadの各世代、縦横向きまで細かく設定してスナップショットを検証しています。
TypeScript
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
/* Mobile Viewports */
{ name: 'Mobile Chrome portrait', use: { ...devices['Pixel 5'] } },
{ name: 'Mobile Safari portrait', use: { ...devices['iPhone 12'] } },
// iPhone 15 / Pro / Max の各サイズ・向きも網羅
{ name: 'iPhone 15 landscape', use: { ...devices['iPhone 15 landscape'] } },
{ name: 'iPhone 15 Pro Max landscape', use: { ...devices['iPhone 15 Pro Max landscape'] } },
// iPad系
{ name: 'iPad Mini portrait', use: { ...devices['iPad Mini'] } },
{ name: 'iPad Pro 11 landscape', use: { ...devices['iPad Pro 11 landscape'] } },
// ...その他多数
],
これにより、改修時に特定の端末だけでレイアウトが崩れるようなケースも自動検知できるようになり、安心してコードを触れるようになりました。
なお、オープニング以降の「Canvas要素内(ゲームプレイ部分)」については、テストケースが膨大になることと、頻繁な調整によるテストのメンテナンスコストを考慮し、現状は対象外としています。ここはいずれ少しずつ導入したいと考えています。
% bun test-e2e
✨ There's a new version of proto available, 0.54.1 (currently on 0.54.0)
✨ Run proto upgrade or install from https://moonrepo.dev/docs/proto/install
[WebServer] $ vite build --mode "tests-e2e"
[WebServer] $ vite preview
Running 72 tests using 5 workers
42 skipped
30 passed (29.1s)
GitHub Copilotによるテストコード作成支援
Playwrightは非常に強力ですが、ドキュメントが英語中心であることや、「特定の表示が完了するまで待機する(WaitFor)」といった具体的な処理の記述に悩み、導入に時間がかかりそうでした。
ここでもGitHub Copilotを活用し、「この操作をして、この要素が出るまで待つコードを書いて」 といった指示でベースを作成してもらい、それを自分が精査して組み込むようにしてます。
5. フレームワークの刷新:ReactからHonoへ
今年の大きな技術的変更として、アプリケーションの基盤となるフレームワークを React から Hono へと移行しました。
移行の意図
元々このゲームは Canvas API を主体としており、React はUIの描画やルーティング等の「ガワ」の部分を担当していました。しかし、ゲームループそのものには React の仮想DOMやステート管理は必ずしも必要ではありません。
そこで、Web標準に準拠し、より軽量で高速な Hono を採用することにしました。Hono はエッジ環境でも動作する高いパフォーマンスを持ちながら、シンプルで扱いやすいAPIを提供してくれます。
また、コンポーネントのファイル数は多かったものの、機能自体はシンプルに実装していたため、GitHub Copilot の提案を参考にしながら進めることで、移行自体のハードルはそこまで高くありませんでした。
実装済みのReactのHooksもHonoへの移行に問題なさそうという技術的な裏付けがあったことや、「Honoを触ってみたい」という興味本位な側面も、今回の移行を後押ししました。
個人的に嬉しかったのは、ビルドしてバンドルしたjsファイルが100k以上縮小できたことでしょうか。
ReactとHonoのそれぞれのビルド結果(詳細はこちらをクリック)
$ bun run build
$ vite build
vite v7.3.0 building client environment for production...
✓ 801 modules transformed.
../dist/manifest.webmanifest 0.32 kB
../dist/index.html.br 0.38 kB
../dist/index.html 1.07 kB │ gzip: 0.56 kB
../dist/assets/workbox-window.prod.es5-DaSx2M5h.js.br 1.99 kB
../dist/assets/index-DUjVur72.css.br 3.50 kB
../dist/assets/index-BkzTKdHK.js.br 184.46 kB
../dist/assets/index-DUjVur72.css 57.34 kB │ gzip: 4.58 kB
../dist/assets/workbox-window.prod.es5-DaSx2M5h.js 5.73 kB │ gzip: 2.25 kB
../dist/assets/index-BkzTKdHK.js 1,189.44 kB │ gzip: 229.80 kB # ←このファイル
PWA v1.2.0
mode generateSW
precache 56 entries (1223.16 KiB)
files generated
../dist/sw.js
../dist/workbox-1ef09536.js
../dist/icons/shooting_controller_direction.svg.br 0.25 kB
../dist/icons/shooting_icon_reload.svg.br 0.76 kB
$ bun run build
$ vite build
vite v7.3.0 building client environment for production...
✓ 798 modules transformed.
../dist/manifest.webmanifest 0.32 kB
../dist/index.html.br 0.37 kB
../dist/index.html 1.07 kB │ gzip: 0.56 kB
../dist/assets/workbox-window.prod.es5-DaSx2M5h.js.br 1.99 kB
../dist/assets/index-DUjVur72.css.br 3.50 kB
../dist/assets/index-D-6fkJk3.js.br 141.66 kB
../dist/assets/index-DUjVur72.css 57.34 kB │ gzip: 4.58 kB
../dist/assets/workbox-window.prod.es5-DaSx2M5h.js 5.73 kB │ gzip: 2.25 kB
../dist/assets/index-D-6fkJk3.js 1,023.11 kB │ gzip: 179.68 kB # ←このファイル
PWA v1.2.0
mode generateSW
precache 56 entries (1060.72 KiB)
files generated
../dist/sw.js
../dist/workbox-1ef09536.js
../dist/icons/shooting_controller_direction.svg.br 0.25 kB
../dist/icons/shooting_icon_reload.svg.br 0.76 kB
具体的な移行手順
ここでは以下の観点で差分を記載しました
- パッケージの差し替え
- プロダクトコードの差し替え
- ユニットテストコードの差し替え
package.json
{
...
"devDependencies": {
...
- "@testing-library/react": "^16.3.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react-swc": "^4.2.2",
...
},
"dependencies": {
+ "hono": "^4.11.3",
- "react": "^19.2.3",
- "react-dom": "^19.2.3",
"vite": "^7.3.0",
"zustand": "^5.0.9"
}
}
vite.config.ts
...
-import react from '@vitejs/plugin-react-swc';
...
export default defineConfig(({ mode }) => {
return {
...
+ esbuild: {
+ jsxImportSource: 'hono/jsx/dom',
+ },
...
plugins: [
- react(),
vanillaExtractPlugin(),
...
],
...
})
tsconfig.json
{
"compilerOptions": {
...
"jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx/dom",
...
+ "types": ["vite/client", "vite-plugin-pwa/client", "@types/bun", "hono/jsx"]
- "types": ["vite/client", "vite-plugin-pwa/react", "@types/bun"]
}
}
tsx
+import { render } from 'hono/jsx/dom';
-import React from 'react';
-import ReactDOM from 'react-dom/client';
import { App } from '@/app';
+const root = document.getElementById('App') as HTMLElement;
+render(<App />, root);
-ReactDOM.createRoot(document.getElementById('App') as HTMLElement).render(
- <React.StrictMode>
- <App />
- </React.StrictMode>,
-);
+import type { FC } from 'hono/jsx';
-import {
- type LevelType,
- PopupBase,
-} from '@/components/notifications/popup-base';
// Popup.tsx
import {
popupError,
popupInfo,
popupOn,
popupWarning,
} from '@/components/styles/notification.module.css';
+export const Popup: FC<{
-export const Popup: React.FC<{
message: string;
level?: (typeof LevelType)[keyof typeof LevelType];
}> = ({ message, level }) => {
const styleMap = [popupInfo, popupWarning, popupError];
return (
<PopupBase
message={message}
level={level}
styleMap={styleMap}
popupOnClass={popupOn}
/>
);
};
// 参考: https://zustand.docs.pmnd.rs/apis/create-store
+import { createStore } from 'zustand/vanilla';
-import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
...
+const useLevelStore = createStore<LevelState>()(
-const useLevelStore = create<LevelState>()(
devtools(
(set, get) => ({
...
})
),
);
tsxのユニットテスト
import { describe, expect, it } from 'bun:test';
+import { render } from 'hono/jsx/dom';
-import { render, screen } from '@testing-library/react';
import { LevelType, PopupBase } from '@/components/notifications/popup-base';
const mockStyleMap = ['info-style', 'warning-style', 'error-style'];
const popupOnClass = 'popup-on';
+const renderPopup = (node: Parameters<typeof render>[0]) => {
+ const container = +document.createElement('div');
+ render(node, container);
+ return container.firstElementChild as HTMLDivElement | null;
+};
+
describe('PopupBase', () => {
it('INFO レベルが表示されること(デフォルト)', () => {
+ const div = renderPopup(
- render(
<PopupBase
message="hello"
styleMap={mockStyleMap}
popupOnClass={popupOnClass}
/>,
);
+ expect(div?.className).toContain('info-style');
+ expect(div?.className).toContain('popup-on');
- const div = screen.getByText('hello');
- expect(div.className).toContain('info-style');
- expect(div.className).toContain('popup-on');
});
});
GitHub Copilotによる移行の現実と「壁」
今回の移行作業において、最初は GitHub Copilot にReactからHonoへの移行を依頼すれば、ほぼ自動で完了するのではないかと期待していました。 しかし、実際には以下のような問題に直面し、AIだけで完結させることはできませんでした。
- TypeScriptエラーの解消ができず迷走する:
生成されたコードで型エラーが発生し、修正を依頼しても別のエラーが発生するイタチごっこになりました。解決策が見つからないと判断したのか、最終的には**「Honoへの移行は諦めて、Preactや他のフレームワークにしませんか?」** と提案される場面もありました。 - 公式ドキュメントとの乖離:
Copilotが提案してきた vite.config.ts などの設定内容が、Honoの公式ドキュメント(最新の推奨設定)と微妙に異なっており、そのままでは動作しませんでした。
結局、GitHub Copilotには「大枠のコード生成」までを担当してもらい、細かいTypeScriptの型定義や設定周りの整合性は、人間(自分)が公式ドキュメントを読み込んで補正することで実現しました。
GitHub Copilotには、作業の初速を上げるには非常に強力ですが、フレームワークの根幹に関わる換装のようなタスクでは、まだ「丸投げ」は難しく、人間の知識によるレビューと補正が不可欠だと感じました。
Hono移行の課題:デバッグツールの不在
一方で、Hono(Hono/jsx)への切り替えで明確にデメリットを感じた点もありました。それはランタイムでのコンポーネント状態の検証です。
React であれば、Chrome 拡張機能の React Developer Tools を使い、ブラウザ上で各コンポーネントの Props や State の変化を視覚的に追うことができますが、Hono には同等のエコシステムがまだありません。
(もしあれば教えて頂きたいです)
解決策:設計とテストによる品質担保
ただ、これについては「ツールに頼る」のではなく、「設計とテストでカバーする」という方針で開発を進めることにしました。
- 設計
コンポーネントを可能な限りミニマル(単機能)に保ち、複雑な状態を持たせない。 - テスト
動作検証をブラウザでの目視やツール確認に頼るのではなく、ユニットテストや Playwright での自動テストで担保する。
これは大規模プロダクト開発などでも推奨されるプラクティスですが、デバッグツールがない環境になったことで、個人開発においても「テストありきの堅牢な設計」を意識する良いきっかけになったと感じています。
6. まとめ
開発環境を整え、次へ進む
今年は、ゲームのロジックそのものだけでなく、「開発者自身がいかに快適に、安心して開発を続けられるか」 という点に注力した一年でした。
- コントローラー対応: 自分が一番気持ちよくプレイできる環境を作る
- プレビュー機能: 編集作業のストレスを減らす
- E2Eテスト: 変更に対する恐怖心を取り除く
- フレームワーク刷新: 軽量なHonoへ移行し構成をシンプルに
これらは一見、ゲームの面白さに直結しない「裏側」の作業に見えますが、個人開発を長く続ける上では、モチベーションを維持するために不可欠な投資だったと感じています。
7. 来年に向けて
1. 3Dレンダリング機能の追加
今年行ったリファクタリングにより、すでに「ゲームロジック」と「描画処理」を分離する構成ができています。 このアーキテクチャの利点を活かし、現在の2D Canvas描画に加え、3Dレンダリングを行う機能を追加実装したいと考えています。ロジック側には手を入れず、「描画層を3Dレンダリング実装に差し替える(あるいは追加する)だけ」で動く状態を目指し、設計の正しさを検証する予定です。
2. AIと「共創」する開発スタイルの確立
今年は React から Hono への移行などでGitHub CopilotやGeminiなど多用しましたが、同時に「彼らへの丸投げは難しく、人間の知識による補正が不可欠」を感じました。
この経験を活かし、来年はフレームワーク換装などの大きなタスクにおいて、「AIに任せる領域」と「人間が責任を持つ領域」 の最適なバランスを探求しようと考えてます。
このゲームはあくまで調査・研究用のツールなので、上記を意識して実務に活かせれば良いなと考えてます。





