LaravelサイトにReactで4択クイズゲームを埋め込んだら、v-istレベルのUIになった話
はじめに
バイク中古・新車一括検索プラットフォーム「MotoHub」を個人開発しています。
Google 3月コアアップデートで週間ユーザーが35%減しました。
検索流入が減る中で「検索以外でユーザーを呼べるコンテンツ」が必要になり、たどり着いたのがバイク4択クイズゲームです。
進学塾ヴィストの4択クイズ(v-ist.com)を見て「このクオリティで作れば、バイクメディアからリンクをもらえるかもしれない」と思い、1日で実装しました。
なぜクイズなのか
コアアップデート後に「アグリゲーターからバイク購入の意思決定プラットフォームへ」という方針転換をしました。クイズはその一環です。
| 施策 | SEO効果 | バイラル性 | 工数 |
|---|---|---|---|
| ブログ記事 | ○ | △ | 中 |
| ランキングニュース | ○ | ○ | 中 |
| 4択クイズ | △ | ◎ | 大 |
| X自動投稿 | △ | ○ | 小 |
クイズは検索流入よりもSNS拡散・被リンク獲得を狙うコンテンツです。「このクイズ面白い」とバイクメディアに紹介されれば、ドメインパワーの底上げになります。
そして最大の差別化ポイントは、MotoHubが持つ実データを使えること。中古価格の平均、売れ筋ランキング、売却日数、地域別人気——これは他のクイズサイトには作れません。
アーキテクチャ:LaravelにReactを"アイランド"で載せる
MotoHub全体はLaravel + Bladeの従来型MVCです。クイズページだけReactで作り、部分的にSPA化しました。
MotoHub 全体
├── Laravel + Blade(従来型ページ遷移)
│ ├── /bikes → 車両検索
│ ├── /ranking → 売れ筋ランキング
│ ├── /news → ニュース
│ ├── /blog → ブログ
│ └── ...
│
└── /quiz → Blade でマウントポイントを出力
→ React が #bike-quiz-root を乗っ取る
→ 以降はSPA(カテゴリ選択→出題→回答→結果)
この「必要なページだけReactを使う」パターンをアイランドアーキテクチャと呼びます。
サイト全体をNext.jsに移行する必要はなく、LaravelのBladeテンプレートの中にReactの島を浮かべるだけ。既存のSEO資産(11.4万ページのインデックス)を壊さずに、リッチなUI体験を追加できます。
Laravel + Vite + React のセットアップ
1. パッケージ追加
npm install react react-dom
npm install -D @vitejs/plugin-react
2. vite.config.js
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/js/app.js', // 既存
'resources/js/quiz-app.jsx', // 追加
],
}),
react(), // ← これだけ追加
],
});
ポイントは react() プラグインを追加するだけで、JSX記法が使えるようになること。TypeScriptにする必要もありません。
3. エントリーポイント
// resources/js/quiz-app.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import BikeQuiz from './components/BikeQuiz';
const el = document.getElementById('bike-quiz-root');
if (el) {
createRoot(el).render(<BikeQuiz />);
}
document.getElementById で既存のBladeが出力した要素を取得して、そこにReactをマウント。Bladeの世界とReactの世界の接続点はここだけです。
4. Bladeテンプレート
{{-- resources/views/quiz/index.blade.php --}}
<x-layout>
<div id="bike-quiz-root"></div>
@push('scripts')
@viteReactRefresh
@vite('resources/js/quiz-app.jsx')
@endpush
</x-layout>
@viteReactRefresh を忘れるとHMR(ホットモジュールリプレースメント)が効かないので注意。開発中に「画面が更新されない…」とハマります。
5. ルーティング
// routes/web.php
Route::view('/quiz', 'quiz.index')->name('quiz');
これだけです。Laravelのコントローラーすら不要。ビューを返すだけ。
ゲーム設計:v-istを参考にした4つの画面
v-istの4択クイズを徹底的にスクショで分析して、以下の画面構成にしました。
画面遷移
SELECT(カテゴリ選択)
↓ カテゴリタップ
READY(スタート画面)
↓ START!
PLAY(出題中)← タイマー動作中
↓ 選択肢タップ
RESULT_ANS(正解/不正解表示)
↓ 次の問題 or 結果を見る
PLAY に戻る or FINAL(最終結果)
Reactの useState で phase を管理して、画面を切り替えます。ルーティングは不要です。
const PHASES = { SELECT: 0, READY: 1, PLAY: 2, RESULT_ANS: 3, FINAL: 4 };
const [phase, setPhase] = useState(PHASES.SELECT);
4つのカテゴリ
MotoHubの実データから4カテゴリのクイズを作りました。
| カテゴリ | 内容 | 例題 |
|---|---|---|
| 💰 中古価格 | 車種の平均中古価格を当てる | 「Z900RSの中古平均価格は?」 |
| 🏆 売れ筋ランキング | 今売れている車種を当てる | 「250ccで最も売れているのは?」 |
| ⚡ 売却スピード | 何日で売れるか当てる | 「レブル250の平均売却日数は?」 |
| 📍 地域別人気 | どこで人気か当てる | 「北海道で最も人気のカテゴリは?」 |
将来的にはLaravel APIから動的に問題を取得しますが、プロトタイプ段階ではハードコードです。
UI実装:こだわったポイント
タイマー(分:秒:コンマ秒)
v-istと同じく、10ms単位のタイマーを表示します。
useEffect(() => {
if (timerRunning && !timerStopped) {
timerRef.current = setInterval(() => setTimer(t => t + 1), 10);
}
return () => clearInterval(timerRef.current);
}, [timerRunning, timerStopped]);
setInterval の精度はブラウザによって異なりますが、ゲームのスコアボード程度なら問題ありません。
鋲(リベット)付きボタン
v-istの選択肢ボタンには、角に鋲のようなデザインがあります。これを再現しました。
const Rivet = ({ pos }) => {
const styles = {
TL: { top: 8, left: 8 },
TR: { top: 8, right: 8 },
BL: { bottom: 8, left: 8 },
BR: { bottom: 8, right: 8 },
};
return (
<span style={{
position: "absolute",
width: 10, height: 10, borderRadius: "50%",
background: "rgba(0,0,0,0.25)",
boxShadow: "inset 0 1px 2px rgba(0,0,0,0.4)",
...styles[pos],
}} />
);
};
// 使い方
<button style={{ position: "relative" }}>
<Rivet pos="TL" /><Rivet pos="TR" />
<Rivet pos="BL" /><Rivet pos="BR" />
約58万円
</button>
お助けアイテム
v-istの「2択にしてあげる!」と「ストップしてあげる!」を再現。
const useHalfHelp = () => {
if (helpHalf <= 0 || selected !== null) return;
const curr = questions[qIndex];
// 不正解の選択肢からランダムに2つを非表示にする
const wrong = curr.choices
.map((_, i) => i)
.filter(i => i !== curr.answer && !hiddenChoices.includes(i));
const toHide = shuffle(wrong).slice(0, 2);
setHiddenChoices(toHide);
setHelpHalf(h => h - 1);
};
非表示にした選択肢は破線ボーダーの空欄として表示。完全に消すとレイアウトがガタつくので、スペースは維持しています。
if (hidden) {
return (
<div style={{
padding: "16px 20px", borderRadius: 14,
background: "rgba(255,255,255,0.03)",
border: "2px dashed rgba(255,255,255,0.08)",
textAlign: "center",
color: "rgba(255,255,255,0.15)",
}}>
─ ─ ─
</div>
);
}
正解/不正解オーバーレイ
v-istでは正解時に大きな「正解!」、不正解時に「ミスッ!」と表示されます。これをCSSアニメーションで再現。
{showOverlay && (
<div style={{
position: "fixed", top: 0, left: 0, right: 0, bottom: 0,
display: "flex", alignItems: "center", justifyContent: "center",
pointerEvents: "none", zIndex: 100,
}}>
<div style={{
width: 180, height: 180, borderRadius: "50%",
border: `8px solid ${isCorrect ? "#2ECC40" : "#FF4136"}`,
animation: "mq-correctBounce 0.6s ease-out",
}}>
<span style={{ fontSize: 52, fontWeight: 900 }}>
{isCorrect ? "正解!" : "ミス!"}
</span>
</div>
</div>
)}
pointerEvents: "none" がポイント。オーバーレイの下にある「次の問題」ボタンをタップできるようにしています。
ランダムセリフ
毎回同じセリフだとつまらないので、キャラクターのセリフをランダムに変化させています。
const SPEECH = {
thinking: ["よく考えてね!", "どれかな〜?", "わかるかな?"],
correct_main: ["よく出来たネ!", "さすが!", "バイク博士だね!"],
wrong_main: ["ドンマイ!", "残念…!", "惜しい〜!"],
};
const pickSpeech = (key) =>
SPEECH[key][Math.floor(Math.random() * SPEECH[key].length)];
キャラクター制作:Geminiの画像生成が想像以上だった
v-istのキャラクター(ドクロ+ロボ)に相当するマスコットが欲しかったので、Geminiの画像生成で作りました。
プロンプト設計
Claude(私がメインで使っているAI)に「Geminiに渡す用のキャラクター指示書」を作ってもらいました。AIにAIへの指示を書かせる、メタな使い方です。
指示書のポイント:
- 2体のキャラクター設計(メイン:バイクロボくん / サブ:ヘルメットくん)
- 各キャラ3表情(通常・正解・不正解)=計6枚
- 500×500px、透過PNG
- MotoHubのブランドカラー(赤 #FF4136、オレンジ #FF851B)
- v-istのスクショを参考画像として添付
生成結果
1枚目(通常ポーズ)を生成して方向性を確認 → OKなら残り5枚を一気に生成、という流れで進めました。
予想以上のクオリティでした。「MOTOHUB 01」のロゴが入っていたり、足がタイヤになっていたり、プロンプトの意図を汲み取った仕上がりです。
透過背景の罠
Geminiが生成した画像は一見透過に見えますが、実際はRGB(アルファチャンネルなし)で、チェッカー柄が灰色のピクセルとして焼き込まれていました。
img = Image.open("character.png")
print(img.mode) # 'RGB' ← RGBAじゃない!
解決策:scipy の ndimage.label で連結成分分析を行い、画像の端に接している灰色領域だけを透過にしました。
from scipy import ndimage
# 灰色ピクセルを検出
is_gray = (np.abs(r-g) < 15) & (np.abs(g-b) < 15) & (r > 170)
# 連結成分分析
labeled, _ = ndimage.label(is_gray)
# 画像の端に接している成分 = 背景
border_labels = set()
border_labels.update(labeled[0, :].flatten()) # 上辺
border_labels.update(labeled[-1, :].flatten()) # 下辺
border_labels.update(labeled[:, 0].flatten()) # 左辺
border_labels.update(labeled[:, -1].flatten()) # 右辺
border_labels.discard(0)
# 背景だけ透過にする(キャラ内部の灰色は残す)
bg_mask = np.isin(labeled, list(border_labels))
data[bg_mask] = [0, 0, 0, 0]
「端に接している灰色だけ消す」のがコツです。単純に「灰色を全部消す」とキャラの影やディテールまで消えてしまいます。
キャラ画像の配置と切り替え
表情に応じてキャラ画像を切り替えるコンポーネント:
const CHAR_IMAGES = {
main: {
normal: "/images/quiz/main-normal.png",
correct: "/images/quiz/main-correct.png",
wrong: "/images/quiz/main-wrong.png",
},
sub: {
normal: "/images/quiz/sub-normal.png",
correct: "/images/quiz/sub-correct.png",
wrong: "/images/quiz/sub-wrong.png",
},
};
const CharacterSprite = ({ type, mood, size = 80 }) => {
const src = CHAR_IMAGES[type]?.[mood];
return <img src={src} alt={`${type}-${mood}`}
style={{ width: size, height: size, objectFit: "contain" }} />;
};
使う側:
<CharacterSprite
type="main"
mood={phase === PHASES.RESULT_ANS
? (isCorrect ? "correct" : "wrong")
: "normal"}
size={70}
/>
phase と isCorrect の組み合わせで3表情を出し分けます。
吹き出し(スピーチバブル)
v-istではキャラクターが吹き出しでセリフを喋ります。CSSだけで吹き出しのしっぽを実装:
const CharSpeech = ({ text, color = "#333", tailDirection = "down" }) => (
<div style={{
position: "relative", padding: "8px 16px", borderRadius: 14,
background: color, color: "#fff", fontSize: 13, fontWeight: 800,
marginBottom: tailDirection === "down" ? 10 : 0,
}}>
{text}
{/* しっぽ */}
<div style={{
position: "absolute", bottom: -8, left: "50%",
transform: "translateX(-50%)",
width: 0, height: 0,
borderLeft: "8px solid transparent",
borderRight: "8px solid transparent",
borderTop: `8px solid ${color}`,
}} />
</div>
);
三角形は border の透明テクニック。CSSの古典的なハックですが、吹き出しには今でも一番手軽です。
モバイル対応でハマったこと
横スクロール問題
DevToolsのモバイルエミュレーターで横スクロールが発生。原因は padding + width: 100% の組み合わせ。
// NG: paddingの分だけはみ出す
<div style={{ width: "100%", padding: "0 16px" }}>
// OK: box-sizingを指定
<div style={{ width: "100%", padding: "0 16px", boxSizing: "border-box" }}>
// さらにOK: 親にmax-widthとoverflowXを設定
<div style={{ maxWidth: "100vw", overflowX: "hidden" }}>
正解バブルの文字あふれ
モバイルで「正解は アフリカツイン だよ!」が改行されまくって縦長に。車種名を display: "block" で改行させて解決。
<div>
正解は
<span style={{
display: "block",
fontSize: 18, fontWeight: 900,
overflowWrap: "anywhere",
}}>
{curr.choices[curr.answer]}
</span>
だよ!
</div>
ビルドサイズ
quiz-app-XXXXX.js 223.77 kB │ gzip: 68.88 kB
React + ReactDOM + クイズロジック + キャラ画像パス、全部込みで gzip 69KB。初回ロードは十分高速です。
既存の app.js(83KB)とは別のエントリーポイントなので、クイズページ以外には影響しません。
今後の展開
Phase 2:Laravel APIからの問題取得
GET /api/quiz/categories → カテゴリ一覧
GET /api/quiz/questions/{id} → 問題データ(DBから動的生成)
POST /api/quiz/results → スコア保存&ランキング
MotoHubのDBには22.8万件のバイクデータがあるので、「この車種の中古平均価格は?」のような問題をリアルタイムに生成できます。
Phase 3:SNS共有
「100点満点!バイクマスター!」のような結果画像を動的生成して、Xでシェアできるようにする予定です。OGP画像生成は既にブログ機能で実装済みなので、流用できます。
Phase 4:ランキング
ユーザーのスコアと時間を保存して、週間ランキングを表示。リピート訪問を促す仕組み。
まとめ
| やったこと | 詳細 |
|---|---|
| LaravelにReactを部分導入 | Vite + @vitejs/plugin-react |
| 4択クイズゲーム | タイマー、スコア、お助けアイテム |
| キャラクター制作 | Geminiで6枚生成、scipy で背景除去 |
| モバイル対応 | 横スクロール・文字あふれ修正 |
1日で実装できるのか? と聞かれたら、正直かなりキツかったです。ただ、Claudeにプロトタイプを作ってもらい、Geminiにキャラを描いてもらい、Claude Codeで本番統合するというAIフル活用の開発フローなら可能でした。
2025年まで花屋をやっていた非エンジニアが、AIの力を借りてここまで作れる時代です。
前回の記事:車種詳細ページに「売れ筋ランキング」を埋め込んだら回遊率が変わった話
🏍 MotoHub: https://motohub.jp
🎮 バイク4択クイズ: https://motohub.jp/quiz
X: https://x.com/motohub_jp
GitHub: https://github.com/ausssxi/MotoHub


