SLOPSHOPPER

wait-games

/games で、Claude の返事を待つ間に 2048・マインスイーパー・15 パズル・ライツアウト・数独・神経衰弱をペインで遊ぶ。記録は毎日控えを取り、控えから戻せる

newpanecommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wait-games
│ ┃ ミニゲーム ✕ › fix the failing auth test and add an audit log call │ ┃ ゲーム: 2048 ▾ │ ┃ ⏺ Read(src/auth.ts) │ ┃ スコア 0 / 最高 0 ⎿ Read 6 lines │ ┃ . . . . ⏺ Update(src/auth.ts) │ ┃ . . 4 . ⎿ Added 2 lines, removed 1 line │ ┃ . . . . ⏺ Bash(bun test) │ ┃ 4 . . . ⎿ 3 pass, 1 fail │ ┃ 0 手 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ ↑ ] │ ┃ [ ← ] [ ↓ ] [ → ] ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ 新しいゲーム ] › /games │ ┃ │ ┃ [ 控えから戻す ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ミニゲーム
ゲーム: 2048 ▾ スコア 0 / 最高 0 . . . . . . 4 . . . . . 4 . . . 0 手 [ ↑ ] [ ← ] [ ↓ ] [ → ] [ 新しいゲーム ] [ 控えから戻す ]
README

claude-code-mods

English

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。

Anthropic とは関係のない、個人が作った非公式の mod です。

入っている mod

mod呼び方何をするか
mod-hub/mod画面のある mod(ペインや帯を出す物)をアイコン付きで並べる。名前・説明で絞る、固定して上に置く、カテゴリー別(開発・状況・道具・遊び)・名前順・最近開いた順。見出しに季節の小さな飾り
cc-dash/dashセッションの計器盤。経過・ターン・ツール・エラーのタイル、ターンの所要時間の棒グラフ、ツールのタイムライン、使用量のリング型ゲージ。「自分の並び」で出す枠と順を自分で決められる
work-bar入力欄の上に自動で出る作業中は経過時間・今のツール・流れる光の帯。待ち中は前のターンのまとめと定型文のボタン
aqua-pet/aquarium作業に反応する水槽。魚は 1 匹ずつターンで 稚魚 → 若魚 → 成魚 → 大物 と育ち、節目で新しい魚(全 10 種、後ほど珍しい)が来る。エラーで赤く震える。えさ・名前・掃除・魚の一覧のボタン。日本時間の時刻で水の色と光が(夕は橙で沈む日、夜は月と星)、季節で飾り(花びら・泡・落ち葉・雪)が変わる。turn-gacha で集めたカードでかざり(水草・石・流木・貝・つぼ・城・宝箱・沈没船)が手に入り、底の 5 つの置き場に置ける(城と沈没船の周りを魚が回る)。記録は毎日控えを取り、ペインから戻せる
project-board/projectsフォルダに並んだ引き継ぎメモ(*.md)の鮮度グラフと一覧・詳細
git-pane/git今のフォルダの git の様子。ブランチ・変更行数の差分バー・直近コミット。git には書き込まない
token-meter/tokensターンごとのトークンの積み上げ棒とキャッシュの当たり率
focus-timer/focus-timerポモドーロ。減っていくリングと今日の本数
session-recap/session-recapセッションのふりかえり。ターンの帯のタイムラインと、押した時だけ作る要約
edit-log/editsこのセッションで Claude が Edit / Write などの編集ツールで変えたファイルの時系列。押すと git の差分(HEAD と比べる)と「レビューして」
todo-board/todos今のフォルダの TODO / FIXME / HACK / XXX の件数・推移・一覧。git の外はフォルダを歩いて読む。種類のボタンで絞り、押すと「片付けて」
test-runner/testrun設定したテストのコマンドをボタンで走らせる。信号と直近 20 回の結果、失敗したら「直して」。Claude が走らせたテストも拾い、落ちている間は入力欄の上に 1 行出す。落ちたままコミットしようとしたらトーストで知らせる
prompt-book/promptsよく使う依頼文を保存して、ボタンで入力欄へ。タグ・使った回数・並び替え
live-commentary/jikkyoClaude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西
turn-gacha/gachaターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。daily-quest のお題をこなすと、おまけで引ける。記録は毎日控えを取り、「控えから戻す」で戻せる
dev-rpg/rpg作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。daily-quest のお題をこなすと経験値が入る。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる
code-omikuji/omikuji今日のコード運勢。筒を振ると棒の番号が回って止まり、和紙のおみくじが開く。文は 238 通りと、その季節の日だけ出る一言
agent-watch/agent-watch動いているサブエージェントの帯のタイムライン(経過・ツール回数・状態)。ボタンでその会話へ切り替え、メインへ戻る
canvas-pane/canvasClaude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる
rule-switch/rules「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる
wait-games/games長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる
spec-board/specsSpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ)
ci-pane/ci今のブランチの PR(状態・レビュー・チェックの丸)と GitHub Actions の結果。gh を使う。何かを起こす操作は無い
port-watch/ports待ち受け中の TCP ポートと、セッション中に増えた物の印。URL を写すボタンと、開発用の実行環境だけ 2 回押しで止めるボタン
pre-check/precheckコミット前の確かめ。変更の要約・秘密情報らしい文字列(足した行だけ、伏せて表示)・衝突の印・1MB 超のファイル・テスト(ボタンで)
face-band/face で出す / 隠す待ち・考え中・ツール実行中・答え終わり・エラーで変わる Claude の顔。日本時間の時間帯と季節で小物が変わる(夜は眠そうにナイトキャップ、夏は麦わら帽子、冬はマフラーなど)。入れただけでは出ない(最初はオフ)。work-bar と並べて出る
done-chime/chime長いターン(既定 30 秒以上)が終わったら音を鳴らす。音の種類・しきい値・音量・試し鳴らし
turn-haiku/haikuターンが終わるたびに、やった事を五七五の 1 句にして縦書きの短冊に。季節の季語の候補を添えて詠む。過去の句の一覧
session-timeline/timelineこのセッションのターンとツール呼び出しを横の帯の年表で。ツールの種類で色分け、失敗は赤い印、サブエージェントは細い帯、1 分以上のターンは黄の縁
file-map/filemapこのセッションで読んだ・書いたファイルを、フォルダごとの四角(ツリーマップ)で。大きいほど多く触った所、橙は書いた物、青は読んだだけ
toast-inbox/inboxほかの mod が出したトーストを 100 件まで残す受け箱(設定でステータス行も)。未読の数・mod ごとの件数・mod で絞る・全部既読・空にする。通知の行を押すと、それを出した mod のペインを開く
usage-history/history日をまたいだ使い方の記録。日ごとのターン数か出力トークンの棒(7 日 / 30 日)、合計と 1 日平均、曜日と時間帯のヒートマップ。直近 7 日の数をほかの mod(week-card)にも見せる。記録は毎日控えを取れる
fail-log/errorsツールが失敗した記録を 200 件まで残す。似た失敗をまとめた「よく落ちる物」の棒、最近の一覧、今日・通算の数。サブエージェントの分は紫で分ける
daily-quest/quest日替わりのお題 3 つ(ターン・ファイルを直す・テスト・コミットなど 8 種から)。進みの棒・ごほうび・連続日数・直近 7 日。ごほうびは dev-rpg が経験値、turn-gacha がおまけの抽選で受け取る。記録は毎日控えを取り、「控えから戻す」で戻せる
trophy-shelf/trophiesdev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も
mod-check/mod-checkmod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする
compact-cue入力欄の上に自動で出る・/cueコンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない
ship-guard常時・/guardClaude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す
rule-watch入力欄の上に自動で出る・/rulewatchClaude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・AskUserQuestion を使わずに「〜しますか?」で終わった・コミットの後に引き継ぎメモや開発ログを書いていない・開発ログを書いたのに「📝」の 1 行が無い・締めの言葉なのに開発ログが無い、の 6 つ。ペインは数と記録、「Claude に伝える」で入力欄に一言入れる
enc-guard常時・/enc文字コード・改行の罠を Claude が踏んだ時に、直し方を Claude に伝える(設定で止めるにもできる)。-Encoding の無い Set-Content / Add-Content、日本語入りで BOM の無い .ps1、ASCII 以外の字が入った .bat / .cmd、Windows で encoding= / newline= の無い Python の open
release-draft/release前のタグから今までのコミットと、版が上がった plugin.json / package.json を集めて、CHANGELOG の下書き(Added / Changed / Fixed)と次の版の候補を出す。「これで CHANGELOG を書いて」で Claude に頼む。git は読むだけ
reply-frame常時返答をオレンジ、自分の発言を青の角丸の枠で囲む
danger-frame常時危ないシェルのコマンドの行を赤い枠で囲む
long-turn-toast常時長くかかったターン(既定 120 秒超)をトーストで知らせる
usage-pane/usage-pane使用量の JSON を 3 本のバーで表示
devlog-pane/devlogフォルダにある今日の開発ログ(YYYY-MM-DD.md)を表示
run-guard常時・/runguardClaude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る
week-card/week1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 1 枚のカードにまとめ、週報に貼る文(Markdown)を入力欄に入れる

reply-frame は takosasi-dev/reply-frame でも公開しています。ここに入っているのは、自分の発言の枠を足した新しい版です。

画面

VS Code のペインに描かれる SVG の部分です(架空のデータ。ボタンなどは省いています)。node tools/shots.mts で作り直せます(--light で明るいテーマ)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.png" width="250" alt="mod-hub"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.png" width="250" alt="cc-dash"></td> <td align="center" valign="top"><b>work-bar</b><br><img src="docs/images/work-bar.png" width="250" alt="work-bar"></td> </tr> <tr> <td align="center" valign="top"><b>face-band</b><br><img src="docs/images/face-band.png" width="250" alt="face-band"></td> <td align="center" valign="top"><b>aqua-pet</b><br><img src="docs/images/aqua-pet.png" width="250" alt="aqua-pet"></td> <td align="center" valign="top"><b>project-board</b><br><img src="docs/images/project-board.png" width="250" alt="project-board"></td> </tr> <tr> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane.png" width="250" alt="git-pane"></td> <td align="center" valign="top"><b>token-meter</b><br><img src="docs/images/token-meter.png" width="250" alt="token-meter"></td> <td align="center" valign="top"><b>focus-timer</b><br><img src="docs/images/focus-timer.png" width="250" alt="focus-timer"></td> </tr> <tr> <td align="center" valign="top"><b>session-recap</b><br><img src="docs/images/session-recap.png" width="250" alt="session-recap"></td> <td align="center" valign="top"><b>edit-log</b><br><img src="docs/images/edit-log.png" width="250" alt="edit-log"></td> <td align="center" valign="top"><b>todo-board</b><br><img src="docs/images/todo-board.png" width="250" alt="todo-board"></td> </tr> <tr> <td align="center" valign="top"><b>test-runner</b><br><img src="docs/images/test-runner.png" width="250" alt="test-runner"></td> <td align="center" valign="top"><b>pre-check</b><br><img src="docs/images/pre-check.png" width="250" alt="pre-check"></td> <td align="center" valign="top"><b>ci-pane</b><br><img src="docs/images/ci-pane.png" width="250" alt="ci-pane"></td> </tr> <tr> <td align="center" valign="top"><b>spec-board</b><br><img src="docs/images/spec-board.png" width="250" alt="spec-board"></td> <td align="center" valign="top"><b>port-watch</b><br><img src="docs/images/port-watch.png" width="250" alt="port-watch"></td> <td align="center" valign="top"><b>prompt-book</b><br><img src="docs/images/prompt-book.png" width="250" alt="prompt-book"></td> </tr> <tr> <td align="center" valign="top"><b>agent-watch</b><br><img src="docs/images/agent-watch.png" width="250" alt="agent-watch"></td> <td align="center" valign="top"><b>canvas-pane</b><br><img src="docs/images/canvas-pane.png" width="250" alt="canvas-pane"></td> <td align="center" valign="top"><b>rule-switch</b><br><img src="docs/images/rule-switch.png" width="250" alt="rule-switch"></td> </tr> <tr> <td align="center" valign="top"><b>live-commentary</b><br><img src="docs/images/live-commentary.png" width="250" alt="live-commentary"></td> <td align="center" valign="top"><b>turn-gacha</b><br><img src="docs/images/turn-gacha.png" width="250" alt="turn-gacha"></td> <td align="center" valign="top"><b>dev-rpg</b><br><img src="docs/images/dev-rpg.png" width="250" alt="dev-rpg"></td> </tr> <tr> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.png" width="250" alt="code-omikuji"></td> <td align="center" valign="top"><b>turn-haiku</b><br><img src="docs/images/turn-haiku.png" width="250" alt="turn-haiku"></td> <td align="center" valign="top"><b>wait-games</b><br><img src="docs/images/wait-games.png" width="250" alt="wait-games"></td> </tr> <tr> <td align="center" valign="top"><b>session-timeline</b><br><img src="docs/images/session-timeline.png" width="250" alt="session-timeline"></td> <td align="center" valign="top"><b>file-map</b><br><img src="docs/images/file-map.png" width="250" alt="file-map"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.png" width="250" alt="usage-history"></td> </tr> <tr> <td align="center" valign="top"><b>toast-inbox</b><br><img src="docs/images/toast-inbox.png" width="250" alt="toast-inbox"></td> <td align="center" valign="top"><b>done-chime</b><br><img src="docs/images/done-chime.png" width="250" alt="done-chime"></td> <td align="center" valign="top"><b>fail-log</b><br><img src="docs/images/fail-log.png" width="250" alt="fail-log"></td> </tr> <tr> <td align="center" valign="top"><b>daily-quest</b><br><img src="docs/images/daily-quest.png" width="250" alt="daily-quest"></td> <td align="center" valign="top"><b>trophy-shelf</b><br><img src="docs/images/trophy-shelf.png" width="250" alt="trophy-shelf"></td> <td align="center" valign="top"><b>mod-check</b><br><img src="docs/images/mod-check.png" width="250" alt="mod-check"></td> </tr> <tr> <td align="center" valign="top"><b>compact-cue</b><br><img src="docs/images/compact-cue.png" width="250" alt="compact-cue"></td> <td align="center" valign="top"><b>ship-guard</b><br><img src="docs/images/ship-guard.png" width="250" alt="ship-guard"></td> <td align="center" valign="top"><b>rule-watch</b><br><img src="docs/images/rule-watch.png" width="250" alt="rule-watch"></td> </tr> <tr> <td align="center" valign="top"><b>enc-guard</b><br><img src="docs/images/enc-guard.png" width="250" alt="enc-guard"></td> <td align="center" valign="top"><b>release-draft</b><br><img src="docs/images/release-draft.png" width="250" alt="release-draft"></td> <td align="center" valign="top"><b>run-guard</b><br><img src="docs/images/run-guard.png" width="250" alt="run-guard"></td> </tr> <tr> <td align="center" valign="top"><b>week-card</b><br><img src="docs/images/week-card.png" width="250" alt="week-card"></td> </tr> </table>

