freeeの開発情報ポータルサイト

童謡の歌詞で神経衰弱ゲームを作ってみた

真夏の自由研究〜AIを使って雑にアプリを作ろう!〜 4日目は、請求書プロダクトの開発をしているyoneが担当します!

AIアプリとして私は、多くの人が知っている童謡の歌詞で神経衰弱ができるゲームを作りました。

神経衰弱ゲームの画面。合計6枚のカード、リセットボタン、戻るボタンとスコアが表示されている。
神経衰弱ゲームの画面
神経衰弱でペアをめくった時の画面
神経衰弱でペアをめくった時の画面

作成過程について

AI雑アプリアドベントカレンダーの大まかな方針として

  • AIを活用してアプリをシュッと作る
  • 規模としてはHTMLファイルに直接CSSとJSを書いてGitHub Pagesなどにシュッと上げられるくらい

という2つの軸がありました。

コーディングだけでなくアイディア出しからAIに壁打ちしてもらい、人間は「アイディアがときめくかどうか」と「バグがないかのチェック」を主に行いました。 その中でも特に、アイディア出しからコーディングに繋げる時に、いかに自分が楽をできるかを考えながら進めました。

アイディア出しにはGeminiを利用しています。 Geminiを利用する際は、以下のようなプロンプトでアイディア出しを手伝ってもらいました。

あなたはアイディアを考えるのが得意なシニアエンジニアです。
htmlファイルに直接htmlとcssとJavaScriptを書くという縛りのもと、ちょっと捻ったアイディアのアプリ案を3つ考えてください。

何度かプロンプトを渡して試し、「ユーザーはランニングが好きです」など自分の複数ある趣味を入れて依頼もしてみました。

この中で良さそうな案があれば「3つ目のアイディアで詳細な実装計画を立ててみてください。」といった感じで、HTMLとCSSとJSそれぞれの実装計画を立ててもらいます。

以下は神経衰弱の前にデモで作っていたアスキーアートとタイポグラフィだけで構成された迷路ゲームの実装計画です。

### **「文字で描くタイポグラフィ・ダンジョン」実装計画**

この計画は、HTML、CSS、JavaScriptを1つのファイルに記述することを前提にしています。

#### 1\. HTML構造

ダンジョン全体を表示する`<div>`を1つ用意し、その中にプレイヤーやモンスター、壁、アイテムなどを表現する`<span>`タグを配置します。


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文字のダンジョン</title>
    <style>
        /* ここにCSSを記述 */
    </style>
</head>
<body>
    <div id="game-container">
        </div>
    <script>
        /* ここにJavaScriptを記述 */
    </script>
</body>
</html>


#### 2\. CSSデザイン

ゲームの見た目を整えるためのCSSです。


body {
    background-color: #000;
    color: #0f0;
    font-family: 'Courier New', Courier, monospace;
    font-size: 24px;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    margin: 0;
}

#game-container {
    white-space: pre; /* これが重要!文字の改行とスペースを維持します */
    line-height: 1; /* 行の高さを調整して、文字の間の余白をなくします */
}

/* プレイヤーやモンスターなどの文字にクラスを割り当ててスタイルを変更 */
.player {
    font-weight: bold;
    color: #fff;
    text-shadow: 0 0 5px #fff;
}

.monster {
    color: #f00;
}

.item {
    color: #ff0;
}

/* 戦闘時のアニメーション */
@keyframes blink {
    0% { opacity: 1; }
    50% { opacity: 0; }
    100% { opacity: 1; }
}

.blinking {
    animation: blink 0.5s linear infinite;
}


#### 3\. JavaScriptロジック

ゲームの核となる部分です。以下のステップで実装を進めます。

1.  **ゲームの状態管理**: ダンジョンマップ、プレイヤーの位置、プレイヤーのHP、モンスターの位置とHPなどを管理するオブジェクトを定義します。

    const state = {
        map: [], // 2次元配列でマップデータを保持
        player: { x: 1, y: 1, hp: 10, symbol: '@' },
        monsters: [],
        items: [],
        size: 20 // マップのサイズ (20x20)
    };

