バイク版2048パズルをReactで1日で作った話
はじめに
個人開発のバイクポータルサイト「MotoHub」を運営しています。
ユーザーの滞在時間とリピート率を上げるため、ゲームコンテンツを充実させています。第1弾の「バイクわらしべ長者」、第2弾の「バイク4択クイズ」に続き、第3弾としてバイク版の2048パズルを作りました。
▶️ バイク2048パズル
コンセプト
通常の2048は数字(2→4→8→...→2048)を合体させますが、バイク版はバイクを合体させてグレードアップ:
カブ50 → ハンターカブ → PCX → Ninja250 → GB350
→ MT-07 → Z900 → MT-09 → ハヤブサ → ZX-10R → ゴールドウイング
全11段階。原付から始めて、最終目標のゴールドウイングを目指します。
技術構成
クイズ・わらしべと同じ構成を踏襲しました。
- React(島アーキテクチャでLaravel Bladeに埋め込み)
- Viteでビルド
- Tailwind CSSでスタイリング
- localStorageでゲーム状態を保存
resources/
views/puzzle/index.blade.php ← Bladeテンプレート
js/puzzle-app.jsx ← Reactエントリーポイント
js/components/BikePuzzle.jsx ← メインコンポーネント(約500行)
public/
images/puzzle/level-1.png〜level-11.png ← バイク画像
audio/puzzle/bgm.mp3, move.mp3, etc. ← 効果音
3つのゲームが全てReact + 同じビルドパイプラインで統一されているので、保守が楽です。
画像は「わらしべ長者」から流用
11枚必要な画像のうち、10枚はわらしべ長者で使ったGemini生成イラストをそのまま流用。新規作成はゴールドウイング1枚だけで済みました。
cp backend/public/images/warashibe/bike_cub50.png backend/public/images/puzzle/level-1.png
cp backend/public/images/warashibe/bike_ct125.png backend/public/images/puzzle/level-2.png
# ...10枚コピー
# ゴールドウイングだけGeminiで新規生成
ゲーム内のバイクラインナップをわらしべの車種に合わせることで、画像制作コストをほぼゼロにしています。
スライドアニメーション
瞬間移動問題
最初の実装では、タイルの移動が「瞬間移動」に見えてしまい、ゲーム感がありませんでした。
2段階アニメーションで解決
合体を2段階に分けることで、滑らかなスライドを実現しました。
Step 1(0〜300ms):移動フェーズ
- CSS
transition: transform 0.3s easeでタイルがスライド - 合体元のタイルも移動先まで滑る(まだ消さない)
- 一時的に同じセルに2つのタイルが重なる
Step 2(300ms後):合体フェーズ
- 重なっていた2つのタイルを1つに統合
-
scale 1→1.2→1のポップアニメーション - スコア加算
.tile {
transition: transform 0.3s ease-in-out;
}
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
最初は0.15sにしていましたが、早すぎて瞬間移動に見えたので0.3sに調整。このチューニングは実際に操作して確かめるしかありません。
モバイル対応:AutoPlay Policy
BGMが鳴らない
モバイルブラウザではユーザーインタラクション前に音声再生が禁止されます(AutoPlay Policy)。普通にAudioオブジェクトを再生しようとしても無音のまま。
「タップしてスタート」で解決
ゲーム開始時にオーバーレイを表示し、ユーザーがタップした瞬間に全音声ファイルをvolume=0で再生してブラウザのロックを解除する方式にしました。
const unlockAudio = () => {
const files = ['move', 'gameover', 'clear', 'bgm'];
files.forEach(name => {
const audio = new Audio(`/audio/puzzle/${name}.mp3`);
audio.volume = 0;
audio.play().then(() => audio.pause()).catch(() => {});
});
setAudioUnlocked(true);
};
タップ → 音声ロック解除 → オーバーレイ非表示 → BGM再生開始。この流れで、わらしべ・クイズでも同じパターンを使っています。
localStorageのバリデーション
壊れたデータでクラッシュ
開発中、localStorageに保存されたゲームデータが壊れていると、Reactが初期化時に Cannot read properties of undefined (reading '0') でクラッシュする問題が発生しました。
バリデーション関数で防御
function isValidGrid(grid) {
if (!Array.isArray(grid) || grid.length !== 4) return false;
return grid.every(row =>
Array.isArray(row) && row.length === 4 &&
row.every(cell => typeof cell === 'number' && cell >= 0 && cell <= 11)
);
}
const [grid, setGrid] = useState(() => {
try {
const saved = localStorage.getItem('motohub_puzzle');
if (saved) {
const data = JSON.parse(saved);
if (isValidGrid(data.grid)) return data.grid;
}
} catch (e) {
localStorage.removeItem('motohub_puzzle');
}
return initialGrid();
});
4×4配列かつ全要素が0〜11の数値であることを検証。壊れたデータは自動削除してリセットします。
localStorageに保存するゲームでは、復元時のバリデーションは必須です。Phase 1(基本実装)の段階で入れ忘れると、本番でユーザーが白画面になります。
タイルサイズの固定
画像サイズでタイルが変わる問題
バイクのイラスト画像をタイルに表示すると、画像のアスペクト比によってタイルのサイズがバラバラになってしまいました。
グリッドでサイズを決める
タイルのサイズはグリッドが決め、画像はその中に収めるだけ、という設計にしました。
.grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 8px;
aspect-ratio: 1;
}
.tile {
aspect-ratio: 1;
position: relative;
overflow: hidden;
}
.tile img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
画像がタイルを「押し広げない」ことが重要です。
開発のタイムライン
| フェーズ | 内容 | 時間 |
|---|---|---|
| 指示書作成 | Claude Codeへの実装指示書(MD) | 30分 |
| Phase 1 | 基本ゲーム(グリッド・移動・合体・スコア) | 2時間 |
| Phase 2 | モバイル対応・アニメーション・音声 | 2時間 |
| 画像配置 | わらしべから10枚コピー + ゴールドウイング生成 | 30分 |
| デバッグ | localStorage、タイルサイズ、アニメーション調整 | 1時間 |
Claude Codeに詳細な指示書(Markdown)を渡して、Phase 1→動作確認→Phase 2→動作確認、と段階的に進めました。指示書を事前に作り込んでおくと、Claude Codeが一発で正しい実装をしてくれる確率が上がります。
SNS投稿の反応
TikTokにプレイ動画を投稿したところ、初投稿で500再生・10いいねを獲得。その後も再生数・フォロワーが伸び続けています。
ゲームコンテンツはSNSとの相性が良く、動画映えするのが強みです。「バイク好き」というニッチなターゲットに刺さるコンテンツを作れるのは、個人開発ならではの機動力だと思います。
まとめ
- バイク版2048は、既存のわらしべ画像を流用して画像コスト最小化
- スライドアニメーションは0.3sが体感的にちょうど良い
- モバイルAutoPlay Policyは「タップしてスタート」オーバーレイで解決
- localStorageのバリデーションは必須(壊れたデータで白画面になる)
- タイルサイズはグリッドで固定、画像はその中に収める
- TikTokとの相性が良い
ゲーム系コンテンツは「サイトに来る理由」を作れるので、SEOだけに頼らないトラフィック獲得手段として有効です。
MotoHub: https://motohub.jp
バイク2048パズル: https://motohub.jp/puzzle
バイクわらしべ長者: https://motohub.jp/warashibe
バイク4択クイズ: https://motohub.jp/quiz