動く絵

ペインの絵は開いた時に 1 回動きます(数字が増える・棒が伸びる・おみくじが開く)。node tools/gif.mts <mod> で作り直せます。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.gif" width="250" alt="mod-hub(動く絵)"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.gif" width="250" alt="cc-dash(動く絵)"></td> </tr> <tr> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.gif" width="250" alt="usage-history(動く絵)"></td> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.gif" width="250" alt="code-omikuji(動く絵)"></td> </tr> </table>

明るいテーマ

Claude Code の /config で theme を明るい物(light で始まる物)にすると、SVG も明るい色で描きます(VS Code の配色は mod から読めないので、Claude Code の theme に合わせます)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub-light.png" width="250" alt="mod-hub(明るいテーマ)"></td> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane-light.png" width="250" alt="git-pane(明るいテーマ)"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history-light.png" width="250" alt="usage-history(明るいテーマ)"></td> </tr> </table>

動作環境

  • Claude Code 2.1.300 以降(関数フックの mod を読み込める版)
  • VS Code で描くには、VS Code 拡張 2.1.300 以降が要ります。2.1.288 までの拡張は mod の描画に対応していません
  • ターミナル・デスクトップアプリでも動きます。ターミナルでは SVG の代わりに文字だけの表示になります

入れ方

好きな mod だけ入れられます。冒険の書(dev-rpg)とガチャ(turn-gacha)の 2 本だけが欲しい時は、単体版の claude-code-dev-rpg からも入れられます(中身は同じなので、どちらか片方から入れてください)。ターミナルの Claude Code の入力欄で:

/plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:

claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-mods を入れ、Add plugin で選びます。

mod-hub に並ぶのは、mod-hub より後に読み込まれる mod だけです。mod-hub を最初に入れてください。カテゴリーとアイコンはこのリポジトリの mod だけに付いていて、ほかの mod は「その他」に入ります。toast-inbox と mod-check も、ほかの mod より後から読み込まれる物を見るので、mod-hub の次に入れるのがおすすめです。

コマンドの名前がほかのプラグインや組み込みと被った時は、/edit-log・/todo-board・/test-runner・/prompt-book・/live-commentary・/turn-gacha・/dev-rpg・/code-omikuji・/gitpane・/token-meter・/mod-hub・/canvas-pane・/rule-switch・/wait-games・/spec-board・/ci-pane・/port-watch・/pre-check・/face-band・/done-chime・/turn-haiku・/session-timeline・/file-map・/toast-inbox・/usage-history・/fail-log・/compact-cue・/ship-guard・/rule-watch・/enc-guard・/release-draft・/daily-quest・/trophy-shelf・/modcheck・/run-guard・/week-card のように mod の名前のコマンドになります。

まとめて入れる

claude plugin install は 1 回に 1 本だけ受け取ります。まとめて入れる時は、下の行のかたまりをシェル(bash・zsh・PowerShell のどれでも)にそのまま貼ってください。最初の 1 本でマーケットプレイスが足され、2 本目からはそれを使います。スコープは既定で user(全プロジェクト)で、-s project を付けると今のプロジェクトだけに入ります。mod-hub・toast-inbox・mod-check を先に入れてから、ほしいカテゴリーを開いて貼ります。

最初に(mod-hub・toast-inbox・mod-check)

claude plugin install mod-hub --marketplace takosasi-dev/claude-code-mods
claude plugin install toast-inbox --marketplace takosasi-dev/claude-code-mods
claude plugin install mod-check --marketplace takosasi-dev/claude-code-mods
claude plugin install project-board --marketplace takosasi-dev/claude-code-mods
claude plugin install git-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install edit-log --marketplace takosasi-dev/claude-code-mods
claude plugin install todo-board --marketplace takosasi-dev/claude-code-mods
claude plugin install test-runner --marketplace takosasi-dev/claude-code-mods
claude plugin install spec-board --marketplace takosasi-dev/claude-code-mods
claude plugin install ci-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install port-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install pre-check --marketplace takosasi-dev/claude-code-mods
claude plugin install fail-log --marketplace takosasi-dev/claude-code-mods
claude plugin install ship-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install enc-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install release-draft --marketplace takosasi-dev/claude-code-mods
claude plugin install run-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
claude plugin install work-bar --marketplace takosasi-dev/claude-code-mods
claude plugin install token-meter --marketplace takosasi-dev/claude-code-mods
claude plugin install session-recap --marketplace takosasi-dev/claude-code-mods
claude plugin install agent-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install session-timeline --marketplace takosasi-dev/claude-code-mods
claude plugin install file-map --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-history --marketplace takosasi-dev/claude-code-mods
claude plugin install compact-cue --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install devlog-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install focus-timer --marketplace takosasi-dev/claude-code-mods
claude plugin install prompt-book --marketplace takosasi-dev/claude-code-mods
claude plugin install canvas-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-switch --marketplace takosasi-dev/claude-code-mods
claude plugin install done-chime --marketplace takosasi-dev/claude-code-mods
claude plugin install aqua-pet --marketplace takosasi-dev/claude-code-mods
claude plugin install live-commentary --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-mods
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-mods
claude plugin install code-omikuji --marketplace takosasi-dev/claude-code-mods
claude plugin install wait-games --marketplace takosasi-dev/claude-code-mods
claude plugin install face-band --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-haiku --marketplace takosasi-dev/claude-code-mods
claude plugin install daily-quest --marketplace takosasi-dev/claude-code-mods
claude plugin install trophy-shelf --marketplace takosasi-dev/claude-code-mods
claude plugin install week-card --marketplace takosasi-dev/claude-code-mods
claude plugin install reply-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install danger-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install long-turn-toast --marketplace takosasi-dev/claude-code-mods

全部(48 本)をまとめて(bash / zsh):

for m in mod-hub toast-inbox mod-check cc-dash work-bar aqua-pet project-board git-pane token-meter focus-timer session-recap edit-log todo-board test-runner prompt-book live-commentary turn-gacha dev-rpg code-omikuji agent-watch canvas-pane rule-switch wait-games spec-board ci-pane port-watch pre-check face-band done-chime turn-haiku session-timeline file-map usage-history compact-cue fail-log ship-guard rule-watch enc-guard release-draft daily-quest trophy-shelf reply-frame danger-frame long-turn-toast usage-pane devlog-pane run-guard week-card; do claude plugin install "$m" --marketplace takosasi-dev/claude-code-mods; done

PowerShell:

foreach ($m in 'mod-hub','toast-inbox','mod-check','cc-dash','work-bar','aqua-pet','project-board','git-pane','token-meter','focus-timer','session-recap','edit-log','todo-board','test-runner','prompt-book','live-commentary','turn-gacha','dev-rpg','code-omikuji','agent-watch','canvas-pane','rule-switch','wait-games','spec-board','ci-pane','port-watch','pre-check','face-band','done-chime','turn-haiku','session-timeline','file-map','usage-history','compact-cue','fail-log','ship-guard','rule-watch','enc-guard','release-draft','daily-quest','trophy-shelf','reply-frame','danger-frame','long-turn-toast','usage-pane','devlog-pane','run-guard','week-card') { claude plugin install $m --marketplace takosasi-dev/claude-code-mods }

この一覧は node tools/plugin_dirs.mts --readme で作り直せます(--en で英語版)。

clone したフォルダから読ませる

リポジトリを clone して、そのフォルダから読ませることもできます。

  • フォルダをマーケットプレイスとして足す: 上の行の takosasi-dev/claude-code-mods を clone 先のパス(例 D:\work\claude-code-mods)に変えて入れます。mod はそのフォルダから直接読まれるので、git pull の後に /reload-plugins で新しい版になります
  • CLAUDE_CODE_PLUGIN_DIRS で読ませる: 入れずに、セッションを始める時に毎回フォルダから読みます。値は clone 先で node tools/plugin_dirs.mts を走らせると出ます(別の場所を指すなら node tools/plugin_dirs.mts D:\work\claude-code-mods)。mod-hub・toast-inbox・mod-check を先頭に、全 mod のフォルダを Windows は ;、ほかは : でつないだ 1 行です。これを環境変数か、~/.claude/settings.json の env の CLAUDE_CODE_PLUGIN_DIRS に入れます(道具は settings.json を書き換えません)。要らない mod はその行から消してください
D:\work\claude-code-mods\mods\mod-hub;D:\work\claude-code-mods\mods\toast-inbox;D:\work\claude-code-mods\mods\cc-dash;...