2.  **マップ生成**: 迷路生成アルゴリズム(単純なランダム生成でもOK)を使って、`state.map`に壁('\#')と通路('.')を配置します。モンスター('M')やアイテム('i')もランダムな位置に配置します。

3.  **描画関数**: `state.map`の内容を読み込み、HTMLの`#game-container`に文字列として描画する関数を作成します。この際、プレイヤーやモンスターの位置にある文字には専用の`<span>`タグとクラスを付与し、スタイルを適用します。

4.  **キーボード入力の処理**: `keydown`イベントリスナーを設定し、矢印キーが押された時の処理を記述します。

      * プレイヤーの移動先の座標を計算します。
      * 移動先が壁('\#')でなければ、プレイヤーの位置を更新します。
      * 移動先にモンスター('M')がいれば、戦闘ロジックを呼び出します。
      * 移動先にアイテム('i')があれば、アイテム獲得ロジックを呼び出します。
      * 移動やイベントが発生したら、**再描画関数**を呼び出して画面を更新します。

5.  **戦闘ロジック**:

      * プレイヤーがモンスターの位置に移動しようとしたら戦闘開始。
      * プレイヤーとモンスターの文字に`blinking`クラスを付与し、点滅させます。
      * シンプルなターン制バトルを実装します。例えば、プレイヤーの攻撃力をランダムに計算し、モンスターのHPを減らします。
      * モンスターのHPが0になったら、マップからモンスターを削除し、プレイヤーがそのマスに移動できるようにします。
      * 戦闘中はキーボード入力を一時的に無効にします。

6.  **初期化**: ページ読み込み時にこれらの関数を呼び出し、ゲームを開始します。

    document.addEventListener('DOMContentLoaded', () => {
        setupGame(); // マップやキャラクターを初期化
        drawMap(); // 初回描画
        document.addEventListener('keydown', handleInput); // キーボード入力を待機
    });

-----

この実装計画に沿って進めれば、最小限の機能を持つタイポグラフィRPGが完成します。さらに面白さを加えるなら、以下のような機能を追加できます。

  * **インベントリシステム**: プレイヤーが獲得したアイテムを管理・表示する。
  * **より複雑なダンジョン生成**: 部屋と通路を生成するアルゴリズムを導入する。
  * **サウンドエフェクト**: HTML Audio APIを使って、効果音を追加する。

これらの詳細な実装について、さらに知りたい部分があれば、ぜひ教えてください。

このような実装計画をclineに投げると以下のようなアプリが作れました。

ボツになった迷路ゲームの画面
ボツになった迷路ゲームの画面
(このアプリでも良かったのですが、いまいち自分が興味や愛着を持てなかったので、好きなことに全振りすることを決めました。)

Geminiに実装計画を作ってもらわないで進めるパターンも試しました。 最終的に作ると決めた「童謡の歌詞で神経衰弱ゲーム」は、既存の神経衰弱ゲームの土台があること、またアイディア出しにGeminiをあまり使わなかったことから、いきなりclineにプロンプトを投げる形で進めました。

そのときのプロンプトは以下です。

<Goal>
神経衰弱ゲームを作ってください。
</Goal>
<Instructions>
- htmlファイルに直接htmlとcssとJavaScriptを書いてください。
- カードは全部で16枚にしてください。
- カードの内容はハードコーディングしてください。
- カードに表示されるテキストはこのような配列([1, 2, 3, 4, 5, 6, 7, 8])から使用して
- すべてのカードをめくり終わったらゲーム終了。その時点で、ペアを当てた得点で勝敗を決めてください。
<Instructions>

行ってほしいことを明確にするために、タグで囲っています。 これで神経衰弱ゲームを作ってから、カードの表示内容を変えたいと伝え、ハードコーディングする内容を共有してゲームが完成しました。

