2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LaravelサイトにReactで4択クイズゲームを埋め込んだら、v-istレベルのUIになった話🎮

2
Posted at

LaravelサイトにReactで4択クイズゲームを埋め込んだら、v-istレベルのUIになった話

はじめに

バイク中古・新車一括検索プラットフォーム「MotoHub」を個人開発しています。

Google 3月コアアップデートで週間ユーザーが35%減しました。

検索流入が減る中で「検索以外でユーザーを呼べるコンテンツ」が必要になり、たどり着いたのがバイク4択クイズゲームです。

進学塾ヴィストの4択クイズ(v-ist.com)を見て「このクオリティで作れば、バイクメディアからリンクをもらえるかもしれない」と思い、1日で実装しました。

image.png

MotoHubのバイク4択クイズで遊ぶ


なぜクイズなのか

コアアップデート後に「アグリゲーターからバイク購入の意思決定プラットフォームへ」という方針転換をしました。クイズはその一環です。

施策 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の useStatephase を管理して、画面を切り替えます。ルーティングは不要です。

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>

image.png

お助けアイテム

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枚を一気に生成、という流れで進めました。

image.png

予想以上のクオリティでした。「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}
/>

phaseisCorrect の組み合わせで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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?