o3-mini でタイピングゲームを作成

# 概要
- o3-mini high がリリースされたので簡単に試す
- タイピングゲームを chat のやり取りで作成する
- 実コードを後述する




# 動作画面
![2025-02-01_1631.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118627/961c0322-a2a1-b853-d08d-476b98f47f44.gif)




# 所管
## 性能向上
確実に性能が上がっています。
今回の所要時間は30分〜1時間程。
若干バグもありますが十分な基本動作をしています。



## 体感した o1 との違い
プロンプトの与え方もあると思いますが、o1 ではローマ字表記の揺れへの対応が難しかった。o3-mini では一回で大筋実現し、2,3回の細かな調整である程度期待する状態にできました。

また、o1, 4o はやり取りが長引くとバグの再発や、コードが意図せず変化する事もありした。o3-mini は今の所、この症状が緩和されている様にみえます。

一度とった方法論に固執するようなクセは見受けられますが、上手く忘却できれば制御できるかもしれません。




## ゲームに使用する文字列の生成で手間取る
文字列を生成した際、ローマ字表記が不適切なケースが多々ありました。

一例 
カレンダー == `karendaa`

伸ばし棒をハイフンに変更せよと指示した所、おはようございますが `ohayo-gozaimasu` となりましたが、以下の様に正解パターンをプロンプトで数例与えると適切に判断してくれました。

```
カレンダー == karenda-
データ == de-ta 
おはようございます == ohayougozaimasu
ごちそうさまでした == gotisousamadesita
```



## 仕様の明確化
適当なプロンプトを与えるよりも、仕様を少し明確にすると期待する結果により近づきました。前述のパターン明示の例からも、以前から正しく伝える言語化能力が必要とは言われていますが、その通りだなと少し実感できました。




# 作成時の主な流れ
1. 基本仕様を簡単にプロンプトで与える
2. ローマ字表記の揺れに対応
   - ちゃ = tya or cha など
3. [ 2 ] を搭載後の表示方法の調整
    - cha と表記した際に t が選択されたら、動的に cha から tya へ表示を切り替えるなど
4. ゲームに用いる文言の追加生成


# 与えた初期プロンプト
```
#指示
ブラウザで動作するタイピングゲームを作成してください

# 仕様
- ユーザーが見やすいリッチな見た目にする事
    - 文字の大きさ文字色など、明確かつ目に優しい表示をすること

- 通常モードとエンドレスモードを作成
    - 通常モード
        - 幾つかの文章をランダムに選択する
        - それらをすべてタイピングし終わったらゲーム終了とする
    - エンドレスモード
        - esc を押すまで続行しつづける
        - 用意した文章すべてからランダムに問題を出し続ける

- 文章表示
    - 入力すべき日本語を表示する
    - その下へ入力すべきローマ字を表示する
        - 日本語ローマ字には幾つかの表記の揺れがあるので対応すること
             - tu, tsu、
             - n, xn
             - これらの揺れをすべて網羅し反映すること
        - 動的にこれらの揺れに対応して表示を変更すること
             - 表示を tsu とした場合
             - tu を入力した時点, tsu から tu へ切り替える事

- 入力文字の外部化
    - html ないではなく外部に json 形式で持ちましょう
```


# 実行方法と生成されたコード
## 実行方法
- 後述するファイルを同一ディレクトリに設置
- python で実行後
- ブラウザで開きます


```bash
python -m http.server 8000
```

```
http://localhost:8000/
```



## 生成されたコード
### index.html
```html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>タイピングゲーム</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <!-- モード選択メニュー -->
  <div id="menu">
    <h1>タイピングゲーム</h1>
    <button id="normalMode">通常モード</button>
    <button id="endlessMode">エンドレスモード</button>
  </div>

  <!-- ゲーム画面 -->
  <div id="game" style="display:none;">
    <!-- 入力すべき日本語文 -->
    <div id="sentenceJapanese" class="sentence"></div>
    <!-- 入力すべきローマ字（正しく入力した部分と残り部分で色分け） -->
    <div id="sentenceRoman" class="sentence"></div>
    <div id="feedback"></div>
  </div>

  <!-- ゲーム終了時の結果表示 -->
  <div id="result" style="display:none;"></div>

  <script src="script.js"></script>
</body>
</html>
```