気を付けること

  • rule-switch の約束ごとは、サブエージェントのシステムプロンプトにも入ります。また、組織が Claude Code を管理している環境では、利用者が入れたプラグインのシステムプロンプトのフックは動きません
  • rule-switch を切り替えると、プロンプトのキャッシュが作り直されます
  • canvas-pane のツールに渡された生の SVG は、スクリプト・イベント属性・外部の参照を含む物を断ります(ペインでは画像として描かれ、スクリプトは動きません)
  • turn-haiku は、答えて終わったターンごと(ツールを使ったか 20 秒以上かかった時)に Haiku のモデルを 1 回呼び、トークンを使います。/haiku の「自動で詠む」で止められます
  • port-watch の「止める」は子プロセスごと止めます(Windows は taskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認です
  • pre-check の秘密情報の検出は手がかり程度です。見つからなくても安全とは限りません
  • done-chime は Windows では PowerShell の SoundPlayer で鳴らします。それ以外は Claude Code の音の再生を使い、Linux のターミナルでは鳴らないことがあります
  • spec-board の一覧は SpecStatus が GitHub の API を使うため、回数の上限に当たることがあります。読み直しは 5 分ごとです
  • ci-pane と git を読む mod は、所有者の違うフォルダ(別ドライブなど)では git が止まります。git config --global --add safe.directory <フォルダ> を足してください
  • aqua-pet の背景は 15 分ごとに描き直します。描き直すと泳ぎや泡のアニメが頭から始まります
  • aqua-pet 0.2 の記録は、開いた時に今の匹数の魚を作って引き継ぎます。0.2 と 0.3 のセッションを同時に開いていると、魚の来た日が作り直されることがあります
  • dev-rpg の装備は turn-gacha 0.2.0 以降と一緒に入れた時だけ使えます。ガチャの図鑑から消えたカードは外れた扱いになりますが、記録は残ります
  • toast-inbox が拾うのは、ほかの mod が $.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消します
  • usage-history は入れた後のターンから数えます。日付・曜日・時刻は日本時間です
  • file-map は失敗した呼び出しを数えません。Grep と Glob は path を渡した時だけ数えます
  • 明るいテーマでも、水槽・俳句の短冊・おみくじの紙・実況のスタジアム・RPG の窓は絵の色のままです
  • mod の版を上げたら、開いているほかのセッションも開き直してください。0.7.0 から記録は知らない欄を残して書くので、新しい版の記録は消えなくなりましたが、0.6 以前の mod を読んだまま動いているセッションは、新しい版で増えた欄(魚の種類・装備・SP の割り振りなど)を消して書き戻します
  • fail-log の記録は、同時に開いた全セッションで共有します。「今日」は日本時間の日付で、残している 200 件から数えます
  • todo-board は git の中では git grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してください
  • daily-quest のお題は日本時間の日付だけで決まります。数えるのはメインのターンだけで、「テスト」はコマンドの名前の形(npm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取ります
  • trophy-shelf はほかの mod の記録を読むだけです。入っていない mod の棚は「入っていません」と出ます。一度取ったトロフィーは、その mod を外しても残ります
  • cc-dash の「自分の並び」は記録に残るので、全セッションで共有されます
  • mod-check が版と「読み込みを拒まれた」を出せるのは、mod-check より後に読み込まれた mod だけです。「state まだ」は壊れているという意味ではありません(開
Source 12 files
hooks/register.tsx 482 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Dir, Fifteen, Game2048, GameKind, GameStats, Lights, Memory, Mines, Puzzle, RecordKind, Sudoku, SudokuSize } from '../types'
5import { applyFifteen, look as lookFifteen, newFifteen, SIDE as FIFTEEN_SIDE } from './fifteen'
6import { newGame, play } from './game2048'
7import { applyLights, look as lookLights, newLights, SIDE as LIGHTS_SIDE } from './lights'
8import { applyMemory, look as lookMemory, newMemory } from './memory'
9import { COLS, look, newMines, reveal, ROWS, toggleFlag } from './mines'
10import { addStats, EMPTY_STATS, keepUnknown, statsFromSaved } from './stats'
11import type { StatsDelta } from './stats'
12import { applySudoku, boxOf, look as lookSudoku, newSudoku, selectCell } from './sudoku'
13import {
14  board2048Rows,
15  board2048Svg,
16  C,
17  fifteenHead,
18  fifteenSvg,
19  lightsHead,
20  lightsSvg,
21  lines2048,
22  memoryHead,
23  memorySvg,
24  minesHead,
25  minesSvg,
26  sudokuHead,
27  sudokuSvg,
28} from './svg'
29import type { Head } from './svg'
30import { backup, backupDir, backupOf, closeRestore, isBackedToday, resetRestore, restoreButton, restoreCard, setBackupDir } from './backup'
31import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
32
33const PANE = 'wait-games'
34const TITLE = 'ミニゲーム'
35const DESCRIPTION = 'Claude の返事を待つ間に 2048・マインスイーパー・15 パズル・ライツアウト・数独・神経衰弱で遊ぶ'
36
37/** ゲームの並び (Select の順) */
38const GAMES: { value: GameKind; label: string }[] = [
39  { value: '2048', label: '2048' },
40  { value: 'mines', label: 'マインスイーパー' },
41  { value: 'fifteen', label: '15 パズル' },
42  { value: 'lights', label: 'ライツアウト' },
43  { value: 'sudoku', label: '数独' },
44  { value: 'memory', label: '神経衰弱' },
45]
46const STORE_KEY = 'stats'
47
48const GAME = atom({ plugin: 'wait-games', key: 'game' } as const, '2048' as GameKind)
49const G2048 = atom({ plugin: 'wait-games', key: 'g2048' } as const, newGame(Math.random) as Game2048)
50const MINES = atom({ plugin: 'wait-games', key: 'mines' } as const, newMines() as Mines)
51const FLAG = atom({ plugin: 'wait-games', key: 'flagMode' } as const, false)
52const FIFTEEN = atom({ plugin: 'wait-games', key: 'fifteen' } as const, newFifteen(Math.random) as Fifteen)
53const LIGHTS = atom({ plugin: 'wait-games', key: 'lights' } as const, newLights(Math.random) as Lights)
54// 既定は軽い 4x4 (9x9 は作るのに少し時間がかかるので、ボタンを押した時だけ作る)
55const SUDOKU = atom({ plugin: 'wait-games', key: 'sudoku' } as const, newSudoku(4, Math.random) as Sudoku)
56const MEMORY = atom({ plugin: 'wait-games', key: 'memory' } as const, newMemory(Math.random) as Memory)
57const STATS = atom({ plugin: 'wait-games', key: 'stats' } as const, EMPTY_STATS as GameStats)
58const PLAYING = atom({ plugin: 'wait-games', key: 'isPlaying' } as const, false)
59const TURN_NOTE = atom({ plugin: 'wait-games', key: 'turnNote' } as const, '')
60const NOTE = atom({ plugin: 'wait-games', key: 'note' } as const, '')
61
62type $ = EngineInterface
63
64const BROKEN = '記録を読めなかったので、最高点と勝ちを残せません(上書きしないよう止めています)'
65
66let ownsMain = false
67
68// store に書く物は 1 本ずつ流す (連打で同じ値を読んで 1 回分消えないように)
69let chain: Promise<unknown> = Promise.resolve()
70const serial = (f: () => Promise<unknown>) => (chain = chain.then(f, f))
71
72/** store の記録と生の値。キーが無ければ空、読めない時 (例外・形が違う) は stats が undefined */
73async function readStore($: $): Promise<{ stats: GameStats | undefined; raw: unknown }> {
74  try {
75    const raw = await $.store.get(STORE_KEY)
76    return { stats: statsFromSaved(raw), raw }
77  } catch {
78    return { stats: undefined, raw: undefined }
79  }
80}
81
82async function load($: $) {
83  const { stats: saved, raw } = await readStore($)
84  if (saved) await update($, STATS, () => saved)
85  else await update($, NOTE, () => BROKEN)
86  // その日はじめてなら、読んだ生の値 (知らない欄ごと) を控える
87  if (backupDir) await backup($, STORE_KEY, raw, await $.clock.now())
88}
89
90/** ターンの終わりに、その日まだ控えていなければ控える (日をまたいで動き続けるセッション)。控え済みなら store も読まない */
91async function backupIfDue($: $) {
92  const now = await $.clock.now()
93  if (!isBackedToday(STORE_KEY, now)) await backup($, STORE_KEY, (await readStore($)).raw, now)
94}
95
96/** day の控えに戻し、メモリの中の記録も読み直す。古い版のままなら戻さない */
97const restoreDay = ($: $, day: string) =>
98  serial(async () => {
99    if (!(await mayWrite($, await $.clock.now()))) return
100    const value = await backupOf($, STORE_KEY, day)
101    if (typeof value !== 'object' || value === null || Array.isArray(value)) {
102      $.ui.toast('控えを読めなかったので、戻していません', { timeoutMs: 8000 })
103      return
104    }
105    const isSaved = await $.store.set(STORE_KEY, value).then(
106      () => true,
107      () => false,
108    )
109    if (!isSaved) {
110      $.ui.toast('控えの記録を書けませんでした', { timeoutMs: 8000 })
111      return
112    }
113    await update($, NOTE, () => '')
114    await load($)
115    closeRestore($)
116    $.ui.toast(`${day} の記録に戻しました`)
117  })
118
119/** 増えた分だけを、書く直前に読み直した store に足して書く (別のセッションの分を消さない) */
120const record = ($: $, d: StatsDelta) =>
121  serial(async () => {
122    const { stats: saved, raw } = await readStore($)
123    if (!saved) {
124      await update($, NOTE, () => BROKEN)
125      return
126    }
127    const now = await $.clock.now()
128    if (backupDir) await backup($, STORE_KEY, raw, now)
129    // 新しい版が入ったのに古い版のまま動いている時は書かない (トーストで開き直しを頼む)
130    if (!(await mayWrite($, now))) return
131    const next = addStats(saved, d)
132    // 知らない欄 (新しい版が足した物) は読み直した生の値から残す
133    const isSaved = await $.store.set(STORE_KEY, keepUnknown(raw, next)).then(
134      () => true,
135      () => false,
136    )
137    await update($, STATS, () => next)
138    await update($, NOTE, () => (isSaved ? '' : '記録を保存できませんでした'))
139  })
140
141const touch = ($: $) => update($, PLAYING, () => true)
142
143async function move2048($: $, dir: Dir) {
144  await touch($)
145  let isNewWin = false
146  const g = await update($, G2048, cur => {
147    const r = play(cur, dir, Math.random)
148    isNewWin = r.isNewWin
149    return r.game
150  })
151  const best = (await read($, STATS)).best2048
152  if (isNewWin || g.score > best) await record($, { best2048: g.score, wins2048: isNewWin ? 1 : 0 })
153}
154
155async function pressCell($: $, i: number) {
156  await touch($)
157  const isFlag = await read($, FLAG)
158  const now = await $.clock.now()
159  let wasWon = false
160  const s = await update($, MINES, cur => {
161    wasWon = cur.status === 'won'
162    return isFlag ? toggleFlag(cur, i) : reveal(cur, i, now, Math.random)
163  })
164  if (!wasWon && s.status === 'won') await record($, { winsMines: 1, fastestMines: Math.max(1, s.endedAt - s.startedAt) })
165}
166
167/** 押して解くゲームの 1 手。解けた時 (前は解けていなかった) だけ記録を足す */
168// run は update($, <atom>, f) を呼ぶ形で渡す (update の atom はその場に書かないとエンジンが読めない)
169async function playPuzzle<T extends Puzzle>($: $, run: (f: (cur: T) => T) => Promise<T>, apply: (cur: T, now: number) => T, kind: (s: T) => RecordKind) {
170  await touch($)
171  const now = await $.clock.now()
172  let wasWon = false
173  const s = await run(cur => {
174    wasWon = cur.status === 'won'
175    return apply(cur, now)
176  })
177  if (!wasWon && s.status === 'won') await record($, { puzzle: { kind: kind(s), moves: s.moves, ms: Math.max(1, s.endedAt - s.startedAt) } })
178}
179
180const sudokuKind = (s: Sudoku): RecordKind => (s.size === 4 ? 'sudoku4' : 'sudoku9')
181
182const openPane = async ($: $) => {
183  await load($)
184  await $.ui.open({ id: PANE, title: TITLE })
185  return { text: 'ミニゲームを開きました。' }
186}
187
188export const register: Register = (on, options) => {
189  setBackupDir(options)
190  followTheme(on)
191
192  on('session.start', async ($, e, next) => {
193    const started = await next(e)
194    await readTheme($)
195    await rememberVersion($).catch(() => {})
196    resetRestore()
197    // /games が拒まれたら (組み込みと被ったら) /wait-games にする
198    const got = await $.command.register({ name: 'games', description: DESCRIPTION }).catch(() => undefined)
199    ownsMain = got?.command === 'games'
200    if (!ownsMain) await $.command.register({ name: 'wait-games', description: DESCRIPTION }).catch(() => {})
201    await load($)
202    return started
203  })
204
205  // /games を持てなかった時は、元の /games に回す
206  on('command.run', { command: 'games' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
207  // /games を持てた時は /wait-games を登録していないので、その名前の持ち主に回す
208  on('command.run', { command: 'wait-games' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
209
210  // メインのターンが始まったら、遊んでいるかを数え直す
211  on('turn.start', async ($, e, next) => {
212    if (e.agentId === undefined) {
213      await update($, PLAYING, () => false)
214      await update($, TURN_NOTE, () => '')
215    }
216    return next(e)
217  })
218
219  // メインのターンの間に遊んでいたら、終わった時に 1 回だけ知らせる。中断は自分で止めたので知らせない
220  on('turn.complete', async ($, e, next) => {
221    const done = await next(e)
222    if (e.agentId === undefined && backupDir) await backupIfDue($).catch(() => {})
223    if (e.agentId === undefined && e.reason !== 'aborted' && (await read($, PLAYING))) {
224      const text = e.reason === 'answer' ? 'Claude のターンが終わりました' : 'Claude のターンが止まりました'
225      await update($, PLAYING, () => false)
226      await update($, TURN_NOTE, () => text)
227      $.ui.toast(text)
228    }
229    return done
230  }).catch(($, e, next) => next(e))
231
232  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233    const t = $.ui.resolve(e)
234    const { Box, Text, Button } = t
235    // terminal の表にも Svg の名前があるので、画面の種類でも分ける
236    const isTerminal = e.surface === 'terminal'
237    const Svg = !isTerminal && 'Svg' in t ? t.Svg : undefined
238    const game = await read($, GAME)
239    const stats = await read($, STATS)
240    const turnNote = await read($, TURN_NOTE)
241    const note = await read($, NOTE)
242
243    const pick = (v: string) => void update($, GAME, cur => GAMES.find(g => g.value === v)?.value ?? cur)
244    const picker =
245      'Select' in t ? (
246        <t.Select key="game" label="ゲーム" options={GAMES} value={game} onSelect={pick} />
247      ) : (
248        <Box key="game" flexDirection="row" flexWrap="wrap" columnGap={1}>
249          {GAMES.map(g => (
250            <Button key={`pick-${g.value}`} label={g.label} onPress={() => pick(g.value)} />
251          ))}
252        </Box>
253      )
254
255    /** 盤の上の見出し。vscode は SVG、terminal は 2 行の Text */
256    // 名前 h は JSX の関数なので使わない
257    const headOf = (svg: string, hd: Head) =>
258      Svg ? (
259        <Svg key="head" source={svg} alt={`${hd.left}、${hd.state}`} />
260      ) : (
261        <Box key="head" flexDirection="column">
262          <Text>
263            {hd.left} <Text color={hd.color}>{hd.state}</Text>
264          </Text>
265          <Text dimColor>{hd.record}</Text>
266        </Box>
267      )
268
269    /** side×side の Button の盤。cell は Button を返す。group を渡すと、その幅ごとに間を広げる (数独の箱) */
270    const board = (prefix: string, side: number, cell: (i: number) => RenderChildren, group = side) => (
271      <Box key={`${prefix}-grid`} flexDirection="column">
272        {Array.from({ length: side }, (_, r) => {
273          const cols = Array.from({ length: side }, (_, c) => r * side + c)
274          const row =
275            group < side ? (
276              Array.from({ length: side / group }, (_, k) => (
277                <Box key={`${prefix}-r${r}g${k}`} flexDirection="row" columnGap={1}>
278                  {cols.slice(k * group, (k + 1) * group).map(cell)}
279                </Box>
280              ))
281            ) : (
282              cols.map(cell)
283            )
284          return (
285            <Box key={`${prefix}-r${r}`} flexDirection="row" columnGap={group < side ? 2 : 1} marginTop={group < side && r > 0 && r % group === 0 ? 1 : undefined}>
286              {row}
287            </Box>
288          )
289        })}
290      </Box>
291    )
292
293    let body
294    if (game === 'fifteen') {
295      const g = await read($, FIFTEEN)
296      body = (
297        <Box key="fifteen" flexDirection="column" gap={1}>
298          {headOf(fifteenSvg(g, stats), fifteenHead(g, stats))}
299          {board('f', FIFTEEN_SIDE, i => {
300            const l = lookFifteen(g, i)
301            return <Button key={`f${i}`} label={l.text} plain dimColor={l.isBlank} onPress={() => void playPuzzle<Fifteen>($, f => update($, FIFTEEN, f), (cur, now) => applyFifteen(cur, i, now), () => 'fifteen')} />
302          })}
303          <Button key="new-fifteen" label="新しいゲーム" onPress={() => void update($, FIFTEEN, () => newFifteen(Math.random))} />
304        </Box>
305      )
306    } else if (game === 'lights') {
307      const g = await read($, LIGHTS)
308      body = (
309        <Box key="lights" flexDirection="column" gap={1}>
310          {headOf(lightsSvg(g, stats), lightsHead(g, stats))}
311          {board('l', LIGHTS_SIDE, i => {
312            const l = lookLights(g, i)
313            return <Button key={`l${i}`} label={isTerminal ? l.text : l.wide} plain dimColor={!l.isOn} onPress={() => void playPuzzle<Lights>($, f => update($, LIGHTS, f), (cur, now) => applyLights(cur, i, now), () => 'lights')} />
314          })}
315          <Button key="new-lights" label="新しいゲーム" onPress={() => void update($, LIGHTS, () => newLights(Math.random))} />
316        </Box>
317      )
318    } else if (game === 'sudoku') {
319      const g = await read($, SUDOKU)
320      const fresh = (n: SudokuSize) => () => void update($, SUDOKU, () => newSudoku(n, Math.random))
321      body = (
322        <Box key="sudoku" flexDirection="column" gap={1}>
323          {headOf(sudokuSvg(g, stats), sudokuHead(g, stats))}
324          {board(
325            's',
326            g.size,
327            i => {
328              const l = lookSudoku(g, i)
329              const label = isTerminal ? l.text : l.wide
330              // 選んだマスは反転、重なった数字は赤、自分で入れた数字は青、初めからある数字はそのまま
331              return (
332                <Button key={`s${i}`} label={label} plain onPress={() => void update($, SUDOKU, cur => selectCell(cur, i))}>
333                  <Text inverse={l.isSelected} color={l.isClash ? C.red : l.isGiven ? undefined : C.blue} dimColor={l.text === '.' && !l.isSelected}>
334                    {label}
335                  </Text>
336                </Button>
337              )
338            },
339            boxOf(g.size),
340          )}
341          <Box key="pad" flexDirection="row" flexWrap="wrap" columnGap={1}>
342            {Array.from({ length: g.size }, (_, k) => k + 1).map(d => (
343              <Button
344                key={`d${d}`}
345                label={isTerminal ? String(d) : String.fromCharCode(0xff10 + d)}
346                plain
347                dimColor={g.selected < 0}
348                onPress={() => void playPuzzle($, f => update($, SUDOKU, f), (cur, now) => applySudoku(cur, d, now), sudokuKind)}
349              />
350            ))}
351            <Button key="d0" label="消す" plain dimColor={g.selected < 0} onPress={() => void playPuzzle($, f => update($, SUDOKU, f), (cur, now) => applySudoku(cur, 0, now), sudokuKind)} />
352          </Box>
353          <Box key="sudoku-actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
354            <Button key="sudoku-4" label="新しく 4x4" variant={g.size === 4 ? 'primary' : undefined} onPress={fresh(4)} />
355            <Button key="sudoku-9" label="新しく 9x9" variant={g.size === 9 ? 'primary' : undefined} onPress={fresh(9)} />
356          </Box>
357        </Box>
358      )
359    } else if (game === 'memory') {
360      const g = await read($, MEMORY)
361      body = (
362        <Box key="memory" flexDirection="column" gap={1}>
363          {headOf(memorySvg(g, stats), memoryHead(g, stats))}
364          {board('c', 4, i => {
365            const l = lookMemory(g, i)
366            const label = isTerminal ? l.text : l.wide
367            return (
368              <Button key={`c${i}`} label={label} plain onPress={() => void playPuzzle<Memory>($, f => update($, MEMORY, f), (cur, now) => applyMemory(cur, i, now), () => 'memory')}>
369                <Text bold={l.isUp && !l.isMiss} color={l.isMiss ? C.red : undefined} dimColor={l.isDone}>
370                  {label}
371                </Text>
372              </Button>
373            )
374          })}
375          <Button key="new-memory" label="新しいゲーム" onPress={() => void update($, MEMORY, () => newMemory(Math.random))} />
376        </Box>
377      )
378    } else if (game === '2048') {
379      const g = await read($, G2048)
380      const best = Math.max(stats.best2048, g.score)
381      const view = Svg ? (
382        <Svg key="board" source={board2048Svg(g, stats)} alt={`2048 スコア ${g.score}、最高点 ${best}${g.isOver ? '、ゲームオーバー' : ''}`} />
383      ) : (
384        <Box key="board" flexDirection="column">
385          <Text bold>{lines2048(g, stats)[0]}</Text>
386          {board2048Rows(g).map((row, r) => (
387            <Box key={`r${r}`} flexDirection="row">
388              {row.cells.map(cell => (
389                <Text color={cell.color}>{cell.text}</Text>
390              ))}
391            </Box>
392          ))}
393          <Text dimColor={!g.isOver} color={g.isOver ? C.red : undefined}>
394            {lines2048(g, stats)[1]}
395          </Text>
396        </Box>
397      )
398      const arrow = (dir: Dir, label: string) => <Button key={dir} label={label} onPress={() => void move2048($, dir)} />
399      body = (
400        <Box key="g2048" flexDirection="column" gap={1}>
401          {view}
402          <Box key="pad" flexDirection="column" alignItems="center">
403            {arrow('up', '↑')}
404            <Box key="pad-row" flexDirection="row" gap={1}>
405              {arrow('left', '←')}
406              {arrow('down', '↓')}
407              {arrow('right', '→')}
408            </Box>
409          </Box>
410          <Button key="new-2048" label="新しいゲーム" onPress={() => void update($, G2048, () => newGame(Math.random))} />
411        </Box>
412      )
413    } else {
414      const s = await read($, MINES)
415      const isFlag = await read($, FLAG)
416      const head = minesHead(s, stats)
417      const rows = Array.from({ length: ROWS }, (_, r) => Array.from({ length: COLS }, (_, c) => r * COLS + c))
418      body = (
419        <Box key="mines" flexDirection="column" gap={1}>
420          {Svg ? (
421            <Svg key="head" source={minesSvg(s, stats)} alt={`${head.left}、${head.state}`} />
422          ) : (
423            <Box key="head" flexDirection="column">
424              <Text>
425                {head.left} <Text color={head.color}>{head.state}</Text>
426              </Text>
427              <Text dimColor>{head.record}</Text>
428            </Box>
429          )}
430          {/* 64 マスは plain の 1 文字の Button。29 桁でも 8 列 + 間 7 = 15 桁に収まる */}
431          <Box key="grid" flexDirection="column">
432            {rows.map((row, r) => (
433              <Box key={`row${r}`} flexDirection="row" columnGap={1}>
434                {row.map(i => {
435                  const l = look(s, i)
436                  return (
437                    <Button
438                      key={`m${i}`}
439                      label={isTerminal ? l.text : l.wide}
440                      plain
441                      dimColor={l.isOpen && !l.isBoom}
442                      variant={l.isBoom ? 'primary' : undefined}
443                      onPress={() => void pressCell($, i)}
444                    />
445                  )
446                })}
447              </Box>
448            ))}
449          </Box>
450          <Box key="mines-actions" flexDirection="row" flexWrap="wrap" columnGap={1}>
451            <Button
452              key="flag"
453              label={isFlag ? '旗モード: オン' : '旗モード: オフ'}
454              variant={isFlag ? 'primary' : 'secondary'}
455              onPress={() => void update($, FLAG, v => !v)}
456            />
457            <Button key="new-mines" label="新しいゲーム" onPress={() => void update($, MINES, () => newMines())} />
458          </Box>
459        </Box>
460      )
461    }
462
463    return (
464      <Box flexDirection="column" gap={1}>
465        {turnNote !== '' && (
466          <Box key="turn" flexDirection="column">
467            <Text color={C.blue} bold>
468              {turnNote}
469            </Text>
470            <Button key="turn-ok" label="閉じる" onPress={() => void update($, TURN_NOTE, () => '')} />
471          </Box>
472        )}
473        {picker}
474        {body}
475        {note !== '' && <Text color={C.yellow}>{note}</Text>}
476        {restoreButton($, t, STORE_KEY)}
477        {restoreCard($, t, day => void restoreDay($, day))}
478      </Box>
479    )
480  })
481}
482
hooks/fifteen.ts 66 lines
1// 15 パズルの規則。どれも純関数 (乱数と時刻は引数で受け取る)
2import type { Fifteen } from '../types'
3
4export const SIDE = 4
5export const CELLS = SIDE * SIDE
6/** そろった形。0 は空き */
7export const SOLVED: readonly number[] = [...Array.from({ length: CELLS - 1 }, (_, i) => i + 1), 0]
8
9/** 混ぜる (Fisher-Yates)。ほかのゲームも使う */
10export function shuffle<T>(xs: readonly T[], rand: () => number): T[] {
11  const a = [...xs]
12  for (let i = a.length - 1; i > 0; i--) {
13    const j = Math.min(i, Math.floor(rand() * (i + 1)))
14    ;[a[i], a[j]] = [a[j]!, a[i]!]
15  }
16  return a
17}
18
19/** 上下左右のマス */
20export function neighbors(i: number, side = SIDE): number[] {
21  const r = Math.floor(i / side)
22  const c = i % side
23  return [r > 0 ? i - side : -1, r < side - 1 ? i + side : -1, c > 0 ? i - 1 : -1, c < side - 1 ? i + 1 : -1].filter(j => j >= 0)
24}
25
26export const isSolved = (tiles: readonly number[]) => tiles.every((v, i) => v === SOLVED[i])
27
28/**
29 * 解けるか。4 列の盤では「転倒数 + 空きの行 (下から 1, 2, ...)」が奇数なら解ける
30 * (そろった形は 0 + 1 で奇数。1 手動かしても偶奇は変わらない)
31 */
32export function isSolvable(tiles: readonly number[]): boolean {
33  const xs = tiles.filter(v => v !== 0)
34  let inv = 0
35  for (let i = 0; i < xs.length; i++) for (let j = i + 1; j < xs.length; j++) if (xs[i]! > xs[j]!) inv++
36  return (inv + SIDE - Math.floor(tiles.indexOf(0) / SIDE)) % 2 === 1
37}
38
39/** 新しい盤。並べ替えて、解けない並びなら空きでない 2 枚を入れ替える (偶奇が変わって解ける) */
40export function newFifteen(rand: () => number): Fifteen {
41  const tiles = shuffle(SOLVED, rand)
42  const swap = (a: number, b: number) => ([tiles[a], tiles[b]] = [tiles[b]!, tiles[a]!])
43  const nums = tiles.flatMap((v, i) => (v === 0 ? [] : [i]))
44  if (!isSolvable(tiles)) swap(nums[0]!, nums[1]!)
45  // 偶然そろっていたら 1 手ずらす (合法な手なので解けるまま)
46  if (isSolved(tiles)) swap(CELLS - 1, CELLS - 2)
47  return { tiles, moves: 0, startedAt: 0, endedAt: 0, status: 'play' }
48}
49
50/** i のタイルを押す。空きの隣なら空きへ滑らせる。それ以外・そろった後は変えない */
51export function applyFifteen(g: Fifteen, i: number, now: number): Fifteen {
52  if (g.status === 'won' || i < 0 || i >= CELLS) return g
53  const blank = g.tiles.indexOf(0)
54  if (!neighbors(blank).includes(i)) return g
55  const tiles = [...g.tiles]
56  ;[tiles[blank], tiles[i]] = [tiles[i]!, 0]
57  const next = { ...g, tiles, moves: g.moves + 1, startedAt: g.startedAt || now }
58  return isSolved(tiles) ? { ...next, status: 'won', endedAt: now } : next
59}
60
61/** マスの字。どちらの画面も 2 桁にそろえる (空きは「 .」) */
62export const look = (g: Fifteen, i: number) => {
63  const v = g.tiles[i] ?? 0
64  return { text: v === 0 ? ' .' : String(v).padStart(2, ' '), isBlank: v === 0, isHome: v !== 0 && v === SOLVED[i] }
65}
66
hooks/game2048.ts 111 lines
1// 2048 の規則。どれも純関数 (乱数は引数で受け取る)
2import type { Dir, Game2048, Slide } from '../types'
3
4export const SIZE = 4
5export const GOAL = 2048
6
7/** 1 列を先頭の側へ詰める。from/to は列の中の位置。1 手で同じタイルは 1 回しか合わさらない */
8export function slideLine(line: readonly number[]) {
9  const out: number[] = []
10  const moves: { from: number; to: number }[] = []
11  const merged: number[] = []
12  let score = 0
13  let canMerge = false
14  line.forEach((v, from) => {
15    if (v === 0) return
16    const last = out.length - 1
17    if (canMerge && out[last] === v) {
18      out[last] = v * 2
19      score += v * 2
20      merged.push(last)
21      moves.push({ from, to: last })
22      canMerge = false
23    } else {
24      out.push(v)
25      moves.push({ from, to: out.length - 1 })
26      canMerge = true
27    }
28  })
29  while (out.length < line.length) out.push(0)
30  return { line: out, score, moves, merged }
31}
32
33/** 向きごとの列 (盤の位置を、詰める側から並べた物) */
34export function linesOf(dir: Dir): number[][] {
35  const lines: number[][] = []
36  for (let k = 0; k < SIZE; k++) {
37    const idx = Array.from({ length: SIZE }, (_, j) => (dir === 'left' || dir === 'right' ? k * SIZE + j : j * SIZE + k))
38    lines.push(dir === 'right' || dir === 'down' ? idx.reverse() : idx)
39  }
40  return lines
41}
42
43/** 1 手動かす。盤が変わらなければ moved は false (その時は新しいタイルを出さない) */
44export function move(board: readonly number[], dir: Dir) {
45  const next = [...board]
46  const slides: Slide[] = []
47  const merged: number[] = []
48  let score = 0
49  for (const idx of linesOf(dir)) {
50    const r = slideLine(idx.map(i => board[i] ?? 0))
51    r.line.forEach((v, j) => (next[idx[j]!] = v))
52    for (const m of r.moves) slides.push({ from: idx[m.from]!, to: idx[m.to]!, value: board[idx[m.from]!] ?? 0 })
53    for (const j of r.merged) merged.push(idx[j]!)
54    score += r.score
55  }
56  const moved = next.some((v, i) => v !== board[i])
57  return { board: next, score, moved, slides, merged }
58}
59
60/** 空きマスに 1 枚出す。r1 で場所、r2 で値 (9 割 2、1 割 4)。空きが無ければ -1 */
61export function spawn(board: readonly number[], r1: number, r2: number) {
62  const empty = board.flatMap((v, i) => (v === 0 ? [i] : []))
63  if (empty.length === 0) return { board: [...board], at: -1 }
64  const at = empty[Math.min(empty.length - 1, Math.floor(r1 * empty.length))]!
65  const next = [...board]
66  next[at] = r2 < 0.9 ? 2 : 4
67  return { board: next, at }
68}
69
70/** まだ動かせるか (空きがある、または縦か横に同じ数が並ぶ) */
71export function canMove(board: readonly number[]) {
72  for (let i = 0; i < SIZE * SIZE; i++) {
73    const v = board[i]
74    if (v === 0) return true
75    if (i % SIZE < SIZE - 1 && board[i + 1] === v) return true
76    if (i < SIZE * (SIZE - 1) && board[i + SIZE] === v) return true
77  }
78  return false
79}
80
81/** 新しいゲーム: 空の盤に 2 枚 */
82export function newGame(rand: () => number): Game2048 {
83  const a = spawn(Array(SIZE * SIZE).fill(0), rand(), rand())
84  const b = spawn(a.board, rand(), rand())
85  return { board: b.board, score: 0, moves: 0, hasWon: false, isOver: false, slides: [], merged: [], spawned: [a.at, b.at] }
86}
87
88/**
89 * 1 手進める。動かなければ同じ物を返す。isNewWin はこの手で初めて 2048 ができた時だけ true
90 */
91export function play(g: Game2048, dir: Dir, rand: () => number): { game: Game2048; isNewWin: boolean } {
92  if (g.isOver) return { game: g, isNewWin: false }
93  const m = move(g.board, dir)
94  if (!m.moved) return { game: g, isNewWin: false }
95  const s = spawn(m.board, rand(), rand())
96  const isNewWin = !g.hasWon && s.board.some(v => v >= GOAL)
97  return {
98    game: {
99      board: s.board,
100      score: g.score + m.score,
101      moves: g.moves + 1,
102      hasWon: g.hasWon || isNewWin,
103      isOver: !canMove(s.board),
104      slides: m.slides,
105      merged: m.merged,
106      spawned: s.at < 0 ? [] : [s.at],
107    },
108    isNewWin,
109  }
110}
111
hooks/lights.ts 47 lines
1// ライツアウト (5×5) の規則。どれも純関数 (乱数と時刻は引数で受け取る)
2import type { Lights } from '../types'
3import { neighbors } from './fifteen'
4
5export const SIDE = 5
6export const CELLS = SIDE * SIDE
7
8/** i を押す: i と上下左右が切り替わる */
9export function press(on: readonly boolean[], i: number): boolean[] {
10  const out = [...on]
11  for (const j of [i, ...neighbors(i, SIDE)]) out[j] = !out[j]
12  return out
13}
14
15const bits = (rows: string[]) => rows.join('').split('').map(c => c === '1')
16/**
17 * 押しても何も変わらない押し方 (5×5 には 2 つある)。
18 * 消灯から押して作れる盤は、どちらとも重なりが偶数になる (解けるのはその盤だけ)
19 */
20export const QUIET = [bits(['01110', '10101', '11011', '10101', '01110']), bits(['10101', '10101', '00000', '10101', '10101'])]
21
22export const isSolvable = (on: readonly boolean[]) => QUIET.every(q => q.filter((v, i) => v && on[i]).length % 2 === 0)
23
24export const isDark = (on: readonly boolean[]) => on.every(v => !v)
25
26/** 新しい盤。全部消えた所から、半分くらいのマスを押して作る (だから必ず解ける) */
27export function newLights(rand: () => number): Lights {
28  let on: boolean[] = Array(CELLS).fill(false)
29  for (let i = 0; i < CELLS; i++) if (rand() < 0.5) on = press(on, i)
30  if (isDark(on)) on = press(on, 12)
31  return { on, moves: 0, startedAt: 0, endedAt: 0, status: 'play' }
32}
33
34/** i を押す。消し終わった盤・範囲の外は変えない */
35export function applyLights(g: Lights, i: number, now: number): Lights {
36  if (g.status === 'won' || i < 0 || i >= CELLS) return g
37  const on = press(g.on, i)
38  const next = { ...g, on, moves: g.moves + 1, startedAt: g.startedAt || now }
39  return isDark(on) ? { ...next, status: 'won', endedAt: now } : next
40}
41
42/** マスの字。text は terminal 用 (半角)、wide は vscode 用 */
43export const look = (g: Lights, i: number) => (g.on[i] ? { text: '#', wide: '■', isOn: true } : { text: '.', wide: '・', isOn: false })
44
45/** 点いている数 */
46export const litCount = (g: Lights) => g.on.filter(Boolean).length
47
hooks/memory.ts 54 lines
1// 神経衰弱 (4×4、8 組) の規則。どれも純関数 (乱数と時刻は引数で受け取る)
2import type { Memory } from '../types'
3import { shuffle } from './fifteen'
4
5export const CELLS = 16
6/** 絵柄は色の名前 1 字 (漢字なので terminal では 2 桁) */
7export const FACES = ['赤', '青', '緑', '黄', '紫', '橙', '白', '黒'] as const
8
9export function newMemory(rand: () => number): Memory {
10  const cards = shuffle(
11    Array.from({ length: CELLS }, (_, i) => i % FACES.length),
12    rand,
13  )
14  return { cards, done: Array(CELLS).fill(false), up: [], moves: 0, startedAt: 0, endedAt: 0, status: 'play' }
15}
16
17/**
18 * i をめくる。そろった札・表の札・終わった盤は変えない。
19 * 2 枚目で同じ絵柄ならそろう。違えば 2 枚とも表のまま残し、次に押した時に伏せる
20 */
21export function applyMemory(g: Memory, i: number, now: number): Memory {
22  if (g.status === 'won' || i < 0 || i >= CELLS || g.done[i]) return g
23  const base = g.up.length === 2 ? [] : g.up
24  if (base.includes(i)) return g
25  const up = [...base, i]
26  const startedAt = g.startedAt || now
27  if (up.length < 2) return { ...g, up, startedAt }
28  const [a, b] = up as [number, number]
29  const moves = g.moves + 1
30  if (g.cards[a] !== g.cards[b]) return { ...g, up, moves, startedAt }
31  const done = g.done.map((d, k) => d || k === a || k === b)
32  const next = { ...g, up: [], done, moves, startedAt }
33  return done.every(Boolean) ? { ...next, status: 'won', endedAt: now } : next
34}
35
36/** 札の字。伏せた札は terminal で「##」(漢字と同じ 2 桁)、vscode で「■」 */
37export function look(g: Memory, i: number) {
38  const isUp = g.up.includes(i)
39  const isShown = isUp || (g.done[i] ?? false)
40  const face = FACES[g.cards[i] ?? 0] ?? '?'
41  return {
42    text: isShown ? face : '##',
43    wide: isShown ? face : '■',
44    isDone: g.done[i] ?? false,
45    /** 表にしている (そろっていない) */
46    isUp,
47    /** 違う 2 枚を見せている */
48    isMiss: isUp && g.up.length === 2,
49  }
50}
51
52/** そろった組の数 */
53export const pairCount = (g: Memory) => g.done.filter(Boolean).length / 2
54
hooks/mines.ts 113 lines
1// マインスイーパーの規則。どれも純関数 (乱数と時刻は引数で受け取る)
2import type { Mines } from '../types'
3
4export const ROWS = 8
5export const COLS = 8
6export const CELLS = ROWS * COLS
7export const MINE_COUNT = 10
8
9/** まわり 8 マスの位置 */
10export function around(i: number): number[] {
11  const r = Math.floor(i / COLS)
12  const c = i % COLS
13  const out: number[] = []
14  for (let dr = -1; dr <= 1; dr++)
15    for (let dc = -1; dc <= 1; dc++) {
16      const rr = r + dr
17      const cc = c + dc
18      if ((dr !== 0 || dc !== 0) && rr >= 0 && rr < ROWS && cc >= 0 && cc < COLS) out.push(rr * COLS + cc)
19    }
20  return out
21}
22
23export const newMines = (): Mines => ({
24  mines: null,
25  open: Array(CELLS).fill(false),
26  flag: Array(CELLS).fill(false),
27  status: 'ready',
28  boom: -1,
29  startedAt: 0,
30  endedAt: 0,
31})
32
33/** 地雷を置く。初手のマスとそのまわりには置かない (初手で必ず広く開く) */
34export function place(first: number, rand: () => number, count = MINE_COUNT): boolean[] {
35  const safe = new Set([first, ...around(first)])
36  const pool = Array.from({ length: CELLS }, (_, i) => i).filter(i => !safe.has(i))
37  const mines = Array(CELLS).fill(false)
38  // 先頭から count 個だけ混ぜる (Fisher-Yates の途中まで)
39  for (let k = 0; k < Math.min(count, pool.length); k++) {
40    const j = k + Math.min(pool.length - k - 1, Math.floor(rand() * (pool.length - k)))
41    ;[pool[k], pool[j]] = [pool[j]!, pool[k]!]
42    mines[pool[k]!] = true
43  }
44  return mines
45}
46
47/** まわりの地雷の数 */
48export const countAt = (mines: readonly boolean[], i: number) => around(i).filter(j => mines[j]).length
49
50const isEnded = (s: Mines) => s.status === 'won' || s.status === 'lost'
51
52/** 地雷以外が全部開いたか */
53export const isCleared = (mines: readonly boolean[], open: readonly boolean[]) => mines.every((m, i) => m || open[i])
54
55/**
56 * マスを開ける。旗のマス・開いたマス・終わった盤は変えない。
57 * 0 のマスはまわりを連鎖して開ける。初手の前なら先に地雷を置く
58 */
59export function reveal(s: Mines, i: number, now: number, rand: () => number): Mines {
60  if (i < 0 || i >= CELLS || isEnded(s) || s.open[i] || s.flag[i]) return s
61  const mines = s.mines ?? place(i, rand)
62  const startedAt = s.mines ? s.startedAt : now
63  const open = [...s.open]
64  if (mines[i]) {
65    // 負け: 地雷を全部見せる
66    mines.forEach((m, j) => m && (open[j] = true))
67    return { ...s, mines, open, status: 'lost', boom: i, startedAt, endedAt: now }
68  }
69  const queue = [i]
70  open[i] = true
71  while (queue.length > 0) {
72    const j = queue.pop()!
73    if (countAt(mines, j) !== 0) continue
74    for (const k of around(j)) {
75      if (open[k] || s.flag[k] || mines[k]) continue
76      open[k] = true
77      queue.push(k)
78    }
79  }
80  if (isCleared(mines, open)) {
81    // 勝ち: 残りの地雷に旗を立てる
82    return { ...s, mines, open, flag: mines.map((m, j) => m || (s.flag[j] ?? false)), status: 'won', startedAt, endedAt: now }
83  }
84  return { ...s, mines, open, status: 'play', startedAt }
85}
86
87/** 旗を立てる・外す。開いたマスと終わった盤は変えない */
88export function toggleFlag(s: Mines, i: number): Mines {
89  if (i < 0 || i >= CELLS || isEnded(s) || s.open[i]) return s
90  const flag = [...s.flag]
91  flag[i] = !flag[i]
92  return { ...s, flag }
93}
94
95/** 残りの地雷の数 (地雷の数 − 旗の数)。旗が多ければ負になる */
96export const minesLeft = (s: Mines) => MINE_COUNT - s.flag.filter(Boolean).length
97
98/** マスに出す 1 文字。text は terminal 用 (幅をそろえるため半角だけ)、wide は vscode 用 */
99export type CellLook = { text: string; wide: string; isOpen: boolean; isBoom: boolean }
100
101export function look(s: Mines, i: number): CellLook {
102  const isBoom = s.boom === i
103  if (s.open[i] && s.mines?.[i]) return { text: '*', wide: '●', isOpen: true, isBoom }
104  if (s.open[i]) {
105    const n = countAt(s.mines ?? [], i)
106    return n === 0 ? { text: '.', wide: '・', isOpen: true, isBoom } : { text: String(n), wide: String.fromCharCode(0xff10 + n), isOpen: true, isBoom }
107  }
108  // 負けた後、地雷でないのに立てた旗
109  if (s.flag[i] && s.status === 'lost' && !s.mines?.[i]) return { text: 'x', wide: 'X', isOpen: false, isBoom }
110  if (s.flag[i]) return { text: 'F', wide: '▶', isOpen: false, isBoom }
111  return { text: '#', wide: '■', isOpen: false, isBoom }
112}
113
hooks/stats.ts 61 lines
1// $.store に残す記録の形と合わせ方。純関数
2import type { GameStats, PuzzleRecord, RecordKind } from '../types'
3
4export const EMPTY_STATS: GameStats = { best2048: 0, wins2048: 0, winsMines: 0, fastestMines: 0 }
5export const RECORD_KINDS: readonly RecordKind[] = ['fifteen', 'lights', 'sudoku4', 'sudoku9', 'memory']
6export const EMPTY_RECORD: PuzzleRecord = { wins: 0, bestMoves: 0, fastest: 0 }
7
8const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0)
9const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
10
11/** store の値を確かめる。オブジェクトでなければ undefined (読めなかった扱い)。数でない項目は 0 */
12export function statsFromSaved(raw: unknown): GameStats | undefined {
13  if (raw === undefined) return EMPTY_STATS
14  if (!isObj(raw)) return undefined
15  const r = raw
16  const out: GameStats = { best2048: num(r.best2048), wins2048: num(r.wins2048), winsMines: num(r.winsMines), fastestMines: num(r.fastestMines) }
17  // 押して解くゲームの記録は、形の合う物だけ拾う (0.1.x の記録には欄ごと無い)
18  const p = isObj(r.puzzles) ? r.puzzles : {}
19  const puzzles: Partial<Record<RecordKind, PuzzleRecord>> = {}
20  for (const k of RECORD_KINDS) {
21    const v = p[k]
22    if (isObj(v)) puzzles[k] = { wins: num(v.wins), bestMoves: num(v.bestMoves), fastest: num(v.fastest) }
23  }
24  return Object.keys(puzzles).length > 0 ? { ...out, puzzles } : out
25}
26
27/**
28 * 書く値に、読み直した生の値のうち自分の知らない欄を残す (入れ子の物の中も)。
29 * 新しい版のセッションが足した欄を、古い版のまま動いているセッションが消さないように
30 */
31export function keepUnknown<T>(raw: unknown, mine: T): T {
32  if (!isObj(raw) || !isObj(mine)) return mine
33  const out: Record<string, unknown> = { ...raw }
34  for (const [k, v] of Object.entries(mine)) out[k] = keepUnknown(raw[k], v)
35  return out as T
36}
37
38/** 押して解くゲームを 1 回解いた */
39export type PuzzleWin = { kind: RecordKind; moves: number; ms: number }
40
41/** このセッションで増えた分。勝ちは足し算、最高点は大きい方、最速・最短は小さい方 */
42export type StatsDelta = { best2048?: number; wins2048?: number; winsMines?: number; fastestMines?: number; puzzle?: PuzzleWin }
43
44/** 0 (まだ) を除いた小さい方 */
45const least = (a: number, b: number) => (a > 0 && b > 0 ? Math.min(a, b) : Math.max(a, b))
46
47export const recordOf = (s: GameStats, k: RecordKind): PuzzleRecord => s.puzzles?.[k] ?? EMPTY_RECORD
48
49export function addStats(s: GameStats, d: StatsDelta): GameStats {
50  const out: GameStats = {
51    best2048: Math.max(s.best2048, d.best2048 ?? 0),
52    wins2048: s.wins2048 + (d.wins2048 ?? 0),
53    winsMines: s.winsMines + (d.winsMines ?? 0),
54    fastestMines: least(s.fastestMines, d.fastestMines ?? 0),
55  }
56  const w = d.puzzle
57  if (!w) return s.puzzles ? { ...out, puzzles: s.puzzles } : out
58  const r = recordOf(s, w.kind)
59  return { ...out, puzzles: { ...s.puzzles, [w.kind]: { wins: r.wins + 1, bestMoves: least(r.bestMoves, w.moves), fastest: least(r.fastest, w.ms) } } }
60}
61
hooks/sudoku.ts 143 lines
1// 数独 (4×4・9×9) の規則。どれも純関数 (乱数と時刻は引数で受け取る)
2import type { Sudoku, SudokuSize } from '../types'
3import { shuffle } from './fifteen'
4
5/** 箱の一辺 (4 なら 2、9 なら 3) */
6export const boxOf = (n: SudokuSize) => (n === 4 ? 2 : 3)
7/** 残す数字の目安。これより少なくすると答えが 1 つに決まらないことが多い */
8const GIVENS: Record<SudokuSize, number> = { 4: 6, 9: 30 }
9
10const range = (n: number) => Array.from({ length: n }, (_, i) => i)
11
12/** i と同じ行・列・箱のマス (i 自身は除く)。中では大きさごとに 1 回だけ作った表を引く (返すのは写し) */
13export const peers = (n: SudokuSize, i: number): number[] => [...(peerTable(n)[i] ?? [])]
14
15const tables = new Map<SudokuSize, readonly (readonly number[])[]>()
16function peerTable(n: SudokuSize) {
17  let t = tables.get(n)
18  if (!t) tables.set(n, (t = range(n * n).map(i => makePeers(n, i))))
19  return t
20}
21
22function makePeers(n: SudokuSize, i: number): number[] {
23  const b = boxOf(n)
24  const r = Math.floor(i / n)
25  const c = i % n
26  const br = r - (r % b)
27  const bc = c - (c % b)
28  const out = new Set<number>()
29  for (let k = 0; k < n; k++) {
30    out.add(r * n + k)
31    out.add(k * n + c)
32    out.add((br + Math.floor(k / b)) * n + bc + (k % b))
33  }
34  out.delete(i)
35  return [...out]
36}
37
38/** 完成した盤。決まった形から、数字・帯の中の行・帯・束の中の列・束を混ぜる */
39export function solutionFor(n: SudokuSize, rand: () => number): number[] {
40  const b = boxOf(n)
41  const digits = shuffle(range(n).map(d => d + 1), rand)
42  const lines = () => shuffle(range(b), rand).flatMap(band => shuffle(range(b), rand).map(k => band * b + k))
43  const rows = lines()
44  const cols = lines()
45  return range(n * n).map(i => {
46    const r = rows[Math.floor(i / n)]!
47    const c = cols[i % n]!
48    return digits[(b * (r % b) + Math.floor(r / b) + c) % n]!
49  })
50}
51
52/** 答えの数 (limit で打ち切る)。0 は空き。候補の一番少ないマスから埋める */
53export function countSolutions(n: SudokuSize, cells: readonly number[], limit = 2): number {
54  const grid = [...cells]
55  const peerOf = peerTable(n)
56  // 候補はビットで持つ (d の印は 1 << d)。試す順は小さい数字から (前と同じ盤になる)
57  const all = ((1 << (n + 1)) - 1) & ~1
58  let found = 0
59  const walk = (): void => {
60    let best = -1
61    let bestCands = 0
62    let bestCount = n + 1
63    for (let i = 0; i < grid.length; i++) {
64      if (grid[i] !== 0) continue
65      let used = 0
66      for (const j of peerOf[i]!) used |= 1 << grid[j]!
67      const cands = all & ~used
68      if (cands === 0) return
69      let count = 0
70      for (let m = cands; m !== 0; m &= m - 1) count++
71      if (count < bestCount) {
72        best = i
73        bestCands = cands
74        bestCount = count
75      }
76    }
77    if (best < 0) {
78      found++
79      return
80    }
81    for (let d = 1; d <= n; d++) {
82      if ((bestCands & (1 << d)) === 0) continue
83      grid[best] = d
84      walk()
85      if (found >= limit) break
86    }
87    grid[best] = 0
88  }
89  walk()
90  return found
91}
92
93/** 新しい盤。完成した盤から、答えが 1 つのままのマスだけを消していく */
94export function newSudoku(n: SudokuSize, rand: () => number): Sudoku {
95  const solution = solutionFor(n, rand)
96  const cells = [...solution]
97  let left = n * n
98  for (const i of shuffle(range(n * n), rand)) {
99    if (left <= GIVENS[n]) break
100    cells[i] = 0
101    if (countSolutions(n, cells) === 1) left--
102    else cells[i] = solution[i]!
103  }
104  return { size: n, givens: cells.map(v => v !== 0), cells, solution, selected: -1, moves: 0, startedAt: 0, endedAt: 0, status: 'play' }
105}
106
107/** 同じ行・列・箱に同じ数字がある */
108export const clashes = (g: Sudoku, i: number) => (g.cells[i] ?? 0) !== 0 && (peerTable(g.size)[i] ?? []).some(j => g.cells[j] === g.cells[i])
109
110/** 全部埋まっていて、どの行・列・箱にも重なりが無い */
111export const isComplete = (g: Pick<Sudoku, 'size' | 'cells'>) => g.cells.every((v, i) => v !== 0 && !(peerTable(g.size)[i] ?? []).some(j => g.cells[j] === v))
112
113/** マスを選ぶ。もう一度押すと外す。初めからある数字と、解き終わった盤は選べない */
114export function selectCell(g: Sudoku, i: number): Sudoku {
115  if (g.status === 'won' || i < 0 || i >= g.cells.length || g.givens[i]) return g
116  return { ...g, selected: g.selected === i ? -1 : i }
117}
118
119/** 選んだマスに数字を入れる (0 で消す)。選んでいない・同じ数字・解き終わった盤は変えない */
120export function applySudoku(g: Sudoku, digit: number, now: number): Sudoku {
121  const i = g.selected
122  if (g.status === 'won' || i < 0 || g.givens[i] || digit < 0 || digit > g.size || g.cells[i] === digit) return g
123  const cells = [...g.cells]
124  cells[i] = digit
125  const next = { ...g, cells, moves: g.moves + 1, startedAt: g.startedAt || now }
126  return isComplete(next) ? { ...next, status: 'won', selected: -1, endedAt: now } : next
127}
128
129/** マスの字。text は terminal 用 (半角)、wide は vscode 用 (全角) */
130export function look(g: Sudoku, i: number) {
131  const v = g.cells[i] ?? 0
132  return {
133    text: v === 0 ? '.' : String(v),
134    wide: v === 0 ? '・' : String.fromCharCode(0xff10 + v),
135    isGiven: g.givens[i] ?? false,
136    isSelected: g.selected === i,
137    isClash: clashes(g, i),
138  }
139}
140
141/** 空いているマスの数 */
142export const emptyCount = (g: Sudoku) => g.cells.filter(v => v === 0).length
143
hooks/svg.ts 214 lines
1// SVG を文字列で組む関数と terminal 用の行。どれも純関数。描画面は <img> なので SMIL だけで動かす
2import type { Fifteen, Game2048, GameStats, Lights, Memory, Mines, Puzzle, PuzzleRecord, Sudoku } from '../types'
3import { SOLVED } from './fifteen'
4import { litCount } from './lights'
5import { pairCount } from './memory'
6import { MINE_COUNT, minesLeft } from './mines'
7import { recordOf } from './stats'
8import { emptyCount } from './sudoku'
9import { C, frame, header, HEADER_H, isLight, panel } from './style'
10
11// 色は共通の C (/config の theme で明るい色に替わる)
12export { C }
13
14const FONT = 'font-family="sans-serif"'
15
16/** 制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、SVG も壊れる */
17export const clean = (s: string) => s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
18
19export const esc = (s: string) =>
20  clean(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
21
22const text = (x: number, y: number, s: string, size: number, fill: string, extra = '') =>
23  `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" ${FONT} ${extra}>${esc(s)}</text>`
24
25/** 秒 (小数 1 桁) */
26export const secs = (ms: number) => `${(Math.max(0, ms) / 1000).toFixed(1)}秒`
27
28// ---- 2048 ----
29
30/** タイルの地と文字の色 */
31export function tileColor(v: number): { fill: string; ink: string } {
32  const table: Record<number, string> = {
33    2: isLight() ? '#eaeef2' : '#21262d',
34    4: C.line,
35    8: C.orange,
36    16: C.yellow,
37    32: C.red,
38    64: C.purple,
39    128: C.blue,
40    256: C.green,
41    512: '#1f6feb',
42    1024: '#8957e5',
43  }
44  if (v >= 2048) return { fill: '#e3b341', ink: C.bg }
45  const fill = table[v] ?? C.face
46  return { fill, ink: v <= 4 ? C.text : C.bg }
47}
48
49const CELL = 72
50const GAP = 8
51const BX = 16
52const BY = 72
53const pos = (i: number) => ({ x: BX + GAP + (i % 4) * (CELL + GAP), y: BY + GAP + Math.floor(i / 4) * (CELL + GAP) })
54const MOVE = '0.12s'
55
56/** タイル 1 枚 (左上を 0,0 に置いた形) */
57function tile(v: number) {
58  const { fill, ink } = tileColor(v)
59  const digits = String(v).length
60  const size = digits <= 2 ? 32 : digits === 3 ? 28 : digits === 4 ? 24 : 18
61  const glow = v >= 2048 ? ` stroke="#f2cc60" stroke-width="3"` : ''
62  return (
63    `<rect width="${CELL}" height="${CELL}" rx="6" fill="${fill}"${glow}/>` +
64    text(CELL / 2, CELL / 2 + size * 0.36, String(v), size, ink, 'text-anchor="middle" font-weight="bold"')
65  )
66}
67
68/** 遅れて大きくなって出るタイル (合わさった物・新しい物) */
69function pop(i: number, v: number, from: string) {
70  const { x, y } = pos(i)
71  const h = CELL / 2
72  return (
73    `<g transform="translate(${x + h} ${y + h})" opacity="0"><set attributeName="opacity" to="1" begin="${MOVE}" fill="freeze"/>` +
74    `<g><animateTransform attributeName="transform" type="scale" values="${from};1" begin="${MOVE}" dur="0.15s" fill="freeze"/>` +
75    `<g transform="translate(${-h} ${-h})">${tile(v)}</g></g></g>`
76  )
77}
78
79export function board2048Svg(g: Game2048, stats: GameStats): string {
80  const best = Math.max(stats.best2048, g.score)
81  let out = ''
82  // 見出し: 題・スコア・最高点
83  out += text(16, 44, '2048', 30, C.text, 'font-weight="bold"')
84  const box = (x: number, label: string, v: number) =>
85    `<rect x="${x}" y="10" width="100" height="50" rx="6" fill="${C.face}" stroke="${C.line}"/>` +
86    text(x + 50, 29, label, 12, C.dim, 'text-anchor="middle"') +
87    text(x + 50, 52, String(v), 18, C.text, 'text-anchor="middle" font-weight="bold"')
88  out += box(140, 'スコア', g.score) + box(248, '最高点', best)
89  // 盤の地と空きマス
90  out += `<rect x="${BX}" y="${BY}" width="${4 * CELL + 5 * GAP}" height="${4 * CELL + 5 * GAP}" rx="8" fill="${C.face}" stroke="${C.line}"/>`
91  for (let i = 0; i < 16; i++) {
92    const { x, y } = pos(i)
93    out += `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}" rx="6" fill="${C.bg}"/>`
94  }
95  // 動いたタイル (動く前の値を、行き先へ滑らせる)。直前の手が無ければ盤のまま
96  const fresh = new Set([...g.merged, ...g.spawned])
97  if (g.slides.length > 0) {
98    for (const s of g.slides) {
99      const a = pos(s.from)
100      const b = pos(s.to)
101      const slide =
102        s.from === s.to ? '' : `<animateTransform attributeName="transform" type="translate" from="${a.x - b.x} ${a.y - b.y}" to="0 0" dur="${MOVE}" fill="freeze"/>`
103      out += `<g transform="translate(${b.x} ${b.y})"><g>${slide}${tile(s.value)}</g></g>`
104    }
105  } else {
106    g.board.forEach((v, i) => {
107      if (v === 0 || fresh.has(i)) return
108      const { x, y } = pos(i)
109      out += `<g transform="translate(${x} ${y})">${tile(v)}</g>`
110    })
111  }
112  for (const i of g.merged) out += pop(i, g.board[i] ?? 0, '1.2')
113  for (const i of g.spawned) out += pop(i, g.board[i] ?? 0, '0.1')
114
115  // 下の一言・終わりの幕
116  const by = BY + 4 * CELL + 5 * GAP
117  if (g.isOver) {
118    out +=
119      `<rect x="${BX}" y="${BY}" width="${4 * CELL + 5 * GAP}" height="${4 * CELL + 5 * GAP}" rx="8" fill="${C.bg}" opacity="0">` +
120      `<animate attributeName="opacity" from="0" to="0.8" begin="0.3s" dur="0.4s" fill="freeze"/></rect>` +
121      text(180, BY + 150, 'ゲームオーバー', 28, C.red, 'text-anchor="middle" font-weight="bold"') +
122      text(180, BY + 182, `スコア ${g.score}`, 16, C.text, 'text-anchor="middle"')
123  }
124  const foot = g.hasWon ? '2048 達成。このまま続けられます' : `${g.moves} 手 / 同じ数を合わせて 2048 へ`
125  out += text(180, by + 22, foot, 13, g.hasWon ? '#e3b341' : C.dim, 'text-anchor="middle"')
126  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 ${by + 32}"><rect width="360" height="${by + 32}" fill="${C.bg}"/>${out}</svg>`
127}
128
129/** terminal 用の盤の行。1 マス 5 桁で 20 桁 */
130export function board2048Rows(g: Game2048): { cells: { text: string; color: string }[] }[] {
131  return [0, 1, 2, 3].map(r => ({
132    cells: [0, 1, 2, 3].map(c => {
133      const v = g.board[r * 4 + c] ?? 0
134      return { text: (v === 0 ? '.' : String(v)).padStart(4) + ' ', color: v === 0 ? C.dim : v <= 4 ? C.text : tileColor(v).fill }
135    }),
136  }))
137}
138
139export function lines2048(g: Game2048, stats: GameStats): string[] {
140  const head = `スコア ${g.score} / 最高 ${Math.max(stats.best2048, g.score)}`
141  const foot = g.isOver ? 'ゲームオーバー' : g.hasWon ? '2048 達成。このまま続けられます' : `${g.moves} 手`
142  return [head, foot]
143}
144
145// ---- マインスイーパー ----
146
147/** 盤の上の 1 行 (残りの地雷・状態・記録) */
148export function minesHead(s: Mines, stats: GameStats): { left: string; state: string; record: string; color: string } {
149  const left = `地雷 ${minesLeft(s)} / ${MINE_COUNT}`
150  const record = `勝ち ${stats.winsMines} 回` + (stats.fastestMines > 0 ? ` / 最速 ${secs(stats.fastestMines)}` : '')
151  if (s.status === 'won') return { left, state: `クリア ${secs(s.endedAt - s.startedAt)}`, record, color: C.green }
152  if (s.status === 'lost') return { left, state: '地雷を踏みました', record, color: C.red }
153  if (s.status === 'play') return { left, state: 'プレイ中', record, color: C.blue }
154  return { left, state: '初手は安全です', record, color: C.dim }
155}
156
157export function minesSvg(s: Mines, stats: GameStats): string {
158  const h = minesHead(s, stats)
159  let out = `<rect x="4" y="4" width="352" height="64" rx="8" fill="${C.face}" stroke="${h.color}" stroke-width="2"/>`
160  // 勝ち負けの時だけ枠を光らせる
161  if (s.status === 'won' || s.status === 'lost')
162    out += `<rect x="4" y="4" width="352" height="64" rx="8" fill="${h.color}" opacity="0"><animate attributeName="opacity" values="0;0.35;0" dur="0.9s" repeatCount="2"/></rect>`
163  out += text(18, 30, h.left, 14, C.text)
164  out += text(342, 30, h.state, 14, h.color, 'text-anchor="end" font-weight="bold"')
165  out += text(18, 54, h.record, 12, C.dim)
166  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 72"><rect width="360" height="72" fill="${C.bg}"/>${out}</svg>`
167}
168
169// ---- 押して解くゲーム (15 パズル・ライツアウト・数独・神経衰弱) ----
170
171/** 盤の上の見出しの中身 (terminal でも同じ言葉を使う) */
172export type Head = { left: string; state: string; record: string; color: string }
173
174export function recordLine(r: PuzzleRecord): string {
175  return `勝ち ${r.wins} 回` + (r.bestMoves > 0 ? ` / 最短 ${r.bestMoves} 手` : '') + (r.fastest > 0 ? ` / 最速 ${secs(r.fastest)}` : '')
176}
177
178/** 解いた・まだ動かしていない (遊び方の一言)・遊んでいる の 3 つ */
179export function puzzleHead(g: Puzzle, r: PuzzleRecord, left: string, hint: string): Head {
180  const record = recordLine(r)
181  if (g.status === 'won') return { left, state: `クリア ${secs(g.endedAt - g.startedAt)} / ${g.moves} 手`, record, color: C.green }
182  if (g.moves === 0) return { left, state: hint, record, color: C.dim }
183  return { left, state: `${g.moves} 手`, record, color: C.blue }
184}
185
186export const fifteenHead = (g: Fifteen, s: GameStats) =>
187  puzzleHead(g, recordOf(s, 'fifteen'), `正しい位置 ${g.tiles.filter((v, i) => v !== 0 && v === SOLVED[i]).length} / 15`, '空きの隣を押して 1 から順に並べる')
188
189export const lightsHead = (g: Lights, s: GameStats) => puzzleHead(g, recordOf(s, 'lights'), `点灯 ${litCount(g)} 個`, '押すと十字が切り替わる。全部消す')
190
191export function sudokuHead(g: Sudoku, s: GameStats): Head {
192  const h = puzzleHead(g, recordOf(s, g.size === 4 ? 'sudoku4' : 'sudoku9'), `空き ${emptyCount(g)} マス`, 'マスを選び、下の数字で入れる')
193  // 埋まったのに解けていない = どこかで重なっている
194  return g.status === 'play' && emptyCount(g) === 0 ? { ...h, state: '同じ数字が重なっています', color: C.red } : h
195}
196
197export const memoryHead = (g: Memory, s: GameStats) => puzzleHead(g, recordOf(s, 'memory'), `そろった ${pairCount(g)} / 8 組`, '同じ色の名前を 2 枚そろえる')
198
199/** 見出し帯 + 状態と記録の 2 行。解いた時は枠が光る */
200export function headSvg(title: string, h: Head, isWon: boolean): string {
201  const y = HEADER_H + 6
202  let body = header(title, h.left, h.color === C.dim ? C.blue : h.color)
203  body += panel(8, y, 364, 50)
204  if (isWon) body += `<rect x="8" y="${y}" width="364" height="50" rx="8" fill="${h.color}" opacity="0"><animate attributeName="opacity" values="0;0.35;0" dur="0.9s" repeatCount="2"/></rect>`
205  body += text(20, y + 21, h.state, 14, h.color, h.color === C.dim ? '' : 'font-weight="bold"')
206  body += text(20, y + 40, h.record, 12, C.dim)
207  return frame(y + 58, body)
208}
209
210export const fifteenSvg = (g: Fifteen, s: GameStats) => headSvg('15 パズル', fifteenHead(g, s), g.status === 'won')
211export const lightsSvg = (g: Lights, s: GameStats) => headSvg('ライツアウト', lightsHead(g, s), g.status === 'won')
212export const sudokuSvg = (g: Sudoku, s: GameStats) => headSvg(`数独 ${g.size}x${g.size}`, sudokuHead(g, s), g.status === 'won')
213export const memorySvg = (g: Memory, s: GameStats) => headSvg('神経衰弱', memoryHead(g, s), g.status === 'won')
214
hooks/backup.tsx 102 lines
1// 記録の控え (設定 backupDir) と、ペインの「控えから戻す」欄 (docs/mod-guide.md の 9 節)。
2// turn-gacha・daily-quest・wait-games が同じ物を持つ。戻す欄の開け閉めは $.state に置かず、
3// モジュールの値で持って描き直しを頼む (state の key を増やさない)
4import type { ElementConstructor, BoxProps, ButtonProps, EngineInterface, PluginOptions, TextProps } from 'claude-code'
5
6import { C, backupDaily, jstDay, listBackups } from './style'
7
8type $ = EngineInterface
9
10const isAbsolute = (p: string) => /^([a-zA-Z]:[\\/]|[\\/])/.test(p)
11
12/** 記録の控えのフォルダ (設定 backupDir)。絶対パスだけ。空なら控えない */
13export let backupDir = ''
14export const setBackupDir = (options: PluginOptions) => {
15  const dir = String(options.backupDir ?? '').trim()
16  backupDir = isAbsolute(dir) ? dir : ''
17}
18
19/** key ごとに、今日もう控えを確かめた日 (1 日 1 回だけファイルを読みに行く) */
20const backedDay = new Map<string, string>()
21export const isBackedToday = (key: string, now: number) => !backupDir || backedDay.get(key) === jstDay(now)
22
23/** その日はじめての時だけ、store の生の値 (知らない欄ごと) を控える */
24export async function backup($: $, key: string, raw: unknown, now: number) {
25  if (isBackedToday(key, now) || raw === undefined) return
26  backedDay.set(key, jstDay(now))
27  // 書けない時も、その日はもう試さない (ターンのたびに読み書きしない)
28  await backupDaily($, backupDir, key, raw, now).catch(() => {})
29}
30
31/** day の key の控えの値。無い・読めない時は undefined */
32export async function backupOf($: $, key: string, day: string) {
33  return (await listBackups($, backupDir, key).catch(() => [])).find(b => b.day === day)?.value
34}
35
36// ---- 戻す欄 ----
37
38/** null は閉じている。days は控えのある日 (新しい順)、pick は選んだ日 ('' はまだ) */
39let view: { days: string[]; pick: string } | null = null
40export const isRestoring = () => view !== null
41
42function show($: $, v: typeof view) {
43  view = v
44  $.ui.invalidate('ui.render')
45}
46
47/** セッションの始まりに閉じる (リロードの前の欄を残さない) */
48export const resetRestore = () => {
49  view = null
50  backedDay.clear()
51}
52
53export function closeRestore($: $) {
54  show($, null)
55}
56
57/** 控えのある日を並べて開く。key は日を並べる元の記録 */
58export async function openRestore($: $, key: string) {
59  const days = (await listBackups($, backupDir, key).catch(() => [])).map(b => b.day)
60  show($, { days, pick: '' })
61}
62
63type Parts = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; Button: ElementConstructor<ButtonProps> }
64
65/** 「控えから戻す」のボタン (脇の操作)。開いている時は「戻すのをやめる」 */
66export function restoreButton($: $, { Button }: Parts, key: string) {
67  return <Button key="restore" dimColor label={view ? '戻すのをやめる' : '控えから戻す'} onPress={() => void (view ? closeRestore($) : openRestore($, key))} />
68}
69
70/** 戻す欄: 日を選ぶ → 確かめる → 戻す。onRestore は選んだ日を受け取って書く */
71export function restoreCard($: $, { Box, Text, Button }: Parts, onRestore: (day: string) => void) {
72  const v = view
73  if (!v) return null
74  return (
75    <Box key="restore-card" flexDirection="column" borderStyle="round" borderColor={C.yellow} paddingX={1} paddingY={1} gap={1}>
76      <Text bold color={C.yellow}>
77        控えから戻す
78      </Text>
79      {!backupDir ? (
80        <Text dimColor>設定の backupDir を入れると控えが取れます</Text>
81      ) : v.days.length === 0 ? (
82        <Text dimColor>まだ控えがありません (1 日 1 回取ります)</Text>
83      ) : (
84        <Box flexDirection="row" flexWrap="wrap" gap={1}>
85          {v.days.map(day => (
86            <Button key={`day-${day}`} dimColor={v.pick !== day} label={day} onPress={() => show($, view ? { ...view, pick: day } : null)} />
87          ))}
88        </Box>
89      )}
90      {v.pick ? (
91        <Box flexDirection="column" gap={1}>
92          <Text>{`${v.pick}: この日の記録に戻しますか。今の記録は上書きされます`}</Text>
93          <Box flexDirection="row" gap={1}>
94            <Button key="restore-ok" variant="primary" label="戻す" onPress={() => onRestore(v.pick)} />
95            <Button key="restore-no" dimColor label="やめる" onPress={() => show($, view ? { ...view, pick: '' } : null)} />
96          </Box>
97        </Box>
98      ) : null}
99    </Box>
100  )
101}
102
hooks/style.ts 315 lines
1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7  bg: string
8  face: string
9  line: string
10  text: string
11  dim: string
12  weak: string
13  blue: string
14  green: string
15  yellow: string
16  red: string
17  purple: string
18  orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23  bg: '#0d1117',
24  face: '#161b22',
25  line: '#30363d',
26  text: '#c9d1d9',
27  dim: '#8b949e',
28  weak: '#8b949e',
29  blue: '#58a6ff',
30  green: '#3fb950',
31  yellow: '#d29922',
32  red: '#f85149',
33  purple: '#bc8cff',
34  orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39  bg: '#ffffff',
40  face: '#f6f8fa',
41  line: '#d0d7de',
42  text: '#1f2328',
43  dim: '#656d76',
44  weak: '#656d76',
45  blue: '#0969da',
46  green: '#1a7f37',
47  yellow: '#9a6700',
48  red: '#cf222e',
49  purple: '#8250df',
50  orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65  isLightNow = light
66  Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75  const rows = await $.config.list().catch(() => [])
76  setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81  on('config.set', { key: 'theme' }, async ($, e, next) => {
82    const r = await next(e)
83    if (r.deny === undefined) {
84      setTheme(isLightTheme(r.value))
85      $.ui.invalidate('ui.render')
86    }
87    return r
88  })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98  clean(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109  const h = jstDate(now).getUTCHours()
110  return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115  const m = jstDate(now).getUTCMonth() + 1
116  return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137  if (!dir.trim() || value === undefined) return false
138  const day = jstDay(now)
139  const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140  // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141  const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142  if (old === day) return false
143  await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144  return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149  try {
150    const b = JSON.parse(text) as Partial<Backup> | null
151    return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152  } catch {
153    return undefined
154  }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159  if (!dir.trim()) return []
160  const all = await Promise.all(
161    [0, 1, 2, 3, 4, 5, 6].map(wd =>
162      $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163    ),
164  )
165  return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177  let h = 0x811c9dc5
178  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179  return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187  const root = $.plugin.root
188  let manifest: string | undefined
189  for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190    manifest = await $.fs.read(p).then(t => t, () => undefined)
191    if (manifest !== undefined) break
192  }
193  if (manifest === undefined) return undefined
194  const names = await $.fs.list(`${root}/hooks`).then(
195    es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196    () => [] as string[],
197  )
198  const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199  return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207  const root = normPath($.plugin.root)
208  const at = root.lastIndexOf('/plugins/cache/')
209  if (at < 0) return false
210  const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211  try {
212    const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213    const mine = Object.entries(plugins)
214      .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215      .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216      .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217    return mine.length > 0 && !mine.includes(root)
218  } catch {
219    return false
220  }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225  loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234  if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235  staleAt = now
236  if (await replacedInCache($)) return (isStaleNow = true)
237  if (loadedPrint === undefined) return false
238  const p = await diskPrint($)
239  isStaleNow = p !== undefined && p !== loadedPrint
240  return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248  if (!(await isStale($, now))) return true
249  if (!toldStale) {
250    toldStale = true
251    $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252  }
253  return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264  `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268  `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275  const pad = 14
276  return [
277    `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278    `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279    `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280    sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281    growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282  ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287  `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288  `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292  `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293  `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294  `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298  `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305  const STEPS = 8
306  const d = 0.08
307  const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308  if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309  const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310    const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311    return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312  })
313  return frames.join('') + last
314}
315
types/index.d.ts 120 lines
1export type GameKind = '2048' | 'mines' | 'fifteen' | 'lights' | 'sudoku' | 'memory'
2
3export type Dir = 'up' | 'down' | 'left' | 'right'
4
5/** 1 枚のタイルの動き (盤の位置 0〜15)。アニメにだけ使う */
6export type Slide = { from: number; to: number; value: number }
7
8/** 2048 の盤。board は左上から横に 16 マス、0 は空き */
9export type Game2048 = {
10  board: number[]
11  score: number
12  /** 動かした手の数 */
13  moves: number
14  /** この盤で 2048 を作った (勝ちは 1 回だけ数える) */
15  hasWon: boolean
16  /** もう動かせない */
17  isOver: boolean
18  /** 直前の手の動き。新しいゲームの直後は空 */
19  slides: Slide[]
20  /** 直前の手で合わさってできたタイルの位置 */
21  merged: number[]
22  /** 直前の手で出た新しいタイルの位置 (出ていなければ空) */
23  spawned: number[]
24}
25
26export type MinesStatus = 'ready' | 'play' | 'won' | 'lost'
27
28/** マインスイーパーの盤。8×8 を左上から横に 64 マス */
29export type Mines = {
30  /** 地雷の位置。初手までは null (初手のまわりを避けて置く) */
31  mines: boolean[] | null
32  open: boolean[]
33  flag: boolean[]
34  status: MinesStatus
35  /** 踏んだ地雷の位置。踏んでいなければ -1 */
36  boom: number
37  /** 初手の時刻と終わった時刻 (ms)。0 はまだ */
38  startedAt: number
39  endedAt: number
40}
41
42/** 押して解くゲーム (15 パズル・ライツアウト・数独・神経衰弱) に共通の欄 */
43export type Puzzle = {
44  /** 手の数 */
45  moves: number
46  /** 初手の時刻と解いた時刻 (ms)。0 はまだ */
47  startedAt: number
48  endedAt: number
49  status: 'play' | 'won'
50}
51
52/** 15 パズル。tiles は 4×4 を左上から横に 16 マス、0 は空き */
53export type Fifteen = Puzzle & { tiles: number[] }
54
55/** ライツアウト。5×5 の点いているマス */
56export type Lights = Puzzle & { on: boolean[] }
57
58export type SudokuSize = 4 | 9
59
60/** 数独。cells は size×size、0 は空き */
61export type Sudoku = Puzzle & {
62  size: SudokuSize
63  /** 初めからある数字 (書き換えられない) */
64  givens: boolean[]
65  cells: number[]
66  solution: number[]
67  /** 選んでいるマス。-1 は無し */
68  selected: number
69}
70
71/** 神経衰弱。cards は 4×4 の絵柄の番号 (同じ番号が 2 枚ずつ) */
72export type Memory = Puzzle & {
73  cards: number[]
74  /** そろった札 */
75  done: boolean[]
76  /** 表にしている、まだそろっていない札 (0〜2 枚) */
77  up: number[]
78}
79
80/** 記録を分ける単位 (数独は大きさ別) */
81export type RecordKind = 'fifteen' | 'lights' | 'sudoku4' | 'sudoku9' | 'memory'
82
83/** 1 つのゲームの記録。最短・最速の 0 はまだ */
84export type PuzzleRecord = { wins: number; bestMoves: number; fastest: number }
85
86/** $.store に残す記録 */
87export type GameStats = {
88  best2048: number
89  /** 2048 を作った回数 */
90  wins2048: number
91  winsMines: number
92  /** マインスイーパーの一番速い勝ち (ms)。0 はまだ */
93  fastestMines: number
94  /** 押して解くゲームの記録。遊んだ物だけ (0.1.x の記録には無い) */
95  puzzles?: Partial<Record<RecordKind, PuzzleRecord>>
96}
97
98declare module 'claude-code' {
99  interface PluginState {
100    'wait-games': {
101      game: GameKind
102      g2048: Game2048
103      mines: Mines
104      /** 押したマスに旗を立てる */
105      flagMode: boolean
106      fifteen: Fifteen
107      lights: Lights
108      sudoku: Sudoku
109      memory: Memory
110      stats: GameStats
111      /** このターンの間にゲームを触った (ターンが終わった知らせを出すか) */
112      isPlaying: boolean
113      /** 「ターンが終わりました」の帯。空なら出さない */
114      turnNote: string
115      /** 記録の読み書きの知らせ。空なら出さない */
116      note: string
117    }
118  }
119}
120