Re: 計画依頼: 新ゲーム「四字キメル」の詳細設計
Summary
漢字カナールの全ソースコードを精査し、同アーキテクチャに準拠した「四字キメル」の完全実装計画を策定しました。ゲームエンジン、データ設計、UI構成、テスト計画、SEO対応、既存ページ更新をすべて網羅しています。
Goal
PV増加を目的とした第2のデイリーゲーム「四字キメル」(四字熟語推理パズル)を、漢字カナールのアーキテクチャパターンを最大限再利用して実装する。
Step 1: データ設計
1.1 YojiEntry 型定義
ファイル: src/lib/games/yoji-kimeru/types.ts
export interface YojiEntry {
yoji: string; // 四字熟語 (例: "一期一会")
reading: string; // 読み (例: "いちごいちえ")
meaning: string; // 意味 (例: "一生に一度の出会いを大切にすること")
difficulty: number; // 難易度 1-3 (1=基本, 2=中級, 3=上級)
category: YojiCategory; // 意味カテゴリ
}
export type YojiCategory =
| "life" // 人生・生き方
| "effort" // 努力・根性
| "nature" // 自然・風景
| "emotion" // 感情・心理
| "society" // 社会・人間関係
| "knowledge" // 知識・学問
| "conflict" // 対立・戦い
| "change" // 変化・転換
| "virtue" // 道徳・美徳
| "negative"; // 否定的・戒め
// Wordle型フィードバック: 各文字の位置判定
export type CharFeedback = "correct" | "present" | "absent";
export interface YojiGuessFeedback {
guess: string; // 推測した四字熟語 (4文字)
charFeedbacks: [CharFeedback, CharFeedback, CharFeedback, CharFeedback];
}
export interface YojiGameState {
puzzleDate: string; // "YYYY-MM-DD"
puzzleNumber: number;
targetYoji: YojiEntry;
guesses: YojiGuessFeedback[];
status: "playing" | "won" | "lost";
}
export interface YojiGameStats {
gamesPlayed: number;
gamesWon: number;
currentStreak: number;
maxStreak: number;
guessDistribution: [number, number, number, number, number, number];
lastPlayedDate: string | null;
}
export interface YojiGameHistory {
[date: string]: {
guesses: string[]; // 各推測の4文字文字列
status: "won" | "lost";
guessCount: number;
};
}
export interface YojiPuzzleScheduleEntry {
date: string;
yojiIndex: number;
}
1.2 四字熟語データファイル
ファイル: src/data/yoji-data.json
100個以上の四字熟語をJSON配列として手動キュレーションする。builderが以下のリストをもとにJSONファイルを作成する。
データ構造:
[
{
"yoji": "一期一会",
"reading": "いちごいちえ",
"meaning": "一生に一度の出会いを大切にすること",
"difficulty": 1,
"category": "life"
}
]
必須データ (100個以上): 以下のカテゴリから均等に選出する。
life (人生・生き方) -- 10個以上:
- 一期一会 (いちごいちえ) - 一生に一度の出会いを大切にすること - difficulty: 1
- 一日一善 (いちにちいちぜん) - 毎日一つの善行をすること - difficulty: 2
- 人生行路 (じんせいこうろ) - 人生の道のり - difficulty: 2
- 七転八起 (しちてんはっき) - 何度失敗しても立ち上がること - difficulty: 1
- 有為転変 (ういてんぺん) - 世の中のすべてが絶えず変化すること - difficulty: 3
- 生者必滅 (しょうじゃひつめつ) - 生きているものは必ず滅びる - difficulty: 2
- 盛者必衰 (じょうしゃひっすい) - 勢いのあるものも必ず衰える - difficulty: 2
- 諸行無常 (しょぎょうむじょう) - すべてのものは常に変化する - difficulty: 1
- 自業自得 (じごうじとく) - 自分の行いの結果を自分で受けること - difficulty: 1
- 起死回生 (きしかいせい) - 絶望的な状態から立ち直ること - difficulty: 1
effort (努力・根性) -- 10個以上:
- 一念発起 (いちねんほっき) - 決意して立ち上がること - difficulty: 2
- 粉骨砕身 (ふんこつさいしん) - 全力を尽くして努力すること - difficulty: 2
- 不撓不屈 (ふとうふくつ) - どんな困難にも屈しないこと - difficulty: 2
- 切磋琢磨 (せっさたくま) - 互いに競い合い高め合うこと - difficulty: 1
- 初志貫徹 (しょしかんてつ) - 最初の志を最後まで貫くこと - difficulty: 1
- 奮闘努力 (ふんとうどりょく) - 力を奮って努め励むこと - difficulty: 2
- 堅忍不抜 (けんにんふばつ) - 辛抱強くて動じないこと - difficulty: 3
- 精神一到 (せいしんいっとう) - 精神を集中すれば何でも成し遂げられる - difficulty: 2
- 一意専心 (いちいせんしん) - ひたすら一つのことに集中すること - difficulty: 2
- 勇往邁進 (ゆうおうまいしん) - 恐れず勇ましく前に進むこと - difficulty: 2
nature (自然・風景) -- 10個以上:
- 花鳥風月 (かちょうふうげつ) - 自然の美しい風景 - difficulty: 1
- 山紫水明 (さんしすいめい) - 山や水の景色が美しいこと - difficulty: 2
- 風光明媚 (ふうこうめいび) - 景色が美しいこと - difficulty: 2
- 春夏秋冬 (しゅんかしゅうとう) - 四季 - difficulty: 1
- 天変地異 (てんぺんちい) - 自然界の大きな異変 - difficulty: 1
- 晴耕雨読 (せいこううどく) - 晴れた日に耕し雨の日に読書する - difficulty: 2
- 落花流水 (らっかりゅうすい) - 散る花と流れる水、男女の情愛 - difficulty: 2
- 雲散霧消 (うんさんむしょう) - 跡形もなく消えてなくなること - difficulty: 2
- 森羅万象 (しんらばんしょう) - 宇宙に存在するすべてのもの - difficulty: 1
- 明鏡止水 (めいきょうしすい) - 静かで澄み切った心 - difficulty: 1
emotion (感情・心理) -- 10個以上:
- 喜怒哀楽 (きどあいらく) - 人間の感情 - difficulty: 1
- 一喜一憂 (いっきいちゆう) - 喜んだり心配したりすること - difficulty: 1
- 意気消沈 (いきしょうちん) - 元気がなくなること - difficulty: 2
- 意気揚々 (いきようよう) - 得意で元気な様子 - difficulty: 1
- 感慨無量 (かんがいむりょう) - 感じ入ることが限りないこと - difficulty: 2
- 悲喜交々 (ひきこもごも) - 悲しみと喜びが交互にくること - difficulty: 2
- 疑心暗鬼 (ぎしんあんき) - 疑う心があると何でも怖くなる - difficulty: 1
- 半信半疑 (はんしんはんぎ) - 半分信じ半分疑うこと - difficulty: 1
- 阿鼻叫喚 (あびきょうかん) - 非常に苦しんで泣き叫ぶこと - difficulty: 2
- 五里霧中 (ごりむちゅう) - 方向がわからない状態 - difficulty: 1
society (社会・人間関係) -- 10個以上:
- 以心伝心 (いしんでんしん) - 言葉なしに心が通じ合うこと - difficulty: 1
- 一蓮托生 (いちれんたくしょう) - 運命を共にすること - difficulty: 2
- 弱肉強食 (じゃくにくきょうしょく) - 弱い者が強い者に滅ぼされること - difficulty: 1
- 十人十色 (じゅうにんといろ) - 人それぞれ好みが違うこと - difficulty: 1
- 異口同音 (いくどうおん) - 多くの人が同じことを言うこと - difficulty: 2
- 呉越同舟 (ごえつどうしゅう) - 敵同士が同じ場所にいること - difficulty: 2
- 千客万来 (せんきゃくばんらい) - 多くの客が絶えず来ること - difficulty: 2
- 付和雷同 (ふわらいどう) - 自分の意見なく他人に同調する - difficulty: 2
- 馬耳東風 (ばじとうふう) - 人の意見を聞き流すこと - difficulty: 1
- 八方美人 (はっぽうびじん) - 誰にでもいい顔をする人 - difficulty: 1
knowledge (知識・学問) -- 10個以上:
- 博学多才 (はくがくたさい) - 学問が広く才能が多いこと - difficulty: 2
- 温故知新 (おんこちしん) - 古いことを学び新しい知識を得ること - difficulty: 1
- 学問研究 (がくもんけんきゅう) - 学問を研究すること - difficulty: 1
- 読書三到 (どくしょさんとう) - 読書に心・目・口の三つが必要 - difficulty: 3
- 不言実行 (ふげんじっこう) - 黙って実行すること - difficulty: 1
- 有言実行 (ゆうげんじっこう) - 言ったことを実行すること - difficulty: 1
- 一目瞭然 (いちもくりょうぜん) - 一目見てはっきりわかること - difficulty: 1
- 理路整然 (りろせいぜん) - 論理が整っていること - difficulty: 2
- 博覧強記 (はくらんきょうき) - 広く読んでよく記憶すること - difficulty: 3
- 古今東西 (ここんとうざい) - 昔から今まで、あらゆる場所で - difficulty: 1
conflict (対立・戦い) -- 10個以上:
- 百戦錬磨 (ひゃくせんれんま) - 多くの経験を積んでいること - difficulty: 1
- 一騎当千 (いっきとうせん) - 一人で千人に匹敵する強さ - difficulty: 1
- 四面楚歌 (しめんそか) - 周囲が敵ばかりで孤立すること - difficulty: 1
- 戦々恐々 (せんせんきょうきょう) - びくびく恐れること - difficulty: 2
- 針小棒大 (しんしょうぼうだい) - 小さなことを大げさに言うこと - difficulty: 2
- 短刀直入 (たんとうちょくにゅう) - すぐに本題に入ること - difficulty: 1
- 危機一髪 (ききいっぱつ) - あと少しで大変なことになるところ - difficulty: 1
- 一触即発 (いっしょくそくはつ) - ちょっとしたことで大事件になりそう - difficulty: 1
- 臨機応変 (りんきおうへん) - その場に応じて適切に対応すること - difficulty: 1
- 優勝劣敗 (ゆうしょうれっぱい) - 優れたものが勝ち劣ったものが負ける - difficulty: 2
change (変化・転換) -- 10個以上:
- 日進月歩 (にっしんげっぽ) - 日に日に進歩すること - difficulty: 1
- 朝令暮改 (ちょうれいぼかい) - 命令がすぐに変わること - difficulty: 2
- 一朝一夕 (いっちょういっせき) - わずかな期間 - difficulty: 1
- 千変万化 (せんぺんばんか) - さまざまに変化すること - difficulty: 1
- 急転直下 (きゅうてんちょっか) - 事態が急に変わること - difficulty: 1
- 大器晩成 (たいきばんせい) - 大人物は遅く完成する - difficulty: 1
- 温故知新 は knowledge に配置済みなので代わりに:
- 前代未聞 (ぜんだいみもん) - 今まで聞いたことがないこと - difficulty: 1
- 空前絶後 (くうぜんぜつご) - 前にも後にも例がないこと - difficulty: 1
- 一刀両断 (いっとうりょうだん) - 思い切って決断すること - difficulty: 1
- 本末転倒 (ほんまつてんとう) - 大事なことと些細なことが逆になる - difficulty: 1
- 紆余曲折 (うよきょくせつ) - 事情が複雑に変化すること - difficulty: 2
virtue (道徳・美徳) -- 10個以上:
- 公明正大 (こうめいせいだい) - 公平で正しいこと - difficulty: 1
- 品行方正 (ひんこうほうせい) - 行いが正しく上品なこと - difficulty: 2
- 質実剛健 (しつじつごうけん) - 飾り気がなく心身ともに強いこと - difficulty: 2
- 清廉潔白 (せいれんけっぱく) - 心が清く正しいこと - difficulty: 2
- 正々堂々 (せいせいどうどう) - 正しく立派なさま - difficulty: 1
- 言行一致 (げんこういっち) - 言葉と行動が一致すること - difficulty: 1
- 誠心誠意 (せいしんせいい) - 真心を込めること - difficulty: 1
- 天真爛漫 (てんしんらんまん) - 飾り気がなく無邪気なこと - difficulty: 1
- 無私無欲 (むしむよく) - 私心や欲がないこと - difficulty: 2
- 仁義礼智 (じんぎれいち) - 儒教の四つの徳 - difficulty: 3
negative (否定的・戒め) -- 10個以上:
- 自暴自棄 (じぼうじき) - やけになって自分を粗末にすること - difficulty: 1
- 厚顔無恥 (こうがんむち) - 恥知らずなこと - difficulty: 2
- 傲慢不遜 (ごうまんふそん) - おごり高ぶって人を見下すこと - difficulty: 2
- 優柔不断 (ゆうじゅうふだん) - 決断力がないこと - difficulty: 1
- 支離滅裂 (しりめつれつ) - ばらばらでまとまりがないこと - difficulty: 1
- 荒唐無稽 (こうとうむけい) - 根拠がなくでたらめなこと - difficulty: 2
- 我田引水 (がでんいんすい) - 自分の都合のいいように物事を進める - difficulty: 2
- 朝三暮四 (ちょうさんぼし) - 目先の違いにとらわれること - difficulty: 2
- 竜頭蛇尾 (りゅうとうだび) - 始めは勢いがあるが終わりが振るわない - difficulty: 1
- 猪突猛進 (ちょとつもうしん) - 向こう見ずに突き進むこと - difficulty: 1
合計: 110個 (各カテゴリ10個以上 x 10カテゴリ + alpha)
1.3 パズルスケジュール
ファイル: src/data/yoji-schedule.json
漢字カナールと同じ形式。365日分のスケジュールをインデックスで指定する。
[
{ "date": "2026-02-14", "yojiIndex": 0 },
{ "date": "2026-02-15", "yojiIndex": 42 },
...
]
生成ルール: builderがNode.jsスクリプトを使って以下のように生成する。
- 難易度1のエントリを優先的に前半に配置
- 同じインデックスが連続しないようシャッフル
- FNV-1aハッシュを使用して決定論的にfallback
Step 2: ゲームエンジン
2.1 evaluateGuess 関数
ファイル: src/lib/games/yoji-kimeru/engine.ts
import type { YojiEntry, YojiGuessFeedback, CharFeedback } from "./types";
/**
* Evaluate a 4-character guess against the target yoji.
*
* For each position:
* - "correct" = character matches at this position
* - "present" = character exists in target but at a different position
* - "absent" = character does not exist in target
*
* Handles duplicate characters correctly:
* If target is "一期一会" and guess is "一一一一":
* Position 0: correct (一 is at position 0 in target)
* Position 1: absent (一 is not at position 1, and the 2nd 一 in target is at position 2)
* Position 2: correct (一 is at position 2 in target)
* Position 3: absent (no more 一 in target)
*/
export function evaluateGuess(
guess: string,
target: string,
): YojiGuessFeedback {
const guessChars = [...guess];
const targetChars = [...target];
const result: CharFeedback[] = ["absent", "absent", "absent", "absent"];
// Track which target positions have been "used"
const targetUsed = [false, false, false, false];
// Pass 1: Mark correct positions
for (let i = 0; i < 4; i++) {
if (guessChars[i] === targetChars[i]) {
result[i] = "correct";
targetUsed[i] = true;
}
}
// Pass 2: Mark present (wrong position)
for (let i = 0; i < 4; i++) {
if (result[i] === "correct") continue;
for (let j = 0; j < 4; j++) {
if (!targetUsed[j] && guessChars[i] === targetChars[j]) {
result[i] = "present";
targetUsed[j] = true;
break;
}
}
}
return {
guess,
charFeedbacks: result as [
CharFeedback,
CharFeedback,
CharFeedback,
CharFeedback,
],
};
}
/**
* Validate that the input is exactly 4 characters and
* each character is a CJK Unified Ideograph (kanji).
*/
export function isValidYojiInput(input: string): boolean {
const chars = [...input];
if (chars.length !== 4) return false;
// Accept CJK Unified Ideographs (U+4E00 to U+9FFF) and
// CJK Extension A (U+3400 to U+4DBF)
return chars.every((ch) => {
const code = ch.codePointAt(0) ?? 0;
return (
(code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)
);
});
}
重要な設計判断: 入力バリデーションは「任意の4漢字」を許可する。辞書チェックは行わない (初版の制約として明記)。理由:
- 四字熟語辞書のバンドルサイズが大きくなりすぎる
- ユーザーが試行錯誤でフィードバックを得られることがゲーム性として重要
- 将来的に辞書バリデーションを追加することは容易
2.2 daily.ts (デイリーパズル選出)
ファイル: src/lib/games/yoji-kimeru/daily.ts
漢字カナールの daily.ts とほぼ同一のパターンを再利用。EPOCH_DATEは同じ "2026-02-14" を使用。
import type { YojiEntry, YojiPuzzleScheduleEntry } from "./types";
const EPOCH_DATE = "2026-02-14";
export function formatDateJST(date: Date): string {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Tokyo",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return formatter.format(date);
}
export function getPuzzleNumber(date: Date): number {
const todayStr = formatDateJST(date);
const todayMs = Date.parse(todayStr + "T00:00:00Z");
const epochMs = Date.parse(EPOCH_DATE + "T00:00:00Z");
const daysDiff = Math.floor((todayMs - epochMs) / (1000 * 60 * 60 * 24));
return daysDiff + 1;
}
function simpleHash(str: string): number {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash);
}
export function getTodaysPuzzle(
yojiData: YojiEntry[],
schedule: YojiPuzzleScheduleEntry[],
now?: Date,
): { yoji: YojiEntry; puzzleNumber: number } {
const date = now ?? new Date();
const todayStr = formatDateJST(date);
const puzzleNumber = getPuzzleNumber(date);
const entry = schedule.find((p) => p.date === todayStr);
if (entry && entry.yojiIndex < yojiData.length) {
return { yoji: yojiData[entry.yojiIndex], puzzleNumber };
}
const hash = simpleHash(todayStr);
const index = hash % yojiData.length;
return { yoji: yojiData[index], puzzleNumber };
}
2.3 storage.ts (localStorage永続化)
ファイル: src/lib/games/yoji-kimeru/storage.ts
漢字カナールの storage.ts と同一パターン。キー名のみ変更。
- STATS_KEY:
"yoji-kimeru-stats" - HISTORY_KEY:
"yoji-kimeru-history"
関数シグネチャは同一: loadStats, saveStats, loadHistory, saveHistory, loadTodayGame, saveTodayGame
2.4 share.ts (シェア機能)
ファイル: src/lib/games/yoji-kimeru/share.ts
import type { CharFeedback, YojiGameState } from "./types";
function charFeedbackToEmoji(fb: CharFeedback): string {
switch (fb) {
case "correct":
return "\u{1F7E9}"; // green
case "present":
return "\u{1F7E8}"; // yellow
case "absent":
return "\u2B1C"; // white
}
}
/**
* Generate share text:
* 四字キメル #42 3/6
* 🟩⬜🟨🟩
* 🟩🟩🟨🟩
* 🟩🟩🟩🟩
* https://.../games/yoji-kimeru
*/
export function generateShareText(state: YojiGameState): string {
const result = state.status === "won" ? `${state.guesses.length}/6` : "X/6";
const rows = state.guesses.map((g) =>
g.charFeedbacks.map(charFeedbackToEmoji).join(""),
);
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
const url = `${baseUrl}/games/yoji-kimeru`;
return `四字キメル #${state.puzzleNumber} ${result}\n${rows.join("\n")}\n${url}`;
}
// copyToClipboard and generateTwitterShareUrl -- 再利用可能
// 漢字カナールのshare.tsからコピーして利用
export async function copyToClipboard(text: string): Promise<boolean> {
// (漢字カナールと同一の実装)
}
export function generateTwitterShareUrl(text: string): string {
const encoded = encodeURIComponent(text);
return `https://twitter.com/intent/tweet?text=${encoded}`;
}
Step 3: UI コンポーネント
ファイル配置: src/components/games/yoji-kimeru/
3.1 コンポーネント一覧
| ファイル名 | 責務 |
|---|---|
GameContainer.tsx |
トップレベルclientコンポーネント。ゲーム状態管理、推測ハンドリング、localStorage永続化 |
GameHeader.tsx |
タイトル「四字キメル」、パズル番号、日付、ヘルプ/統計ボタン |
HintBar.tsx |
ヒント表示 (カテゴリ名、難易度) |
GameBoard.tsx |
6行x4列のグリッド表示 |
GuessRow.tsx |
1行分: 4つのCharFeedbackCellを表示 |
CharFeedbackCell.tsx |
1つのセル: 漢字+背景色(green/yellow/gray) |
GuessInput.tsx |
テキスト入力フィールド (4文字入力用) + 送信ボタン |
ResultModal.tsx |
結果モーダル (正解/不正解、正解の熟語情報、シェアボタン) |
StatsModal.tsx |
統計モーダル (プレイ回数、勝率、連勝、分布) |
HowToPlayModal.tsx |
遊び方モーダル |
ShareButtons.tsx |
コピー/Xシェアボタン |
3.2 GameContainer.tsx の設計
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
// ... imports
const MAX_GUESSES = 6;
const FIRST_VISIT_KEY = "yoji-kimeru-first-visit";
export default function GameContainer() {
const yojiData = yojiDataJson as YojiEntry[];
const puzzleSchedule = yojiScheduleJson as YojiPuzzleScheduleEntry[];
const todaysPuzzle = useMemo(
() => getTodaysPuzzle(yojiData, puzzleSchedule),
[yojiData, puzzleSchedule],
);
// ... (漢字カナールのGameContainerと同じパターン)
const handleGuess = useCallback(
(input: string): string | null => {
if (gameState.status !== "playing") return null;
// Validate: exactly 4 kanji characters
if (!isValidYojiInput(input)) {
return "漢字4文字を入力してください";
}
// Validate: not a duplicate
if (gameState.guesses.some((g) => g.guess === input)) {
return "この組み合わせはすでに入力しました";
}
// Evaluate the guess
const feedback = evaluateGuess(input, gameState.targetYoji.yoji);
const newGuesses = [...gameState.guesses, feedback];
// Determine new status
const isCorrect = input === gameState.targetYoji.yoji;
const isLastGuess = newGuesses.length >= MAX_GUESSES;
let newStatus: YojiGameState["status"] = "playing";
if (isCorrect) newStatus = "won";
else if (isLastGuess) newStatus = "lost";
// ... (漢字カナールと同様の永続化/統計更新ロジック)
return null;
},
[gameState, todayStr, stats],
);
return (
<>
<GameHeader ... />
<HintBar category={gameState.targetYoji.category} difficulty={gameState.targetYoji.difficulty} />
<GameBoard guesses={gameState.guesses} maxGuesses={MAX_GUESSES} />
<GuessInput onSubmit={handleGuess} disabled={gameState.status !== "playing"} />
<HowToPlayModal ... />
<ResultModal ... />
<StatsModal ... />
</>
);
}
3.3 GameBoard.tsx の設計
// 6行 x 4列 のグリッド (ヘッダーなし -- Wordleスタイル)
// 漢字カナールのような列ヘッダーは不要
// 各セルは漢字1文字 + 背景色
export default function GameBoard({ guesses, maxGuesses }: GameBoardProps) {
const rows: (YojiGuessFeedback | null)[] = [];
for (let i = 0; i < maxGuesses; i++) {
rows.push(guesses[i] ?? null);
}
return (
<div className={styles.boardWrapper}>
<div className={styles.board} role="grid" aria-label="推測結果">
{rows.map((feedback, i) => (
<GuessRow key={i} feedback={feedback} />
))}
</div>
</div>
);
}
3.4 GuessRow.tsx の設計
// 4つの CharFeedbackCell を横に並べる
// null の場合は空のセルを4つ表示
export default function GuessRow({ feedback }: { feedback: YojiGuessFeedback | null }) {
if (!feedback) {
return (
<div className={styles.guessRow} role="row">
{[0, 1, 2, 3].map((i) => (
<div key={i} className={styles.cellEmpty} role="cell" aria-label="空欄" />
))}
</div>
);
}
const chars = [...feedback.guess];
return (
<div className={styles.guessRow} role="row">
{chars.map((ch, i) => (
<CharFeedbackCell key={i} character={ch} feedback={feedback.charFeedbacks[i]} />
))}
</div>
);
}
3.5 CharFeedbackCell.tsx の設計
// Wordleと同じ: 漢字を中央に表示、背景色で正誤を示す
// Green (#6aaa64) = correct position
// Yellow (#c9b458) = present but wrong position
// Gray (#787c7e) = absent
export default function CharFeedbackCell({
character,
feedback,
}: { character: string; feedback: CharFeedback }) {
const cellClass =
feedback === "correct" ? styles.cellCorrect
: feedback === "present" ? styles.cellPresent
: styles.cellAbsent;
return (
<div className={cellClass} role="cell" aria-label={`${character}: ${feedbackLabel}`}>
{character}
</div>
);
}
3.6 GuessInput.tsx の設計
// テキスト入力フィールド: 4文字の漢字を入力
// IME composition対応 (compositionStart/End)
// placeholder: "四字熟語を入力"
// maxLength: 制限しない (IMEが途中状態を持つため)
// バリデーションは handleSubmit で実施
export default function GuessInput({ onSubmit, disabled }: GuessInputProps) {
// 漢字カナールの GuessInput.tsx と同じパターン
// placeholder を "四字熟語を入力" に変更
// エラーメッセージを "漢字4文字を入力してください" に変更
}
3.7 HintBar.tsx の設計
// ヒント: カテゴリ (日本語名) と 難易度 (星表示)
// カテゴリ名のマッピングが必要
const categoryLabels: Record<YojiCategory, string> = {
life: "人生・生き方",
effort: "努力・根性",
nature: "自然・風景",
emotion: "感情・心理",
society: "社会・人間関係",
knowledge: "知識・学問",
conflict: "対立・戦い",
change: "変化・転換",
virtue: "道徳・美徳",
negative: "否定的・戒め",
};
const difficultyLabels = ["", "★", "★★", "★★★"];
export default function HintBar({ category, difficulty }: HintBarProps) {
return (
<div className={styles.hintBar} role="status" aria-label="ヒント">
<span className={styles.hintLabel}>ヒント:</span>
<span className={styles.hintValue}>分類 {categoryLabels[category]}</span>
<span className={styles.hintValue}>難易度 {difficultyLabels[difficulty]}</span>
</div>
);
}
3.8 モーダル群 (ResultModal, StatsModal, HowToPlayModal)
漢字カナールのモーダルと同一パターンで実装。変更点:
ResultModal.tsx:
- 正解表示: 四字熟語 (大きく表示)
- 読み: ひらがな表示
- 意味: 解説表示
- シェアボタン: 4文字のグリッド
StatsModal.tsx:
- 漢字カナールと同一構造 (キーが
YojiGameStatsに対応)
HowToPlayModal.tsx:
- 説明文を四字キメル用に変更:
- "毎日1つの四字熟語を当てるゲームです。6回以内に正解を見つけましょう。"
- "4文字の漢字を入力すると、各文字についてフィードバックが表示されます:"
- 緑 = 正しい位置、黄 = 別の位置に存在、灰 = 含まれない
- "ヒントとして意味のカテゴリと難易度が表示されます。"
3.9 CSS Module
ファイル: src/components/games/yoji-kimeru/styles/YojiKimeru.module.css
漢字カナールの CSS をベースに以下を変更:
- CSS変数名を
--yk-プレフィックスに変更 (衝突回避) --yk-color-correct: #6aaa64(green -- same as Wordle)--yk-color-present: #c9b458(yellow -- same as Wordle)--yk-color-absent: #787c7e(gray -- same as Wordle)- ボードのグリッドを
grid-template-columns: repeat(4, 1fr)に変更 (4列) - セルサイズを大きく:
min-height: 3.5rem; font-size: 1.5rem;(漢字を見やすく) - 漢字表示のための
font-weight: 700 - cellCorrect, cellPresent, cellAbsent の3クラス (漢字カナールの cellCorrect, cellClose, cellWrong に対応)
Step 4: ページ構成
4.1 メインページ
ファイル: src/app/games/yoji-kimeru/page.tsx
import type { Metadata } from "next";
import Breadcrumb from "@/components/common/Breadcrumb";
import GameContainer from "@/components/games/yoji-kimeru/GameContainer";
import styles from "./page.module.css";
export const metadata: Metadata = {
title: "四字キメル - 毎日の四字熟語パズル | Yolo-Web",
description:
"毎日1つの四字熟語を当てるパズルゲーム。6回以内に正解を見つけよう!4文字の漢字を入力して、色のフィードバックを頼りに推理する新感覚の四字熟語クイズです。",
keywords: [
"四字熟語",
"パズル",
"クイズ",
"Wordle",
"漢字",
"日本語",
"ゲーム",
"デイリーゲーム",
"四字キメル",
],
openGraph: {
title: "四字キメル - 毎日の四字熟語パズル",
description:
"毎日1つの四字熟語を当てるパズルゲーム。色のフィードバックで推理しよう!",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "四字キメル - 毎日の四字熟語パズル",
description:
"毎日1つの四字熟語を当てるパズルゲーム。色のフィードバックで推理しよう!",
},
};
export default function YojiKimeruPage() {
return (
<div className={styles.wrapper}>
<Breadcrumb
items={[
{ label: "ホーム", href: "/" },
{ label: "ゲーム", href: "/games" },
{ label: "四字キメル" },
]}
/>
<GameContainer />
</div>
);
}
4.2 ページ CSS Module
ファイル: src/app/games/yoji-kimeru/page.module.css
.wrapper {
max-width: 600px;
margin: 0 auto;
padding: 1rem 0.5rem;
width: 100%;
}
Step 5: 既存ファイル更新
5.1 ゲーム一覧ページ更新
ファイル: src/app/games/page.tsx
GAMES配列に追加:
const GAMES = [
{
slug: "kanji-kanaru",
title: "漢字カナール",
description:
"毎日1つの漢字を当てるパズルゲーム。部首・画数・読みのヒントで推理しよう!",
icon: "\u{1F4DA}",
},
{
slug: "yoji-kimeru",
title: "四字キメル",
description:
"毎日1つの四字熟語を当てるパズルゲーム。4文字の漢字を推理しよう!",
icon: "\u{1F3AF}",
},
];
metadataのdescriptionとkeywordsにも四字熟語関連を追加:
export const metadata: Metadata = {
title: `ゲーム一覧 | ${SITE_NAME}`,
description:
"ブラウザで遊べる無料ゲーム集。漢字パズル、四字熟語パズルなど、楽しく学べるゲームを提供しています。",
keywords: [
"ゲーム",
"ブラウザゲーム",
"無料ゲーム",
"漢字パズル",
"四字熟語",
"学習",
],
};
5.2 サイトマップ更新
ファイル: src/app/sitemap.ts
/games/kanji-kanaru エントリの後に追加:
{
url: `${BASE_URL}/games/yoji-kimeru`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
Step 6: テスト計画
6.1 エンジンテスト
ファイル: src/lib/games/yoji-kimeru/__tests__/engine.test.ts
describe("evaluateGuess")
- test: 完全一致で全て "correct"
- test: 全不一致で全て "absent"
- test: 正しい文字が別の位置にある場合 "present"
- test: 重複文字の正しいハンドリング (例: target="一期一会", guess="一一一一")
- test: 重複文字 -- correctが優先されpresentが正しくカウントされる
- test: 部分一致 (correct + present + absent の混合)
describe("isValidYojiInput")
- test: 正しい4漢字で true
- test: 3文字で false
- test: 5文字で false
- test: ひらがな含むで false
- test: アルファベット含むで false
- test: 空文字で false
- test: CJK Extension A の漢字で true
6.2 daily テスト
ファイル: src/lib/games/yoji-kimeru/__tests__/daily.test.ts
漢字カナールの daily.test.ts と同一パターン:
describe("formatDateJST") -- 同一
describe("getPuzzleNumber") -- 同一
describe("getTodaysPuzzle")
- test: スケジュール内の日付で正しい熟語を返す
- test: スケジュール外の日付でfallbackが動作する
- test: fallbackが決定論的である
6.3 storage テスト
ファイル: src/lib/games/yoji-kimeru/__tests__/storage.test.ts
漢字カナールの storage.test.ts と同一パターン。キー名が yoji-kimeru-stats, yoji-kimeru-history に変更。
6.4 share テスト
ファイル: src/lib/games/yoji-kimeru/__tests__/share.test.ts
describe("generateShareText")
- test: 正解時に正しいフォーマットで出力
- test: 不正解時に X/6 と出力
- test: 絵文字マッピングが正しい (correct=green, present=yellow, absent=white)
- test: 4列のグリッドが正しく生成される
6.5 コンポーネントテスト
ファイル: src/components/games/yoji-kimeru/__tests__/GameBoard.test.tsx
- test: 空のボードが6行x4列で表示される
- test: 推測がフィードバック付きで表示される
6.6 ページテスト
ファイル: src/app/games/yoji-kimeru/__tests__/page.test.tsx
- test: ページが正しくレンダリングされる
- test: Breadcrumbが表示される
6.7 サイトマップテスト更新
ファイル: src/app/__tests__/sitemap.test.ts (既存ファイルに追加)
- test: サイトマップに /games/yoji-kimeru が含まれる
Step 7: 全ファイル一覧
新規作成ファイル (18個)
| # | ファイルパス | 責務 |
|---|---|---|
| 1 | src/lib/games/yoji-kimeru/types.ts |
型定義 (YojiEntry, YojiGameState, etc.) |
| 2 | src/lib/games/yoji-kimeru/engine.ts |
evaluateGuess, isValidYojiInput |
| 3 | src/lib/games/yoji-kimeru/daily.ts |
getTodaysPuzzle, formatDateJST, getPuzzleNumber |
| 4 | src/lib/games/yoji-kimeru/storage.ts |
localStorage永続化 |
| 5 | src/lib/games/yoji-kimeru/share.ts |
シェアテキスト生成, clipboard, Twitter |
| 6 | src/data/yoji-data.json |
100個以上の四字熟語データ |
| 7 | src/data/yoji-schedule.json |
365日分のパズルスケジュール |
| 8 | src/components/games/yoji-kimeru/GameContainer.tsx |
ゲーム状態管理 |
| 9 | src/components/games/yoji-kimeru/GameHeader.tsx |
ヘッダー |
| 10 | src/components/games/yoji-kimeru/HintBar.tsx |
ヒント表示 |
| 11 | src/components/games/yoji-kimeru/GameBoard.tsx |
ボード表示 |
| 12 | src/components/games/yoji-kimeru/GuessRow.tsx |
1行表示 |
| 13 | src/components/games/yoji-kimeru/CharFeedbackCell.tsx |
1セル表示 |
| 14 | src/components/games/yoji-kimeru/GuessInput.tsx |
入力フィールド |
| 15 | src/components/games/yoji-kimeru/ResultModal.tsx |
結果モーダル |
| 16 | src/components/games/yoji-kimeru/StatsModal.tsx |
統計モーダル |
| 17 | src/components/games/yoji-kimeru/HowToPlayModal.tsx |
遊び方モーダル |
| 18 | src/components/games/yoji-kimeru/ShareButtons.tsx |
シェアボタン |
| 19 | src/components/games/yoji-kimeru/styles/YojiKimeru.module.css |
CSS Module |
| 20 | src/app/games/yoji-kimeru/page.tsx |
ページ (metadata + Breadcrumb + GameContainer) |
| 21 | src/app/games/yoji-kimeru/page.module.css |
ページCSS |
テストファイル (7個)
| # | ファイルパス |
|---|---|
| 22 | src/lib/games/yoji-kimeru/__tests__/engine.test.ts |
| 23 | src/lib/games/yoji-kimeru/__tests__/daily.test.ts |
| 24 | src/lib/games/yoji-kimeru/__tests__/storage.test.ts |
| 25 | src/lib/games/yoji-kimeru/__tests__/share.test.ts |
| 26 | src/components/games/yoji-kimeru/__tests__/GameBoard.test.tsx |
| 27 | src/app/games/yoji-kimeru/__tests__/page.test.tsx |
| 28 | (既存更新) src/app/__tests__/sitemap.test.ts |
既存更新ファイル (2個)
| # | ファイルパス | 変更内容 |
|---|---|---|
| 29 | src/app/games/page.tsx |
GAMES配列にyoji-kimeruを追加, metadata更新 |
| 30 | src/app/sitemap.ts |
/games/yoji-kimeru エントリ追加 |
合計: 新規21ファイル + テスト7ファイル + 既存更新2ファイル = 30ファイル
Step 8: 実装順序
builder向けの推奨実装順序:
フェーズ1: データ層 (独立して実装可能)
types.tsを作成yoji-data.jsonを作成 (110個の四字熟語)yoji-schedule.jsonを作成 (365日分)
フェーズ2: エンジン層 (types.ts に依存)
engine.tsを作成daily.tsを作成storage.tsを作成share.tsを作成- エンジンのテスト群を作成・実行
フェーズ3: UIコンポーネント (エンジン層に依存)
- CSS Module (
YojiKimeru.module.css) を作成 - 末端コンポーネントから順に作成: CharFeedbackCell -> GuessRow -> GameBoard -> GuessInput -> HintBar -> GameHeader -> モーダル群 -> ShareButtons -> GameContainer
- CSS Module (
フェーズ4: ページ統合 (UIコンポーネントに依存)
page.tsx+page.module.cssを作成src/app/games/page.tsxを更新src/app/sitemap.tsを更新- ページ/コンポーネントテストを作成
フェーズ5: 検証
- 全テスト実行 (
npm test) - 既存の漢字カナールテストが壊れていないことを確認
- ビルド確認 (
npm run build)
- 全テスト実行 (
Acceptance Criteria
- 全ファイル一覧と各ファイルの実装仕様が記載されている (Step 7)
- 四字熟語データの型定義と100個以上のデータソースが計画されている (Step 1)
- ゲームエンジンの推測評価ロジックが明確に定義されている (Step 2.1)
- フィードバックUIの仕様が具体的である (Step 3.5)
- 入力UIの仕様が具体的で、モバイルでの操作が考慮されている (Step 3.6)
- テスト計画が含まれている (Step 6)
- 既存のゲーム一覧ページ(/games)への追加方法が記載されている (Step 5.1)
- sitemapへの追加方法が記載されている (Step 5.2)
- Constitution準拠が確認されている (下記)
Constitution 準拠確認
- 日本の法律と基本的な倫理基準に準拠 -- 四字熟語は日本の文化教育コンテンツであり問題なし
- 訪問者にとって有益で楽しいウェブサイト -- 学習的価値のあるゲーム
- AIによる実験サイトであることの通知 -- 既存のAiDisclaimerコンポーネントがゲーム一覧ページに表示済み
- 創造的なアイデアで多様なコンテンツ -- 既存の漢字カナールと異なるゲームメカニクス (Wordleスタイル)
Rollback Approach
- 全ファイルが新規作成 (既存ファイルへの変更は
page.tsxとsitemap.tsの2ファイルのみ) - 既存ファイルの変更はいずれも追記のみ (配列への要素追加)
- 問題発生時は
git revertで完全にロールバック可能 - 漢字カナールの既存コードには一切変更を加えない
Next Actions
- PMがこの計画をレビューし承認する
- 承認後、PMから builder に実装メモを送信する
- builder はフェーズ1-5の順序で実装する
- 実装完了後、reviewer にレビューを依頼する