### style.css
```css
/* 基本設定 */
/* Mac・Windowsともに美しいシステムフォントを利用 */
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background-color: #fdfdfd;
  color: #333;
  margin: 0;
  padding: 20px;
  font-size: 20px; /* 基本サイズをやや大きめに */
}

/* メニュー */
#menu {
  text-align: center;
  margin-top: 50px;
}

button {
  font-size: 28px;  /* ボタンも大きめに */
  padding: 14px 28px;
  margin: 10px;
  border: none;
  border-radius: 8px;
  background-color: #007acc;
  color: white;
  cursor: pointer;
}

button:hover {
  background-color: #005f99;
}

/* ゲーム画面 */
#game {
  max-width: 900px;
  margin: 50px auto;
  text-align: center;
}


/* 既存のスタイルに加えて、以下を追加 */

#sentenceJapanese,
#sentenceRoman {
  white-space: normal;          /* 改行を許容 */
  word-wrap: break-word;        /* 単語単位でなく、必要に応じて任意の位置で改行 */
  overflow-wrap: break-word;    /* 同上 */
}

/* 表示する文章のサイズ調整（以前より大きめに設定） */
.sentence {
  font-size: 60px;
  margin: 30px 0;
}

/* ローマ字入力部分 */
#sentenceRoman {
  font-size: 52px;
}

/* 入力済み部分（正色） */
.correct {
  color: #007acc;
  font-weight: bold;
}

/* 未入力部分（黒文字） */
.in-progress {
  color: #000;
}

/* ミスタイプ時のフィードバック：赤いシェイク */
.error {
  animation: shake 0.3s;
}

@keyframes shake {
  0% { transform: translateX(0); }
  25% { transform: translateX(-8px); }
  50% { transform: translateX(8px); }
  75% { transform: translateX(-8px); }
  100% { transform: translateX(0); }
}

/* 結果表示 */
#result {
  text-align: center;
  font-size: 48px;
  margin-top: 50px;
}
```