ソースコードは以下です!

app_memory_game.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>神経衰弱ゲーム</title>
    <style>
        body {
            margin: 0;
            padding: 20px;
            font-family: 'Arial', sans-serif;
            background: linear-gradient(135deg, #FFE4E1, #E6E6FA, #F0F8FF);
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            color: #333;
        }

        #game-container {
            width: 600px;
            min-height: 700px;
            border: 3px solid #4169E1;
            background: linear-gradient(to bottom, #FFFFFF 0%, #F8F8FF 100%);
            position: relative;
            border-radius: 15px;
            box-shadow: 0 15px 40px rgba(0,0,0,0.2);
            padding: 20px;
        }

        #header {
            text-align: center;
            margin-bottom: 20px;
        }

        #header h1 {
            color: #4169E1;
            font-size: 32px;
            margin: 0;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
        }

        .screen {
            display: none;
        }

        .screen.active {
            display: block;
        }

        /* スタート画面 */
        #start-screen {
            text-align: center;
            padding: 40px 20px;
        }

        #start-screen h2 {
            color: #4169E1;
            font-size: 24px;
            margin-bottom: 20px;
        }

        #start-screen p {
            color: #666;
            font-size: 16px;
            line-height: 1.6;
            margin-bottom: 30px;
        }

        .start-btn {
            background: linear-gradient(45deg, #4169E1, #6495ED);
            color: white;
            border: none;
            padding: 15px 30px;
            font-size: 18px;
            font-weight: bold;
            border-radius: 25px;
            cursor: pointer;
            transition: all 0.3s;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
        }

        .start-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(0,0,0,0.3);
        }

        /* ゲーム画面 */
        #game-screen {
            padding: 20px;
        }

        #game-info {
            display: flex;
            justify-content: space-between;
            align-items: center;
            background: rgba(255,255,255,0.8);
            padding: 15px 20px;
            border-radius: 10px;
            margin-bottom: 20px;
            border: 2px solid #E6E6FA;
        }

        .info-item {
            font-size: 18px;
            font-weight: bold;
        }

        .info-label {
            color: #4169E1;
        }

        .info-value {
            color: #333;
            margin-left: 10px;
        }

        #card-grid {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            grid-template-rows: repeat(2, 1fr);
            gap: 20px;
            max-width: 360px;
            margin: 0 auto;
        }

        .card {
            width: 100px;
            height: 100px;
            position: relative;
            cursor: pointer;
            perspective: 1000px;
        }

        .card-inner {
            position: relative;
            width: 100%;
            height: 100%;
            text-align: center;
            transition: transform 0.6s;
            transform-style: preserve-3d;
        }

        .card.flipped .card-inner {
            transform: rotateY(180deg);
        }

        .card.matched .card-inner {
            transform: rotateY(180deg);
        }

        .card.matched {
            cursor: default;
        }

        .card-front, .card-back {
            position: absolute;
            width: 100%;
            height: 100%;
            backface-visibility: hidden;
            border-radius: 10px;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 12px;
            font-weight: bold;
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
            padding: 8px;
            text-align: center;
            line-height: 1.3;
        }

        .card-front {
            background: linear-gradient(45deg, #4169E1, #6495ED);
            color: white;
        }

        .card-front::before {
            content: "?";
            font-size: 30px;
        }

        .card-back {
            background: linear-gradient(45deg, #FFB6C1, #FFC0CB);
            color: #8B0000;
            transform: rotateY(180deg);
            border: 3px solid #FF69B4;
        }

        .card.matched .card-back {
            background: linear-gradient(45deg, #90EE90, #98FB98);
            border-color: #32CD32;
            color: #006400;
        }

        /* 結果画面 */
        #result-screen {
            text-align: center;
            padding: 40px 20px;
        }

        #result-screen h2 {
            color: #4169E1;
            font-size: 28px;
            margin-bottom: 20px;
        }

        .result-info {
            background: rgba(255,255,255,0.9);
            border-radius: 15px;
            padding: 30px;
            margin: 20px 0;
            border: 2px solid #E6E6FA;
        }

        .final-score {
            font-size: 48px;
            font-weight: bold;
            color: #4169E1;
            margin: 20px 0;
        }

        .score-message {
            font-size: 18px;
            color: #666;
            margin-bottom: 20px;
        }

        .control-buttons {
            display: flex;
            gap: 15px;
            justify-content: center;
            margin-top: 20px;
        }

        .control-btn {
            background: linear-gradient(45deg, #32CD32, #228B22);
            color: white;
            border: none;
            padding: 12px 20px;
            border-radius: 20px;
            cursor: pointer;
            font-size: 16px;
            transition: all 0.3s;
        }

        .control-btn:hover {
            transform: translateY(-1px);
        }

        .control-btn.secondary {
            background: linear-gradient(45deg, #FF6B6B, #FF4444);
        }

        /* スコアポップアップ */
        .score-popup {
            position: absolute;
            font-weight: bold;
            font-size: 24px;
            pointer-events: none;
            z-index: 1000;
            animation: scoreFloat 1s ease-out forwards;
        }

        .score-popup.positive {
            color: #4CAF50;
        }

        .score-popup.negative {
            color: #F44336;
        }

        @keyframes scoreFloat {
            0% {
                opacity: 1;
                transform: translateY(0) scale(1);
            }
            50% {
                transform: translateY(-20px) scale(1.2);
            }
            100% {
                opacity: 0;
                transform: translateY(-40px) scale(1);
            }
        }

        /* ルール説明 */
        .rules {
            background: rgba(255,255,255,0.9);
            border-radius: 10px;
            padding: 20px;
            margin: 20px 0;
            border: 2px solid #E6E6FA;
        }

        .rules h3 {
            color: #4169E1;
            margin: 0 0 15px 0;
        }

        .rules ul {
            margin: 0;
            padding-left: 20px;
            color: #666;
        }

        .rules li {
            margin-bottom: 8px;
        }
    </style>
</head>
<body>
    <div id="game-container">
        <div id="header">
            <h1>🧠 神経衰弱ゲーム</h1>
        </div>

        <!-- スタート画面 -->
        <div id="start-screen" class="screen active">
            <h2>ゲームを始めましょう!</h2>
            <p>6枚のカードから3つのペアを見つけてください。<br>
            同じ曲の歌詞同士を2枚選んでペアを作ります。</p>
            
            <div class="rules">
                <h3>📋 ルール</h3>
                <ul>
                    <li>カードには3曲の歌詞が2枚ずつ、計6枚</li>
                    <li>同じ曲の歌詞2枚を選んでペアを作る</li>
                    <li>正解のペア: <strong>+10点</strong></li>
                    <li>不正解: <strong>-2点</strong></li>
                    <li>全てのペアを見つけたらゲーム終了</li>
                </ul>
            </div>
            
            <button class="start-btn" onclick="startGame()">ゲーム開始</button>
        </div>

        <!-- ゲーム画面 -->
        <div id="game-screen" class="screen">
            <div id="game-info">
                <div class="info-item">
                    <span class="info-label">スコア:</span>
                    <span class="info-value" id="score">0</span>
                </div>
                <div class="info-item">
                    <span class="info-label">残りペア:</span>
                    <span class="info-value" id="remaining-pairs">4</span>
                </div>
                <div class="info-item">
                    <span class="info-label">試行回数:</span>
                    <span class="info-value" id="attempts">0</span>
                </div>
            </div>

            <div id="card-grid"></div>

            <div class="control-buttons">
                <button class="control-btn secondary" onclick="resetGame()">リセット</button>
                <button class="control-btn secondary" onclick="backToStart()">メニューに戻る</button>
            </div>
        </div>

        <!-- 結果画面 -->
        <div id="result-screen" class="screen">
            <h2>🎉 ゲーム終了!</h2>
            
            <div class="result-info">
                <div class="final-score" id="final-score">0</div>
                <div class="score-message" id="score-message"></div>
                
                <div style="margin-top: 20px;">
                    <div>試行回数: <strong id="final-attempts">0</strong></div>
                    <div>正解率: <strong id="accuracy">0%</strong></div>
                </div>
            </div>

            <div class="control-buttons">
                <button class="control-btn" onclick="startGame()">もう一度プレイ</button>
                <button class="control-btn secondary" onclick="backToStart()">メニューに戻る</button>
            </div>
        </div>
    </div>

    <script>
        // ゲーム状態
        let gameState = {
            cards: [],
            flippedCards: [],
            matchedPairs: 0,
            score: 0,
            attempts: 0,
            isProcessing: false
        };

        // 歌詞データ(曲名と歌詞のペア)
        const lyricsPairs = {
            "シャボン玉": ["シャボン玉飛んだ", "屋根まで飛んだ"],
            "証城寺の狸囃子": ["証城寺の庭は", "つんつんつるつるつんつるてん"],
            "浜辺の歌": ["あした浜辺を", "さまよえば"]
        };

        // カードデータを歌詞から生成
        const cardData = [];
        Object.entries(lyricsPairs).forEach(([songTitle, lyrics]) => {
            cardData.push({
                songTitle: songTitle,
                lyric: lyrics[0]
            });
            cardData.push({
                songTitle: songTitle,
                lyric: lyrics[1]
            });
        });

        // 画面切り替え
        function showScreen(screenId) {
            document.querySelectorAll('.screen').forEach(screen => {
                screen.classList.remove('active');
            });
            document.getElementById(screenId).classList.add('active');
        }

        // ゲーム開始
        function startGame() {
            // ゲーム状態初期化
            gameState = {
                cards: [],
                flippedCards: [],
                matchedPairs: 0,
                score: 0,
                attempts: 0,
                isProcessing: false
            };

            // カードをシャッフル
            const shuffledCards = [...cardData].sort(() => Math.random() - 0.5);
            
            // カードデータ作成
            gameState.cards = shuffledCards.map((cardInfo, index) => ({
                id: index,
                songTitle: cardInfo.songTitle,
                lyric: cardInfo.lyric,
                isFlipped: false,
                isMatched: false
            }));

            // UI更新
            updateGameInfo();
            createCardGrid();
            showScreen('game-screen');
        }

        // カードグリッド作成
        function createCardGrid() {
            const cardGrid = document.getElementById('card-grid');
            cardGrid.innerHTML = '';

            gameState.cards.forEach(card => {
                const cardElement = document.createElement('div');
                cardElement.className = 'card';
                cardElement.dataset.cardId = card.id;
                cardElement.onclick = () => flipCard(card.id);

                cardElement.innerHTML = `
                    <div class="card-inner">
                        <div class="card-front"></div>
                        <div class="card-back">${card.lyric}</div>
                    </div>
                `;

                cardGrid.appendChild(cardElement);
            });
        }

        // カードをめくる
        function flipCard(cardId) {
            if (gameState.isProcessing) return;
            
            const card = gameState.cards.find(c => c.id === cardId);
            if (!card || card.isFlipped || card.isMatched) return;

            // カードをめくる
            card.isFlipped = true;
            gameState.flippedCards.push(card);
            
            const cardElement = document.querySelector(`[data-card-id="${cardId}"]`);
            cardElement.classList.add('flipped');

            // 2枚めくった場合の処理
            if (gameState.flippedCards.length === 2) {
                gameState.isProcessing = true;
                gameState.attempts++;
                updateGameInfo();

                setTimeout(() => {
                    checkMatch();
                }, 1000);
            }
        }

        // ペアの一致チェック
        function checkMatch() {
            const [card1, card2] = gameState.flippedCards;
            const card1Element = document.querySelector(`[data-card-id="${card1.id}"]`);
            const card2Element = document.querySelector(`[data-card-id="${card2.id}"]`);

            if (card1.songTitle === card2.songTitle) {
                // ペア成功
                card1.isMatched = true;
                card2.isMatched = true;
                card1Element.classList.add('matched');
                card2Element.classList.add('matched');
                
                gameState.score += 10;
                gameState.matchedPairs++;
                
                showScorePopup(card1Element, '+10', 'positive');
                showScorePopup(card2Element, '+10', 'positive');

                // ゲーム終了チェック
                if (gameState.matchedPairs === 3) {
                    setTimeout(() => {
                        endGame();
                    }, 1000);
                }
            } else {
                // ペア失敗
                gameState.score -= 2;
                gameState.score = Math.max(0, gameState.score); // スコアが負にならないように
                
                showScorePopup(card1Element, '-2', 'negative');
                showScorePopup(card2Element, '-2', 'negative');

                // カードを裏返す
                setTimeout(() => {
                    card1.isFlipped = false;
                    card2.isFlipped = false;
                    card1Element.classList.remove('flipped');
                    card2Element.classList.remove('flipped');
                }, 500);
            }

            // 状態リセット
            gameState.flippedCards = [];
            gameState.isProcessing = false;
            updateGameInfo();
        }

        // スコアポップアップ表示
        function showScorePopup(cardElement, text, className) {
            const popup = document.createElement('div');
            popup.className = `score-popup ${className}`;
            popup.textContent = text;
            
            const rect = cardElement.getBoundingClientRect();
            const containerRect = document.getElementById('game-container').getBoundingClientRect();
            
            popup.style.left = (rect.left - containerRect.left + rect.width / 2) + 'px';
            popup.style.top = (rect.top - containerRect.top + rect.height / 2) + 'px';
            
            document.getElementById('game-container').appendChild(popup);
            
            setTimeout(() => {
                if (popup.parentNode) {
                    popup.remove();
                }
            }, 1000);
        }

        // ゲーム情報更新
        function updateGameInfo() {
            document.getElementById('score').textContent = gameState.score;
            document.getElementById('remaining-pairs').textContent = 3 - gameState.matchedPairs;
            document.getElementById('attempts').textContent = gameState.attempts;
        }

        // ゲーム終了
        function endGame() {
            const accuracy = gameState.attempts > 0 ? Math.round((gameState.matchedPairs / gameState.attempts) * 100) : 0;
            
            document.getElementById('final-score').textContent = gameState.score;
            document.getElementById('final-attempts').textContent = gameState.attempts;
            document.getElementById('accuracy').textContent = accuracy + '%';
            
            // スコアメッセージ
            let message = '';
            if (gameState.score >= 70) {
                message = '素晴らしい記憶力です!🏆';
            } else if (gameState.score >= 50) {
                message = 'とても良い成績です!👏';
            } else if (gameState.score >= 30) {
                message = 'まずまずの成績ですね!👍';
            } else {
                message = '次回はもっと頑張りましょう!💪';
            }
            
            document.getElementById('score-message').textContent = message;
            showScreen('result-screen');
        }

        // ゲームリセット
        function resetGame() {
            if (confirm('ゲームをリセットしますか?')) {
                startGame();
            }
        }

        // スタート画面に戻る
        function backToStart() {
            showScreen('start-screen');
        }
    </script>
</body>
</html>

今回はAIを使ってシュッと作れる楽しみを味わうことがテーマでしたが、私は文字通りAIを使ってシュッと作れる楽しみを味わえたように思います。 また、ボツになった案も紹介しましたが、触ってみてから判断ができるのも良い点だなと感じました。

みなさんも夏休みの自由研究として、ぜひ簡単アプリ作成してみてください!