### script.js
```js
document.addEventListener("DOMContentLoaded", () => {
  // 要素の参照
  const menu = document.getElementById("menu");
  const normalModeBtn = document.getElementById("normalMode");
  const endlessModeBtn = document.getElementById("endlessMode");
  const gameDiv = document.getElementById("game");
  const sentenceJapaneseDiv = document.getElementById("sentenceJapanese");
  const sentenceRomanDiv = document.getElementById("sentenceRoman");
  const feedbackDiv = document.getElementById("feedback");
  const resultDiv = document.getElementById("result");

  // ゲーム状態
  let mode = "";             // "normal" または "endless"
  let sentences = [];        // JSONから読み込んだ文章データ
  let currentSentence = null;
  let sentenceIndex = 0;     // 通常モード用のインデックス
  let pointer = 0;           // 現在、expected のどこまで確定済みか
  let normalizedTyped = "";  // 確定済みの入力
  let syllableBuffer = "";   // 特殊シラブルの入力中バッファ

  // 特殊シラブルの対応マッピング
  // 【キー】：canonical 表記、【値】：許容する variant の一覧（必ず canonical を含む）
  const specialMappings = {
    "sha": ["sha", "sya"],
    "shu": ["shu", "syu"],
    "sho": ["sho", "syo"],
    "tsu": ["tsu", "tu"],
    "cha": ["cha", "tya"],
    "chu": ["chu", "tyu"],
    "chi": ["chi", "ti"],
    "cho": ["cho", "tyo"],
    "shi": ["shi", "si"],
    "fu":  ["fu", "hu"],
    "ji":  ["ji", "zi"],
    "n":   ["n", "xn"]
  };

  // ゲーム開始
  function startGame(selectedMode) {
    mode = selectedMode;
    menu.style.display = "none";
    gameDiv.style.display = "block";
    fetch("sentences.json")
      .then(response => response.json())
      .then(data => {
        sentences = data.sentences;
        if (mode === "normal") {
          sentences = shuffleArray(sentences);
          sentenceIndex = 0;
        }
        loadNextSentence();
      })
      .catch(err => {
        console.error("文章の読み込みに失敗しました:", err);
      });
  }
  normalModeBtn.addEventListener("click", () => startGame("normal"));
  endlessModeBtn.addEventListener("click", () => startGame("endless"));

  // 次の文章をセットアップ
  function loadNextSentence() {
    pointer = 0;
    normalizedTyped = "";
    syllableBuffer = "";
    feedbackDiv.textContent = "";
    if (mode === "normal") {
      if (sentenceIndex >= sentences.length) {
        endGame();
        return;
      }
      currentSentence = sentences[sentenceIndex];
      sentenceIndex++;
    } else {
      currentSentence = sentences[Math.floor(Math.random() * sentences.length)];
    }
    updateSentenceDisplay();
  }

  // 画面表示の更新
  // ※ 特殊シラブルの場合も、すでに入力済みの部分は .correct、残りは一括して .in-progress で表示
  // ※ 特に「ん」の場合、ユーザーが x を入力しているときは effectiveCanonical を "xn" として表示する
  function updateSentenceDisplay() {
    sentenceJapaneseDiv.textContent = currentSentence.japanese;
    const committedSpan = `<span class="correct">${normalizedTyped}</span>`;
    const special = getSpecialSyllable();
    if (special && syllableBuffer) {
      // 特殊シラブルの表示では、もし対象が "n" なら、入力が "x" で始まっている場合 effectiveCanonical を "xn" とする
      let effectiveCanonical = special.canonical;
      if (special.canonical === "n" && syllableBuffer[0] === "x") {
        effectiveCanonical = "xn";
      }
      const specialRemaining = effectiveCanonical.slice(syllableBuffer.length);
      // 期待値上は canonical の文字数分（"n" の場合は1文字分）しか消費しないので afterSpecial は pointer+canonical.length から
      const afterSpecial = currentSentence.roman.slice(pointer + special.canonical.length);
      const untypedSpan = `<span class="in-progress">${specialRemaining + afterSpecial}</span>`;
      sentenceRomanDiv.innerHTML = committedSpan + `<span class="correct">${syllableBuffer}</span>` + untypedSpan;
    } else {
      const remainingText = `<span class="in-progress">${currentSentence.roman.slice(pointer)}</span>`;
      sentenceRomanDiv.innerHTML = committedSpan + remainingText;
    }
  }

  // ゲーム終了
  function endGame() {
    gameDiv.style.display = "none";
    resultDiv.style.display = "block";
    resultDiv.textContent = "ゲーム終了！お疲れ様でした。";
  }

  // ミスタイプ時のフィードバック
  function showError() {
    sentenceRomanDiv.classList.add("error");
    setTimeout(() => {
      sentenceRomanDiv.classList.remove("error");
    }, 300);
  }

  // 次に入力すべき部分が特殊シラブル対象か判定する
  function getSpecialSyllable() {
    if (syllableBuffer.length > 0) {
      for (const canonical in specialMappings) {
        if (currentSentence.roman.substr(pointer, canonical.length) === canonical &&
            specialMappings[canonical].some(v => v.startsWith(syllableBuffer))) {
          return { canonical, allowed: specialMappings[canonical] };
        }
      }
    } else {
      for (const canonical in specialMappings) {
        if (currentSentence.roman.substr(pointer, canonical.length) === canonical) {
          return { canonical, allowed: specialMappings[canonical] };
        }
      }
    }
    return null;
  }

  // キー入力のハンドリング
  document.addEventListener("keydown", (e) => {
    if (mode === "") return;  // ゲーム開始前は無視
    if (mode === "endless" && e.key === "Escape") {
      endGame();
      return;
    }
    let key = e.key.toLowerCase();
    if (!/^[a-z ]$/.test(key)) return;
    processKey(key);
  });

  // 通常文字・特殊シラブルの入力処理
  function processKey(key) {
    const expected = currentSentence.roman;
    const special = getSpecialSyllable();
    if (special) {
      handleSpecialSyllable(special, key);
      return;
    }
    if (key === expected.charAt(pointer)) {
      normalizedTyped += key;
      pointer++;
      updateSentenceDisplay();
    } else {
      showError();
      return;
    }
    if (pointer >= expected.length) {
      loadNextSentence();  // 入力完了時は即座に次の文章へ切り替え
    }
  }

  // 特殊シラブルの入力処理
  // ・入力中の syllableBuffer と今回の key を結合して、許容される variant を絞る
  // ・候補が１件に絞られ、かつ（通常の場合は）入力済みの文字数が候補の最小文字数以上になった場合のみ自動確定する
  // ※ 「ん」の場合は、ユーザーが x を入力した時点では自動確定せず、"x" の状態で表示し、
  //    続いて n を入力した時点で "xn" と確定する
  function handleSpecialSyllable(special, key) {
    const allowedVariants = special.allowed;
    const currentBuffer = syllableBuffer + key;
    const matchingVariants = allowedVariants.filter(v => v.startsWith(currentBuffer));
    if (matchingVariants.length === 0) {
      showError();
      syllableBuffer = "";
      return;
    }
    // 「ん」対象の場合、もし現在の入力が "x" だけなら自動確定せずにバッファ更新のみ
    if (special.canonical === "n" && currentBuffer === "x") {
      syllableBuffer = currentBuffer;
      updateSentenceDisplay();
      return;
    }
    syllableBuffer = currentBuffer;
    const minVariantLength = Math.min(...matchingVariants.map(v => v.length));
    if (matchingVariants.length === 1 && currentBuffer.length >= minVariantLength) {
      normalizedTyped += matchingVariants[0];
      pointer += special.canonical.length;  // canonical ("n") の長さは 1
      syllableBuffer = "";
    }
    updateSentenceDisplay();
    if (pointer >= currentSentence.roman.length) {
      loadNextSentence();
    }
  }

  // 配列をシャッフルするユーティリティ
  function shuffleArray(array) {
    let newArray = array.slice();
    for (let i = newArray.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [newArray[i], newArray[j]] = [newArray[j], newArray[i]];
    }
    return newArray;
  }
});
```





### sentences.json
```json
{
  "sentences": [
    {
      "japanese": "こんにちは",
      "roman": "konnichiha"
    },
    {
      "japanese": "おはようございます",
      "roman": "ohayougozaimasu"
    },
    {
      "japanese": "こんばんは",
      "roman": "konbanha"
    },
    {
      "japanese": "さようなら",
      "roman": "sayounara"
    },
    {
      "japanese": "ありがとう",
      "roman": "arigatou"
    },
    {
      "japanese": "すみません",
      "roman": "sumimasen"
    },
    {
      "japanese": "よろしくおねがいします",
      "roman": "yoroshikuonegaishimasu"
    },
    {
      "japanese": "いただきます",
      "roman": "itadakimasu"
    },
    {
      "japanese": "ごちそうさまでした",
      "roman": "gochisousamadeshita"
    },
    {
      "japanese": "元気ですか",
      "roman": "genkidesuka"
    },
    {
      "japanese": "今日はいい天気ですね",
      "roman": "kyouhaiitenkidesune"
    },
    {
      "japanese": "明日は雨が降るかもしれません",
      "roman": "ashitahaamegafurukamoshiremasen"
    }
  ]   
}
```