Claude の作業に反応して魚が泳ぐ水槽のペイン (/aquarium)。魚はターンで育ち、節目で珍しい種類が来る(10 種)。水の色と飾りは時刻と季節で変わる。turn-gacha で持っているカードに応じて手に入るかざり(水草・城・沈没船など 8 種)を底の 5…

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。
Anthropic とは関係のない、個人が作った非公式の 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 | /jikkyo | Claude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西 |
| 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 | /canvas | Claude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる |
| rule-switch | /rules | 「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる |
| wait-games | /games | 長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる |
| spec-board | /specs | SpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ) |
| 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 | /trophies | dev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も |
| mod-check | /mod-check | mod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする |
| compact-cue | 入力欄の上に自動で出る・/cue | コンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない |
| ship-guard | 常時・/guard | Claude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す |
| rule-watch | 入力欄の上に自動で出る・/rulewatch | Claude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・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 | 常時・/runguard | Claude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る |
| week-card | /week | 1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 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>
好きな 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 して、そのフォルダから読ませることもできます。
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;...
/haiku の「自動で詠む」で止められますtaskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認ですgit config --global --add safe.directory <フォルダ> を足してください$.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消しますgit grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してくださいnpm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取りますhooks/register.tsx 404 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AquaTank, DecoId } from '../types'
5import {
6 DECOS,
7 DECO_SLOTS,
8 FEED_AMOUNT,
9 FEED_MS,
10 INITIAL,
11 MAX_MURK,
12 UPSET_MS,
13 decoHint,
14 decoName,
15 drawTank,
16 fishLine,
17 fishListSvg,
18 fishOf,
19 fromSaved,
20 fullnessAt,
21 gachaCounts,
22 nameOf,
23 placeDeco,
24 sameView,
25 shownFish,
26 stepFish,
27 toSaved,
28 toStore,
29 unlockedDecos,
30 viewAt,
31} from './tank'
32import type { Saved } from './tank'
33import { C, backupDaily, clean, followTheme, jstDay, listBackups, mayWrite, readTheme, rememberVersion } from './style'
34
35const PANE = 'aqua-pet'
36const TITLE = '水槽'
37const STORE_KEY = 'tank'
38const tank = atom({ plugin: 'aqua-pet', key: 'tank' } as const, INITIAL)
39// turn-gacha の state (読むだけ。turn-gacha が無ければ undefined のまま。読み方は dev-rpg と同じ)
40const GACHA_SAVE = { plugin: 'turn-gacha', key: 'save' } as const
41const GACHA_CARDS = { plugin: 'turn-gacha', key: 'cards' } as const
42
43type $ = EngineInterface
44
45/** 記録の控えのフォルダ (設定 backupDir)。絶対パスだけ。空なら控えない */
46let backupDir = ''
47const isAbsolute = (p: string) => /^([a-zA-Z]:[\\/]|[\\/])/.test(p)
48/** 今日もう控えを確かめた日 (1 日 1 回だけファイルを読みに行く) */
49let backedDay = ''
50
51/** その日はじめての時だけ、store の生の値 (知らない欄ごと) を控える */
52const backup = async ($: $, raw: unknown, now: number) => {
53 if (!backupDir || raw === undefined || backedDay === jstDay(now)) return
54 backedDay = jstDay(now)
55 // 書けない時も、その日はもう試さない (ターンのたびに読み書きしない)
56 await backupDaily($, backupDir, STORE_KEY, raw, now).catch(() => {})
57}
58
59/** ガチャで持っているカードの種類の数。turn-gacha が無い・読めない時は undefined。描画の中で呼ぶと、ガチャが変わった時に描き直る */
60const readCounts = async ($: $) => {
61 try {
62 return gachaCounts((await $.state.get(GACHA_SAVE)).value, (await $.state.get(GACHA_CARDS)).value)
63 } catch {
64 return undefined
65 }
66}
67
68// 同じ store を同時に開いた別のセッション (VS Code とターミナルなど) と分け合う。
69// 書く直前に読み直し、その値に今回の操作 (ターン +1・濁り +1・掃除・えさ・名前) だけを当てて書く
70
71/** store の今の育ち具合と生の値。キーが無ければ既定値、読めない・形が違う時は undefined */
72const readSaved = async ($: $, now: number): Promise<{ saved: Saved; raw: unknown } | undefined> => {
73 try {
74 const raw = await $.store.get(STORE_KEY)
75 if (raw !== undefined && (typeof raw !== 'object' || raw === null || Array.isArray(raw))) return undefined
76 return { saved: fromSaved(raw, now), raw }
77 } catch {
78 return undefined
79 }
80}
81
82// 1 セッションの中の書き込み (ターン終わりとボタンが重なる等) は 1 本ずつ流す
83let chain: Promise<unknown> = Promise.resolve()
84const serial = <T,>(f: () => Promise<T>): Promise<T> => {
85 const p = chain.then(f, f)
86 chain = p.catch(() => {})
87 return p
88}
89
90// 読めない・書けないの知らせはセッションで 1 回 (ターンのたびに出さない)
91let isWarned = false
92const warn = ($: $, text: string) => {
93 if (isWarned) return
94 isWarned = true
95 $.ui.toast(text, { timeoutMs: 8000 })
96}
97
98/** store の今の値に変える → 段を計算し直す → 変わっていれば残す。読めなければ state だけ変える (serial の中で呼ぶ) */
99const changeNow = async ($: $, fn: (t: AquaTank, now: number) => AquaTank) => {
100 const now = await $.clock.now()
101 const fresh = await readSaved($, now)
102 await backup($, fresh?.raw, now)
103 const t = await update($, tank, x => {
104 const y = fn(fresh ? { ...x, ...fresh.saved } : x, now)
105 return { ...y, view: viewAt(y, now) }
106 })
107 // 初めて (キーが無い) なら生まれた時刻を残すために書く。
108 // 新しい版の mod が足した欄 (この版の知らない欄) は消さずに書き戻す。
109 // 新しい版が入ったのに古い版のまま動いている時は書かない (mayWrite がトーストで開き直しを頼む)
110 if (!fresh) warn($, '水槽の記録を読めなかったので保存していません')
111 else {
112 const next = toStore(fresh.raw, toSaved(t))
113 if (JSON.stringify(next) !== JSON.stringify(fresh.raw) && (await mayWrite($, now)))
114 await $.store.set(STORE_KEY, next).catch(() => warn($, '水槽の記録を保存できませんでした'))
115 }
116 return t
117}
118const change = ($: $, fn: (t: AquaTank, now: number) => AquaTank) => serial(() => changeNow($, fn))
119
120/** 控えから戻す欄を開く (控えのある日を並べる) */
121const openRestore = async ($: $) => {
122 const days = (await listBackups($, backupDir, STORE_KEY).catch(() => [])).map(b => b.day)
123 await update($, tank, x => ({ ...x, restore: { days, pick: '' } }))
124}
125
126/** day の控えの値を store に書き、メモリの中も読み直す。古い版のままなら戻さない */
127const restoreDay = ($: $, day: string) =>
128 serial(async () => {
129 const now = await $.clock.now()
130 if (!(await mayWrite($, now))) return
131 const b = (await listBackups($, backupDir, STORE_KEY).catch(() => [])).find(x => x.day === day)
132 if (!b || typeof b.value !== 'object' || b.value === null || Array.isArray(b.value)) {
133 $.ui.toast('控えを読めなかったので、戻していません', { timeoutMs: 8000 })
134 return
135 }
136 try {
137 await $.store.set(STORE_KEY, b.value)
138 } catch {
139 $.ui.toast('控えの記録を書けませんでした', { timeoutMs: 8000 })
140 return
141 }
142 await changeNow($, x => ({ ...x, restore: null }))
143 $.ui.toast(`${day} の記録に戻しました`)
144 })
145
146/** 時間で変わる段 (満腹度・時刻帯・放置・一回限りのアニメ) が変わった時だけ書く */
147const refresh = async ($: $) => {
148 const now = await $.clock.now()
149 const t = await read($, tank)
150 if (sameView(t.view, viewAt(t, now))) return
151 await update($, tank, x => ({ ...x, view: viewAt(x, now) }))
152}
153
154const feed = async ($: $) => {
155 await change($, (t, now) => ({
156 ...t,
157 fullness: Math.min(100, fullnessAt(t, now) + FEED_AMOUNT),
158 fedAt: now,
159 feedUntil: now + FEED_MS,
160 }))
161 $.clock.after(FEED_MS + 50, () => refresh($))
162}
163
164export const register: Register = (on, options) => {
165 const dir = String(options.backupDir ?? '').trim()
166 backupDir = isAbsolute(dir) ? dir : ''
167 followTheme(on)
168
169 on('session.start', async ($, e, next) => {
170 await $.command.register({ name: 'aquarium', description: '水槽のペインを開く' }).catch(() => {})
171 await readTheme($)
172 await rememberVersion($).catch(() => {})
173 isWarned = false
174 backedDay = ''
175 // リロード時は実行中などの一時的な値を残し、育ち具合だけ store から戻す
176 await change($, t => t)
177 // 段は毎分計算するが、書くのは変わった時だけ (背景の時刻は 15 分刻みなので描き直しは 15 分ごと)
178 $.clock.every(60_000, () => refresh($))
179
180 return next(e)
181 })
182
183 on('command.run', { command: 'aquarium' }, async $ => {
184 await $.ui.open({ id: PANE, title: TITLE })
185
186 return { text: '水槽を開きました。' }
187 })
188
189 // 主ループのターンだけ数える (サブエージェントの分は agentId 付き)
190 on('turn.start', async ($, e, next) => {
191 if (!e.agentId) await change($, (t, now) => ({ ...t, isRunning: true, lastActiveAt: now }))
192 return next(e)
193 })
194
195 on('turn.complete', async ($, e, next) => {
196 // 中断したターンは育ちに数えない。数えたターンでは魚が 1 つ育ち、節目なら新しい魚が来る
197 // (t は書く直前に読み直した store の値なので、別のセッションが足した魚も消さない)
198 if (!e.agentId)
199 await change($, (t, now) => {
200 const done = { ...t, isRunning: false, lastActiveAt: now }
201 if (e.isAborted) return done
202 const turns = t.turns + 1
203 return { ...done, turns, fish: stepFish(fishOf(t), turns, t.bornAt, now) }
204 })
205 return next(e)
206 })
207
208 // 見るだけ。エラーの時だけ書く (成功では描き直さない)
209 on('tool.call', async ($, e, next) => {
210 // サブエージェントの分は数えない
211 if (e.agentId !== undefined) return next(e)
212 const ran = await next(e)
213 if ('isError' in ran && ran.isError) {
214 await change($, (t, now) => ({ ...t, upsetUntil: now + UPSET_MS, murk: Math.min(MAX_MURK, t.murk + 1) }))
215 $.clock.after(UPSET_MS + 50, () => refresh($))
216 }
217 return ran
218 }).catch(($, e, next) => next(e))
219
220 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
221 const t = await read($, tank)
222 const ui = $.ui.resolve(e)
223 const { Box, Text, Button } = ui
224 const v = t.view
225 const fish = shownFish(t)
226 const n = fish.length
227 // 0.4.x の state が残ったまま描かれた時 (リロード直後) も描けるように
228 const deco = t.deco ?? INITIAL.deco
229 const restore = t.restore ?? null
230 // かざり・控えの欄を開いている間は、その欄の操作を主にする (主のボタンは 1 つ)
231 const isOpen = t.isDecorating || restore !== null
232
233 const buttons = (
234 <Box flexDirection="row" flexWrap="wrap" gap={1}>
235 <Button key="feed" label="えさをやる" dimColor={isOpen} variant={isOpen ? undefined : 'primary'} onPress={() => void feed($)} />
236 <Button key="rename" dimColor label="名前" onPress={() => void update($, tank, x => ({ ...x, isNaming: !x.isNaming }))} />
237 <Button key="clean" dimColor label="掃除" onPress={() => void change($, x => ({ ...x, murk: 0 }))} />
238 <Button key="list" dimColor label={t.isListing ? '水槽' : '一覧'} onPress={() => void update($, tank, x => ({ ...x, isListing: !x.isListing }))} />
239 <Button
240 key="deco"
241 dimColor
242 label={t.isDecorating ? 'かざりを閉じる' : 'かざり'}
243 onPress={() => void update($, tank, x => ({ ...x, isDecorating: !x.isDecorating, isListing: false, restore: null }))}
244 />
245 <Button
246 key="restore"
247 dimColor
248 label={restore ? '戻すのをやめる' : '控えから戻す'}
249 onPress={() => void (restore ? update($, tank, x => ({ ...x, restore: null })) : openRestore($).then(() => update($, tank, x => ({ ...x, isDecorating: false }))))}
250 />
251 </Box>
252 )
253
254 // かざりの欄: 置き場を選んで、手に入っているかざりを置く・外す (ほかの置き場の物を選ぶと入れ替える)
255 let decoCard = null
256 if (t.isDecorating) {
257 const counts = await readCounts($)
258 const have = new Set<string>(unlockedDecos(counts))
259 const slot = Math.min(DECO_SLOTS - 1, Math.max(0, t.pickSlot))
260 const put = (id: DecoId) => void change($, x => ({ ...x, deco: placeDeco(x.deco, slot, id) }))
261 decoCard = (
262 <Box key="deco-card" flexDirection="column" borderStyle="round" borderColor={C.blue} paddingX={1} paddingY={1} gap={1}>
263 <Text bold color={C.blue}>
264 かざり
265 </Text>
266 <Text dimColor>置き場を選んで、置くかざりを押す</Text>
267 <Box flexDirection="row" flexWrap="wrap" gap={1}>
268 {deco.slice(0, DECO_SLOTS).map((id, i) => (
269 <Button key={`slot-${i}`} dimColor={i !== slot} label={`${i + 1} ${decoName(id)}`} onPress={() => void update($, tank, x => ({ ...x, pickSlot: i }))} />
270 ))}
271 </Box>
272 {counts === undefined ? (
273 <Text dimColor>turn-gacha を入れると、持っているカードでかざりが手に入ります</Text>
274 ) : (
275 <Box flexDirection="column" gap={1}>
276 <Box flexDirection="row" flexWrap="wrap" gap={1}>
277 {DECOS.filter(d => have.has(d.id)).map(d => (
278 <Button key={`put-${d.id}`} dimColor={deco[slot] !== d.id} variant={deco[slot] === d.id ? 'primary' : undefined} label={d.name} onPress={() => put(d.id)} />
279 ))}
280 {deco[slot] ? <Button key="take" dimColor label="外す" onPress={() => put('')} /> : null}
281 </Box>
282 {have.size === 0 ? <Text dimColor>ガチャでカードを集めると、かざりが手に入ります</Text> : null}
283 {DECOS.filter(d => !have.has(d.id)).map(d => (
284 <Text key={`need-${d.id}`} dimColor>
285 {decoHint(d)}
286 </Text>
287 ))}
288 </Box>
289 )}
290 </Box>
291 )
292 }
293
294 // 控えから戻す欄: 日を選ぶ → 確かめる → 戻す
295 let restoreCard = null
296 if (restore) {
297 restoreCard = (
298 <Box key="restore-card" flexDirection="column" borderStyle="round" borderColor={C.yellow} paddingX={1} paddingY={1} gap={1}>
299 <Text bold color={C.yellow}>
300 控えから戻す
301 </Text>
302 {!backupDir ? (
303 <Text dimColor>設定の backupDir を入れると控えが取れます</Text>
304 ) : restore.days.length === 0 ? (
305 <Text dimColor>まだ控えがありません (1 日 1 回取ります)</Text>
306 ) : (
307 <Box flexDirection="row" flexWrap="wrap" gap={1}>
308 {restore.days.map(day => (
309 <Button
310 key={`day-${day}`}
311 dimColor={restore.pick !== day}
312 label={day}
313 onPress={() => void update($, tank, x => ({ ...x, restore: x.restore ? { ...x.restore, pick: day } : null }))}
314 />
315 ))}
316 </Box>
317 )}
318 {restore.pick ? (
319 <Box flexDirection="column" gap={1}>
320 <Text>{`${restore.pick}: この日の記録に戻しますか。今の記録は上書きされます`}</Text>
321 <Box flexDirection="row" gap={1}>
322 <Button key="restore-ok" variant="primary" label="戻す" onPress={() => void restoreDay($, restore.pick)} />
323 <Button key="restore-no" dimColor label="やめる" onPress={() => void update($, tank, x => ({ ...x, restore: x.restore ? { ...x.restore, pick: '' } : null }))} />
324 </Box>
325 </Box>
326 ) : null}
327 </Box>
328 )
329 }
330 const naming =
331 t.isNaming && 'Input' in ui ? (
332 <ui.Input
333 key="name"
334 label="名前"
335 placeholder="一番大きい魚の名前"
336 value={t.name}
337 submitLabel="決める"
338 onSubmit={value => void change($, x => ({ ...x, name: nameOf(value), isNaming: false }))}
339 />
340 ) : null
341 const info = (
342 <Text dimColor>
343 {`${t.name ? `${t.name} / ` : ''}育てた日数 ${v.days}日 / 累計 ${t.turns}ターン${t.murk > 0 ? ` / 濁り ${t.murk}` : ''} / ${v.season}の${v.period}`}
344 </Text>
345 )
346
347 const placed = deco
348 .slice(0, DECO_SLOTS)
349 .filter(id => id !== '')
350 .map(id => clean(decoName(id)))
351 .join('・')
352
353 // 一覧の行 (terminal)
354 const listRows = t.isListing ? (
355 <Box key="fish-list" flexDirection="column">
356 {fish.map(f => {
357 const l = fishLine(f)
358 return (
359 <Box key={`f${f.id}`} flexDirection="column">
360 <Text>
361 {clean(l.title)} <Text color={l.stage === '大物' ? C.yellow : l.stage === '成魚' ? C.green : undefined}>{l.stage}</Text>
362 </Text>
363 <Text dimColor>{` ${l.came}`}</Text>
364 </Box>
365 )
366 })}
367 </Box>
368 ) : null
369
370 // terminal の部品表にも Svg の名前があるので surface でも分ける
371 if (e.surface === 'terminal' || !('Svg' in ui)) {
372 const mood = v.isUpset ? ' / びっくり' : t.isRunning ? ' / 作業中' : v.isIdle ? ' / おやすみ' : ''
373 return (
374 <Box flexDirection="column">
375 <Text>{`魚 ${n} 匹 / 満腹度 ${v.fullness}%${mood}`}</Text>
376 {info}
377 {placed ? <Text dimColor>{`かざり ${placed}`}</Text> : null}
378 {listRows}
379 {buttons}
380 {naming}
381 {decoCard}
382 {restoreCard}
383 </Box>
384 )
385 }
386 const { Svg } = ui
387
388 return (
389 <Box flexDirection="column" gap={1}>
390 {t.isListing ? (
391 <Svg key="list-svg" source={fishListSvg(fish)} alt={`魚の一覧: ${n} 匹`} />
392 ) : (
393 <Svg key="tank-svg" source={drawTank(t)} alt={`水槽: 魚 ${n} 匹、満腹度 ${v.fullness}%`} />
394 )}
395 {info}
396 {buttons}
397 {naming}
398 {decoCard}
399 {restoreCard}
400 </Box>
401 )
402 })
403}
404hooks/tank.ts 965 lines1import type { AquaFish, AquaPeriod, AquaSeason, AquaTank, AquaView, DecoId, FishKind, FishStage, KnownDecoId, KnownFishKind } from '../types'
2import { C, clean, dayPart, esc, frame, header, panel, season } from './style'
3
4export const HOUR = 3_600_000
5const DAY = 24 * HOUR
6export const IDLE_MS = 10 * 60_000
7export const UPSET_MS = 8_000
8export const FEED_MS = 6_000
9/** 1 時間で減る満腹度 (20 時間で空になる) */
10const HUNGER_PER_HOUR = 5
11export const FEED_AMOUNT = 25
12export const MAX_MURK = 10
13const HUNGRY = 30
14
15const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
16
17/** 0〜4 ターンで 1 匹、以後 10 ターンごとに 1 匹、最大 8 匹 */
18export const fishCount = (turns: number) => (turns < 5 ? 1 : Math.min(8, 2 + Math.floor((turns - 5) / 10)))
19
20// ---------------------------------------------------------------- 魚 1 匹ずつ
21
22export const MAX_FISH = 8
23/** i 番目 (0 から) の魚が来る累計ターン。fishCount の逆 */
24export const arrivalTurn = (i: number) => (i <= 0 ? 0 : 5 + (i - 1) * 10)
25
26/** 種類。後ろほど珍しい (足す時は後ろに。前の記録の魚の種類が変わらないように) */
27export const KINDS: readonly KnownFishKind[] = ['maru', 'shima', 'mizutama', 'hirenaga', 'ougi', 'akari', 'hoshi', 'tsuno', 'niji', 'ryugu']
28export const KIND_NAME: Record<KnownFishKind, string> = {
29 maru: 'まる魚',
30 shima: 'しま魚',
31 mizutama: '水玉魚',
32 hirenaga: 'ひれなが',
33 ougi: 'おうぎ尾',
34 akari: 'あかり魚',
35 hoshi: 'ほし魚',
36 tsuno: 'つの魚',
37 niji: 'にじ魚',
38 ryugu: 'りゅうぐう',
39}
40
41/** 来てからのターンで決まる段階 */
42export const stageOf = (turnsSeen: number): FishStage => (turnsSeen < 5 ? '稚魚' : turnsSeen < 20 ? '若魚' : turnsSeen < 60 ? '成魚' : '大物')
43const STAGE_SCALE: Record<FishStage, number> = { 稚魚: 0.55, 若魚: 0.8, 成魚: 1.1, 大物: 1.5 }
44
45/**
46 * i 番目に来る魚の種類。最初はまる魚、以後は節目が進むほど珍しい種類まで出る
47 * (2 匹目は先頭の 3 種から、最後の 8 匹目は全種から。後ろほど出やすい)。
48 * 水槽の生まれた時刻を種にするので、どのセッションが節目を越えても同じ魚が来る
49 */
50export const kindFor = (i: number, tankBornAt: number): KnownFishKind => {
51 if (i <= 0) return 'maru'
52 const pool = KINDS.slice(0, Math.min(KINDS.length, 1 + Math.ceil((i * (KINDS.length - 1)) / (MAX_FISH - 1))))
53 const total = pool.reduce((a, _, k) => a + k + 1, 0)
54 let x = rng(Math.floor(tankBornAt / 1000) ^ Math.imul(i, 2654435761))() * total
55 for (let k = 0; k < pool.length; k++) {
56 x -= k + 1
57 if (x < 0) return pool[k]!
58 }
59 return pool[pool.length - 1]!
60}
61
62/** 水槽と魚の名前。制御文字を落として 12 文字まで (サロゲートの対は割らない)。文字でなければ空 */
63export const nameOf = (v: unknown) => (typeof v === 'string' ? Array.from(clean(v)).slice(0, 12).join('') : '')
64
65/** 古い記録 (魚の欄が無い) から、今の数だけ魚を作る。来てからのターンは節目から数える */
66export const migrateFish = (turns: number, tankBornAt: number): AquaFish[] =>
67 Array.from({ length: fishCount(turns) }, (_, i) => ({ id: i, kind: kindFor(i, tankBornAt), bornAt: tankBornAt, turnsSeen: Math.max(0, turns - arrivalTurn(i)) }))
68
69/**
70 * 描く・並べる魚。空 (記録を読めなかった・見本の絵) なら turns から作る。
71 * 0.2.x の state が残ったまま描かれた時 (リロード直後) は fish が無いので、それも空と同じに扱う
72 */
73export const fishOf = (t: Pick<AquaTank, 'fish' | 'turns' | 'bornAt'>) => (t.fish && t.fish.length > 0 ? t.fish : migrateFish(t.turns, t.bornAt))
74
75/** 描く・数える魚 (MAX_FISH 匹まで)。新しい版が増やした魚は store に残し、ここで切るだけ */
76export const shownFish = (t: Pick<AquaTank, 'fish' | 'turns' | 'bornAt'>) => fishOf(t).slice(0, MAX_FISH)
77
78/** 1 ターン終わった: みんな 1 つ育ち、節目を越えた分だけ新しい魚が来る。turns は足した後の累計 */
79export const stepFish = (fish: readonly AquaFish[], turns: number, tankBornAt: number, now: number): AquaFish[] => {
80 const out = fish.map(f => ({ ...f, turnsSeen: f.turnsSeen + 1 }))
81 while (out.length < fishCount(turns)) {
82 const id = out.reduce((m, f) => Math.max(m, f.id + 1), 0)
83 out.push({ id, kind: kindFor(id, tankBornAt), bornAt: now, turnsSeen: 0 })
84 }
85 return out
86}
87
88/** store の魚の欄。配列でない・中身が全部壊れていれば turns から作り直す */
89const fishFrom = (raw: unknown, turns: number, tankBornAt: number): AquaFish[] => {
90 // ponytail: 0.2.x と 0.3.0 のセッションが同時に開いていると、0.2.x 側の書き込みで fish 欄が落ちる。
91 // その時はここで turns から作り直す (来た日が水槽の生まれた日に戻る)。0.2.x 側は直せないので、全部のセッションが 0.3.0 になれば起きない
92 if (!Array.isArray(raw)) return migrateFish(turns, tankBornAt)
93 const ok = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
94 const out = raw.flatMap((x): AquaFish[] => {
95 const o = typeof x === 'object' && x !== null ? (x as Record<string, unknown>) : {}
96 // 知らない種類 (新しい版の魚) も落とさない。描く時はまる魚の形、名前は「なぞの魚」
97 if (!ok(o.id) || typeof o.kind !== 'string' || o.kind === '') return []
98 const name = nameOf(o.name)
99 // 知らない欄 (新しい版が足した物) はそのまま持ち回る。欄の並びも元のまま (変わっていなければ書かない比べのため)
100 const f: Record<string, unknown> = {
101 ...o,
102 id: Math.max(0, Math.floor(o.id)),
103 kind: o.kind as FishKind,
104 bornAt: ok(o.bornAt) ? o.bornAt : tankBornAt,
105 turnsSeen: ok(o.turnsSeen) ? Math.max(0, Math.floor(o.turnsSeen)) : 0,
106 }
107 if (name) f.name = name
108 else delete f.name
109 return [f as AquaFish]
110 })
111 // 上限で切るのは描く時だけ (shownFish)。書き戻す配列は切らない
112 return out.length > 0 ? out : migrateFish(turns, tankBornAt)
113}
114
115export const fullnessAt = (t: AquaTank, now: number) =>
116 clamp(t.fullness - (Math.max(0, now - t.fedAt) / HOUR) * HUNGER_PER_HOUR, 0, 100)
117
118/** 日本時間 (JST) の時刻帯。決まりは共通の style.ts の dayPart */
119export const periodAt = (now: number): AquaPeriod => dayPart(now)
120
121/** 日本時間 (JST) の月で決まる季節。決まりは共通の style.ts の season */
122export const seasonAt = (now: number): AquaSeason => season(now)
123
124/** 日本時間の 15 分刻みの番号 0-95 (0 は 0:00〜0:15) */
125export const slotAt = (now: number) => Math.floor((((Math.floor(now / 60_000) + 540) % 1440) + 1440) % 1440 / 15)
126
127export const viewAt = (t: AquaTank, now: number): AquaView => ({
128 period: periodAt(now),
129 season: seasonAt(now),
130 slot: slotAt(now),
131 fullness: Math.round(fullnessAt(t, now) / 5) * 5,
132 isIdle: !t.isRunning && t.lastActiveAt > 0 && now - t.lastActiveAt > IDLE_MS,
133 isUpset: now < t.upsetUntil,
134 isFeeding: now < t.feedUntil,
135 days: t.bornAt > 0 ? Math.floor(Math.max(0, now - t.bornAt) / DAY) + 1 : 1,
136})
137
138export const sameView = (a: AquaView, b: AquaView) =>
139 a.period === b.period &&
140 a.season === b.season &&
141 a.slot === b.slot &&
142 a.fullness === b.fullness &&
143 a.isIdle === b.isIdle &&
144 a.isUpset === b.isUpset &&
145 a.isFeeding === b.isFeeding &&
146 a.days === b.days
147
148export const INITIAL: AquaTank = {
149 turns: 0,
150 bornAt: 0,
151 fullness: 70,
152 fedAt: 0,
153 murk: 0,
154 name: '',
155 isRunning: false,
156 lastActiveAt: 0,
157 upsetUntil: 0,
158 feedUntil: 0,
159 fish: [],
160 deco: ['', '', '', '', ''],
161 isNaming: false,
162 isListing: false,
163 isDecorating: false,
164 pickSlot: 0,
165 restore: null,
166 view: { period: '昼', season: '春', slot: 48, fullness: 70, isIdle: false, isUpset: false, isFeeding: false, days: 1 },
167}
168
169/** $.store に残す分 */
170export type Saved = Pick<AquaTank, 'turns' | 'bornAt' | 'fullness' | 'fedAt' | 'murk' | 'name' | 'fish' | 'deco'>
171export const toSaved = (t: AquaTank): Saved => ({
172 turns: t.turns,
173 bornAt: t.bornAt,
174 fullness: t.fullness,
175 fedAt: t.fedAt,
176 murk: t.murk,
177 name: t.name,
178 fish: t.fish,
179 deco: t.deco,
180})
181
182/**
183 * store に書く物。書く直前に読み直した生の値 raw のうち、この版の知らない欄
184 * (新しい版の mod が足した欄) はそのまま残し、知っている欄だけ s で上書きする。
185 * 魚の中の知らない欄は fromSaved が持ち回っている
186 */
187export const toStore = (raw: unknown, s: Saved): Saved => ({ ...(typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {}), ...s })
188
189/** store の中身は信用しない: 数と文字だけ拾う */
190export const fromSaved = (raw: unknown, now: number): Saved => {
191 const o = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
192 const num = (k: string, d: number) => (typeof o[k] === 'number' && Number.isFinite(o[k]) ? (o[k] as number) : d)
193 const turns = Math.max(0, Math.floor(num('turns', 0)))
194 const bornAt = num('bornAt', now) || now
195 return {
196 turns,
197 bornAt,
198 fullness: clamp(num('fullness', 70), 0, 100),
199 fedAt: num('fedAt', now) || now,
200 murk: clamp(Math.floor(num('murk', 0)), 0, MAX_MURK),
201 name: nameOf(o.name),
202 fish: fishFrom(o.fish, turns, bornAt),
203 deco: decoFrom(o.deco),
204 }
205}
206
207// ---------------------------------------------------------------- かざり (turn-gacha のカードで手に入る)
208
209/** 底の置き場の数 */
210export const DECO_SLOTS = 5
211/** 置き場の真ん中の x (左から) */
212export const SLOT_X = [46, 112, 180, 248, 314]
213
214type Rarity = 'N' | 'R' | 'SR' | 'SSR'
215export type GachaCounts = Record<Rarity, number>
216
217/**
218 * かざりの表。rarity のカードを need 種類持っていると手に入る。
219 * id は保存に使うので変えない。足す時は後ろに。home は魚が周りを泳ぐ物
220 */
221export const DECOS: readonly { id: KnownDecoId; name: string; rarity: Rarity; need: number; home?: boolean }[] = [
222 { id: 'kusa', name: '水草', rarity: 'N', need: 1 },
223 { id: 'ishi', name: '石', rarity: 'N', need: 4 },
224 { id: 'ryuboku', name: '流木', rarity: 'N', need: 8 },
225 { id: 'kai', name: '貝', rarity: 'R', need: 1 },
226 { id: 'tsubo', name: 'つぼ', rarity: 'R', need: 4 },
227 { id: 'shiro', name: '城', rarity: 'SR', need: 1, home: true },
228 { id: 'takara', name: '宝箱', rarity: 'SR', need: 4 },
229 { id: 'chinbotsu', name: '沈没船', rarity: 'SSR', need: 1, home: true },
230]
231const DECO_BY_ID = new Map<string, (typeof DECOS)[number]>(DECOS.map(d => [d.id, d]))
232
233/** かざりの名前。知らない物 (新しい版のかざり) は「なぞのかざり」、空きは「空き」 */
234export const decoName = (id: DecoId) => (id === '' ? '空き' : (DECO_BY_ID.get(id)?.name ?? 'なぞのかざり'))
235/** 手に入れ方の一言 */
236export const decoHint = (d: (typeof DECOS)[number]) => `${d.rarity} を ${d.need} 種そろえると${d.name}`
237
238const RARITIES: readonly Rarity[] = ['N', 'R', 'SR', 'SSR']
239
240/**
241 * turn-gacha の state (save と cards) から、持っているカードの種類の数をレア度ごとに数える。
242 * 確かめ方は dev-rpg の ownedCards と同じ。turn-gacha が無い・まだ読み込む前・形が違う時は undefined
243 */
244export function gachaCounts(save: unknown, cards: unknown): GachaCounts | undefined {
245 if (typeof save !== 'object' || save === null || !Array.isArray(cards)) return undefined
246 const book = (save as Record<string, unknown>).book
247 if (typeof book !== 'object' || book === null) return undefined
248 const out: GachaCounts = { N: 0, R: 0, SR: 0, SSR: 0 }
249 const seen = new Set<string>()
250 for (const c of cards) {
251 if (typeof c !== 'object' || c === null) continue
252 const o = c as Record<string, unknown>
253 if (typeof o.id !== 'string' || o.id === '' || seen.has(o.id) || !RARITIES.includes(o.rarity as Rarity)) continue
254 const n = ((book as Record<string, unknown>)[o.id] as Record<string, unknown> | undefined)?.count
255 if (typeof n !== 'number' || !(n > 0)) continue
256 seen.add(o.id)
257 out[o.rarity as Rarity]++
258 }
259 return out
260}
261
262/** 手に入っているかざり (表の順)。ガチャが無ければ無し */
263export const unlockedDecos = (counts: GachaCounts | undefined): KnownDecoId[] =>
264 counts ? DECOS.filter(d => counts[d.rarity] >= d.need).map(d => d.id) : []
265
266/** store の置き場の欄。文字でない物は空き。知らない id と、この版より多い置き場はそのまま持ち回る */
267const decoFrom = (raw: unknown): DecoId[] => {
268 const out: DecoId[] = Array.isArray(raw) ? raw.map(x => (typeof x === 'string' ? clean(x).slice(0, 32) : '')) : []
269 while (out.length < DECO_SLOTS) out.push('')
270 return out
271}
272
273/**
274 * slot に id を置く ('' なら外す)。id がほかの置き場にあれば、そこと入れ替える
275 * (同じかざりは 1 つだけ)。ほかの置き場はそのまま
276 */
277export const placeDeco = (deco: readonly DecoId[], slot: number, id: DecoId): DecoId[] => {
278 const out = decoFrom(deco)
279 if (slot < 0 || slot >= DECO_SLOTS) return out
280 const from = id === '' ? -1 : out.slice(0, DECO_SLOTS).indexOf(id)
281 if (from >= 0 && from !== slot) out[from] = out[slot] ?? ''
282 out[slot] = id
283 return out
284}
285
286/** 時刻帯で寄せる色 (夕は橙、夜は暗く) と寄せる量 */
287const DECO_TONE: Record<AquaPeriod, { to: string; k: number }> = {
288 朝: { to: '#ffd9a0', k: 0.1 },
289 昼: { to: '#ffffff', k: 0 },
290 夕: { to: '#d9803e', k: 0.22 },
291 夜: { to: '#061024', k: 0.45 },
292}
293/** 季節の水草の色 */
294const KUSA_COLOR: Record<AquaSeason, [string, string]> = {
295 春: ['#5fcf6a', '#9be38a'],
296 夏: ['#1f9e4a', '#2ea043'],
297 秋: ['#b5a33a', '#d29922'],
298 冬: ['#4f8f7a', '#6aa894'],
299}
300
301type Tone = { paint: (c: string) => string; season: AquaSeason; isNight: boolean }
302
303/** 冬は上に雪が積もる (cx・上端 y・半幅) */
304const snowCap = (t: Tone, x: number, y: number, w: number) =>
305 t.season === '冬' ? `<path d="M${f1(x - w)} ${f1(y + 2)} Q${x} ${f1(y - 5)} ${f1(x + w)} ${f1(y + 2)} Q${x} ${f1(y - 1)} ${f1(x - w)} ${f1(y + 2)} Z" fill="#f0f6fc" opacity=".92"/>` : ''
306
307/** かざり 1 つの絵。x は置き場の真ん中、b は底 (砂に少し埋まる) */
308const DECO_DRAW: Record<KnownDecoId, (x: number, b: number, t: Tone) => string> = {
309 kusa: (x, b, t) => {
310 const [c1, c2] = KUSA_COLOR[t.season]
311 return [
312 { dx: -8, h: 46, c: c1, d: 3.6 },
313 { dx: 0, h: 64, c: c2, d: 4.4 },
314 { dx: 8, h: 38, c: c1, d: 3.1 },
315 ]
316 .map(p => {
317 const px = x + p.dx
318 const a = `M${px} ${b} C${px - 8} ${f1(b - p.h * 0.4)} ${px + 8} ${f1(b - p.h * 0.7)} ${px} ${b - p.h}`
319 const z = `M${px} ${b} C${px - 2} ${f1(b - p.h * 0.4)} ${px + 12} ${f1(b - p.h * 0.7)} ${px + 8} ${b - p.h + 2}`
320 return `<path d="${a}" stroke="${t.paint(p.c)}" stroke-width="5" stroke-linecap="round" fill="none"><animate attributeName="d" values="${a};${z};${a}" dur="${p.d}s" repeatCount="indefinite"/></path>`
321 })
322 .join('')
323 },
324 ishi: (x, b, t) =>
325 `<ellipse cx="${x - 8}" cy="${b - 8}" rx="15" ry="10" fill="${t.paint('#6e7681')}"/>` +
326 `<ellipse cx="${x + 10}" cy="${b - 6}" rx="11" ry="7" fill="${t.paint('#8b949e')}"/>` +
327 `<ellipse cx="${x - 4}" cy="${b - 21}" rx="9" ry="6.5" fill="${t.paint('#57606a')}"/>` +
328 `<path d="M${x - 10} ${b - 25} Q${x - 4} ${b - 28} ${x + 1} ${b - 25}" stroke="#ffffff" stroke-width="1.2" fill="none" opacity=".25"/>` +
329 snowCap(t, x - 4, b - 27, 8),
330 ryuboku: (x, b, t) => {
331 const wood = t.paint('#7a5534')
332 return (
333 `<path d="M${x - 30} ${b - 3} C${x - 14} ${b - 14} ${x + 4} ${b - 8} ${x + 28} ${b - 24}" stroke="${wood}" stroke-width="7" stroke-linecap="round" fill="none"/>` +
334 `<path d="M${x - 4} ${b - 9} C${x} ${b - 22} ${x - 8} ${b - 30} ${x - 12} ${b - 38}" stroke="${wood}" stroke-width="4" stroke-linecap="round" fill="none"/>` +
335 `<path d="M${x + 12} ${b - 15} L${x + 22} ${b - 5}" stroke="${wood}" stroke-width="3" stroke-linecap="round"/>` +
336 `<path d="M${x - 26} ${b - 6} C${x - 12} ${b - 15} ${x + 4} ${b - 10} ${x + 24} ${b - 25}" stroke="${t.paint('#a07a52')}" stroke-width="1.4" fill="none" opacity=".6"/>` +
337 snowCap(t, x + 20, b - 23, 6)
338 )
339 },
340 kai: (x, b, t) => {
341 const shell = t.paint(t.season === '春' ? '#ffc4d6' : '#f4b6a2')
342 const ridge = t.paint('#c97a6a')
343 return (
344 `<path d="M${x - 15} ${b - 3} Q${x - 17} ${b - 24} ${x} ${b - 26} Q${x + 17} ${b - 24} ${x + 15} ${b - 3} Z" fill="${shell}"/>` +
345 [-10, -5, 0, 5, 10].map(dx => `<path d="M${x} ${b - 3} L${x + dx} ${b - 23}" stroke="${ridge}" stroke-width="1" opacity=".7"/>`).join('') +
346 `<rect x="${x - 6}" y="${b - 5}" width="12" height="5" rx="2" fill="${ridge}"/>` +
347 `<circle cx="${x + 20}" cy="${b - 5}" r="4" fill="${t.paint('#f0f6fc')}"/>` +
348 `<circle cx="${x + 18.6}" cy="${b - 6.4}" r="1.2" fill="#ffffff" opacity=".8"/>` +
349 (t.isNight ? `<circle cx="${x + 20}" cy="${b - 5}" r="8" fill="#f0f6fc" opacity=".15"><animate attributeName="opacity" values=".08;.3;.08" dur="3s" repeatCount="indefinite"/></circle>` : '')
350 )
351 },
352 tsubo: (x, b, t) =>
353 `<ellipse cx="${x - 2}" cy="${b - 12}" rx="19" ry="12" fill="${t.paint('#b0643a')}"/>` +
354 `<path d="M${x - 14} ${b - 20} Q${x - 2} ${b - 25} ${x + 10} ${b - 20}" stroke="${t.paint('#d9895a')}" stroke-width="2" fill="none" opacity=".7"/>` +
355 `<path d="M${x - 8} ${b - 23} L${x - 8} ${b - 1} M${x + 4} ${b - 23} L${x + 4} ${b - 1}" stroke="${t.paint('#7a3f20')}" stroke-width="1.6" opacity=".7"/>` +
356 `<ellipse cx="${x + 18}" cy="${b - 13}" rx="4.5" ry="8" fill="${t.paint('#8a4a28')}"/>` +
357 `<ellipse cx="${x + 19}" cy="${b - 13}" rx="2.6" ry="5.6" fill="#1a0d06"/>` +
358 snowCap(t, x - 2, b - 24, 12),
359 shiro: (x, b, t) => {
360 const wall = t.paint('#9aa4b2')
361 const dark = t.paint('#6e7681')
362 const roof = t.paint('#8957e5')
363 const lit = t.isNight
364 const win = (wx: number, wy: number, k: number) =>
365 lit
366 ? `<rect x="${wx}" y="${wy}" width="4" height="6" rx="2" fill="#ffd866"><animate attributeName="opacity" values=".55;1;.55" dur="${2 + k * 0.6}s" repeatCount="indefinite"/></rect>`
367 : `<rect x="${wx}" y="${wy}" width="4" height="6" rx="2" fill="#0d1117" opacity=".7"/>`
368 const merlons = (x0: number, n: number, y: number) => Array.from({ length: n }, (_, k) => `<rect x="${x0 + k * 5}" y="${y - 4}" width="3" height="4" fill="${wall}"/>`).join('')
369 return (
370 `<rect x="${x - 20}" y="${b - 34}" width="40" height="34" fill="${wall}"/>` +
371 merlons(x - 20, 8, b - 34) +
372 `<rect x="${x - 28}" y="${b - 48}" width="12" height="48" fill="${dark}"/>` +
373 `<rect x="${x + 16}" y="${b - 48}" width="12" height="48" fill="${dark}"/>` +
374 `<path d="M${x - 30} ${b - 48} L${x - 22} ${b - 60} L${x - 14} ${b - 48} Z" fill="${roof}"/>` +
375 `<path d="M${x + 14} ${b - 48} L${x + 22} ${b - 60} L${x + 30} ${b - 48} Z" fill="${roof}"/>` +
376 `<rect x="${x - 7}" y="${b - 54}" width="14" height="20" fill="${wall}"/>` +
377 `<path d="M${x - 9} ${b - 54} L${x} ${b - 70} L${x + 9} ${b - 54} Z" fill="${roof}"/>` +
378 `<path d="M${x} ${b - 70} L${x} ${b - 78}" stroke="${dark}" stroke-width="1"/><path d="M${x} ${b - 78} L${x + 7} ${b - 76} L${x} ${b - 74} Z" fill="${t.paint('#f85149')}"/>` +
379 `<path d="M${x - 6} ${b} V${b - 10} A6 6 0 0 1 ${x + 6} ${b - 10} V${b} Z" fill="#0d1117" opacity=".75"/>` +
380 win(x - 24, b - 40, 0) +
381 win(x + 20, b - 40, 1) +
382 win(x - 2, b - 48, 2) +
383 snowCap(t, x - 22, b - 59, 8) +
384 snowCap(t, x + 22, b - 59, 8) +
385 snowCap(t, x, b - 69, 9)
386 )
387 },
388 takara: (x, b, t) => {
389 const wood = t.paint('#8b5a2b')
390 const gold = t.paint('#e3b341')
391 return (
392 (t.isNight ? `<ellipse cx="${x}" cy="${b - 18}" rx="26" ry="16" fill="#ffd34d" opacity=".18"><animate attributeName="opacity" values=".1;.28;.1" dur="2.8s" repeatCount="indefinite"/></ellipse>` : '') +
393 `<rect x="${x - 17}" y="${b - 20}" width="34" height="20" rx="2" fill="${wood}"/>` +
394 `<path d="M${x - 16} ${b - 21} L${x - 20} ${b - 34} Q${x} ${b - 42} ${x + 20} ${b - 34} L${x + 16} ${b - 21} Z" fill="${t.paint('#a06a35')}"/>` +
395 `<path d="M${x - 15} ${b - 21} Q${x} ${b - 27} ${x + 15} ${b - 21} Z" fill="${t.paint('#ffd34d')}"/>` +
396 [-8, 0, 8].map(dx => `<circle cx="${x + dx}" cy="${b - 23}" r="2.6" fill="${gold}" stroke="${t.paint('#9e6a03')}" stroke-width=".6"/>`).join('') +
397 `<rect x="${x - 17}" y="${b - 13}" width="34" height="3" fill="${gold}"/>` +
398 `<rect x="${x - 3}" y="${b - 16}" width="6" height="8" rx="1" fill="${gold}"/>` +
399 `<path d="M${x + 9} ${b - 33} L${x + 10} ${b - 30} L${x + 13} ${b - 29} L${x + 10} ${b - 28} L${x + 9} ${b - 25} L${x + 8} ${b - 28} L${x + 5} ${b - 29} L${x + 8} ${b - 30} Z" fill="#fffbe0"><animate attributeName="opacity" values="0;1;0" dur="2.2s" repeatCount="indefinite"/></path>` +
400 snowCap(t, x, b - 39, 14)
401 )
402 },
403 chinbotsu: (x, b, t) => {
404 const hull = t.paint('#5a3d24')
405 const port = t.isNight ? '#7ee0c8' : '#0d1117'
406 return (
407 `<g transform="rotate(-10 ${x} ${b})">` +
408 `<path d="M${x - 38} ${b - 20} L${x + 36} ${b - 20} L${x + 26} ${b} L${x - 30} ${b} Z" fill="${hull}"/>` +
409 `<path d="M${x - 38} ${b - 20} L${x + 36} ${b - 20}" stroke="${t.paint('#8a6440')}" stroke-width="2"/>` +
410 [-20, -6, 8, 22].map((dx, k) => `<circle cx="${x + dx}" cy="${b - 11}" r="2.6" fill="${port}"${t.isNight ? ` opacity=".6"><animate attributeName="opacity" values=".3;.9;.3" dur="${f1(2.5 + k * 0.4)}s" repeatCount="indefinite"/></circle>` : ' opacity=".8"/>'}`).join('') +
411 `<path d="M${x - 4} ${b - 20} L${x - 4} ${b - 72}" stroke="${t.paint('#4a3320')}" stroke-width="3"/>` +
412 `<path d="M${x - 16} ${b - 66} L${x + 8} ${b - 66}" stroke="${t.paint('#4a3320')}" stroke-width="2"/>` +
413 `<path d="M${x - 2} ${b - 64} Q${x + 18} ${b - 52} ${x + 6} ${b - 30} L${x - 2} ${b - 34} Q${x + 6} ${b - 46} ${x - 2} ${b - 54} Z" fill="${t.paint('#c9b99a')}" opacity=".65"/>` +
414 `<path d="M${x + 30} ${b - 20} C${x + 34} ${b - 30} ${x + 28} ${b - 36} ${x + 32} ${b - 44}" stroke="${t.paint('#2ea043')}" stroke-width="2.4" fill="none" stroke-linecap="round"/>` +
415 `</g>` +
416 snowCap(t, x - 4, b - 70, 6)
417 )
418 },
419}
420
421/** 底のかざり。知らない id (新しい版のかざり) と空きは描かない */
422const decoSvg = (deco: readonly DecoId[] | undefined, v: AquaView) => {
423 if (!deco) return ''
424 const tone = DECO_TONE[v.period] ?? DECO_TONE.昼
425 const t: Tone = { paint: c => (tone.k > 0 ? mix(c, tone.to, tone.k) : c), season: v.season, isNight: v.period === '夜' }
426 let s = ''
427 deco.slice(0, DECO_SLOTS).forEach((id, i) => {
428 const draw = (DECO_DRAW as Partial<Record<string, (typeof DECO_DRAW)['kusa']>>)[id]
429 if (draw) s += `<g class="deco-${id}">${draw(SLOT_X[i] ?? 180, FLOOR + 8, t)}</g>`
430 })
431 return s
432}
433
434/** 魚が周りを泳ぐかざり (城・沈没船) の x。左から */
435const homesOf = (deco: readonly DecoId[] | undefined) =>
436 (deco ?? []).slice(0, DECO_SLOTS).flatMap((id, i) => (DECO_BY_ID.get(id)?.home ? [SLOT_X[i] ?? 180] : []))
437
438// ---------------------------------------------------------------- SVG
439
440/** 番号から決まる擬似乱数 (mulberry32)。描き直しで魚の見た目が変わらない */
441const rng = (seed: number) => {
442 let a = seed >>> 0
443 return () => {
444 a = (a + 0x6d2b79f5) >>> 0
445 let x = Math.imul(a ^ (a >>> 15), 1 | a)
446 x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x
447 return ((x ^ (x >>> 14)) >>> 0) / 4294967296
448 }
449}
450
451const hex = (c: string) => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
452const mix = (a: string, b: string, t: number) => {
453 const [x, y] = [hex(a), hex(b)]
454 return '#' + x.map((v, i) => Math.round(v + ((y[i] ?? 0) - v) * t).toString(16).padStart(2, '0')).join('')
455}
456const f1 = (n: number) => Math.round(n * 10) / 10
457
458const W = 360
459const H = 300
460const TOP = 30 // 水面
461const FLOOR = 262 // 砂の上端
462
463type Water = { top: string; mid: string; bottom: string }
464
465/** 水の色の節目 (日本時間の 0 時からの分)。間は 15 分刻みで混ぜる */
466const WATER_KEYS: (Water & { at: number })[] = [
467 { at: 0, top: '#10294a', mid: '#08172e', bottom: '#030814' },
468 { at: 300, top: '#2f4f78', mid: '#16304f', bottom: '#08172b' }, // 5:00 夜明け
469 { at: 450, top: '#4a86a8', mid: '#1f557a', bottom: '#0c2a42' }, // 7:30 朝
470 { at: 780, top: '#3aa0d8', mid: '#1767a3', bottom: '#0a345c' }, // 13:00 昼
471 { at: 1050, top: '#d9803e', mid: '#7a4a52', bottom: '#1c1c3c' }, // 17:30 夕焼け (橙)
472 { at: 1170, top: '#163256', mid: '#0b1c36', bottom: '#040a16' }, // 19:30 夜
473 { at: 1440, top: '#10294a', mid: '#08172e', bottom: '#030814' },
474]
475
476/** 季節ごとの水の色味と、それをどれだけ混ぜるか */
477const SEASON_TINT: Record<AquaSeason, { color: string; amount: number }> = {
478 春: { color: '#ffb7c5', amount: 0.1 },
479 夏: { color: '#22d3ee', amount: 0.12 },
480 秋: { color: '#d9822b', amount: 0.12 },
481 冬: { color: '#c8d6e5', amount: 0.16 },
482}
483
484/** 15 分刻みの番号と季節から水の 3 色を決める */
485export const waterAt = (slot: number, season: AquaSeason): Water => {
486 // 0.1.x の state が残ったまま描かれた時 (リロード直後) も描けるように
487 const at = Number.isFinite(slot) ? (((slot % 96) + 96) % 96) * 15 : 720
488 const i = Math.max(0, WATER_KEYS.findIndex(k => k.at > at) - 1)
489 const a = WATER_KEYS[i]!
490 const b = WATER_KEYS[i + 1] ?? a
491 const t = b.at === a.at ? 0 : (at - a.at) / (b.at - a.at)
492 const s = SEASON_TINT[season] ?? SEASON_TINT.春
493 const pick = (k: keyof Water) => mix(mix(a[k], b[k], t), s.color, s.amount)
494 return { top: pick('top'), mid: pick('mid'), bottom: pick('bottom') }
495}
496
497/** 時刻帯ごとの光。昼は筋が強く多い、夜は月明かりの筋が 1 本 */
498const LIGHT: Record<AquaPeriod, { sky: string; ray: string; rayOp: number; tint: string; tintOp: number; rays: { x: number; w: number; d: number }[] }> = {
499 朝: { sky: '#1c2533', ray: '#ffe9b8', rayOp: 0.2, tint: '#ffd9a0', tintOp: 0.05, rays: [{ x: 70, w: 34, d: 7 }, { x: 170, w: 22, d: 9 }, { x: 250, w: 40, d: 11 }] },
500 昼: {
501 sky: '#1a2531',
502 ray: '#f2fbff',
503 rayOp: 0.32,
504 tint: '#ffffff',
505 tintOp: 0,
506 rays: [{ x: 50, w: 36, d: 6 }, { x: 130, w: 26, d: 8 }, { x: 200, w: 30, d: 7.5 }, { x: 280, w: 42, d: 10 }],
507 },
508 夕: { sky: '#3a2216', ray: '#ffb36b', rayOp: 0.22, tint: '#ff8a3c', tintOp: 0.08, rays: [{ x: 90, w: 30, d: 8 }, { x: 230, w: 36, d: 11 }] },
509 夜: { sky: '#0f141c', ray: '#c8d8ff', rayOp: 0.12, tint: '#000814', tintOp: 0.24, rays: [{ x: 214, w: 16, d: 12 }] },
510}
511
512const FISH_COLORS = ['#f78166', '#58a6ff', '#d29922', '#bc8cff', '#3fb950', '#ff7b72', '#79c0ff', '#e3b341']
513
514const sand = (r: () => number) => {
515 let s = `<path d="M0 ${FLOOR} C60 ${FLOOR - 8} 110 ${FLOOR + 6} 180 ${FLOOR - 2} S300 ${FLOOR - 10} 360 ${FLOOR - 2} V${H} H0 Z" fill="url(#sand)"/>`
516 for (let i = 0; i < 14; i++) {
517 const x = f1(r() * W)
518 const y = f1(FLOOR + 8 + r() * 28)
519 const c = ['#6e7681', '#8b949e', '#a5865a', '#5a4a33'][i % 4]
520 s += `<ellipse cx="${x}" cy="${y}" rx="${f1(2 + r() * 4)}" ry="${f1(1.5 + r() * 2)}" fill="${c}" opacity=".8"/>`
521 }
522 // 岩
523 s += `<path d="M232 ${FLOOR + 4} C236 ${FLOOR - 26} 270 ${FLOOR - 34} 286 ${FLOOR - 10} C292 ${FLOOR} 280 ${FLOOR + 8} 232 ${FLOOR + 4} Z" fill="#484f58"/>`
524 s += `<path d="M244 ${FLOOR - 14} C252 ${FLOOR - 26} 266 ${FLOOR - 28} 274 ${FLOOR - 18}" stroke="#6e7681" stroke-width="2" fill="none" opacity=".6"/>`
525 return s
526}
527
528const plants = () => {
529 const list = [
530 { x: 34, h: 150, c: '#238636', w: 6, d: 4.2 },
531 { x: 52, h: 110, c: '#3fb950', w: 5, d: 3.4 },
532 { x: 70, h: 80, c: '#2ea043', w: 4, d: 2.9 },
533 { x: 300, h: 130, c: '#2ea043', w: 6, d: 3.8 },
534 { x: 318, h: 170, c: '#238636', w: 5, d: 4.6 },
535 { x: 160, h: 60, c: '#3fb950', w: 4, d: 3.1 },
536 ]
537 return list
538 .map(p => {
539 const b = FLOOR + 4
540 const a = `M${p.x} ${b} C${p.x - 10} ${b - p.h * 0.35} ${p.x + 10} ${b - p.h * 0.7} ${p.x} ${b - p.h}`
541 const z = `M${p.x} ${b} C${p.x - 4} ${b - p.h * 0.35} ${p.x + 18} ${b - p.h * 0.65} ${p.x + 12} ${b - p.h * 0.96}`
542 const y = `M${p.x} ${b} C${p.x - 16} ${b - p.h * 0.35} ${p.x + 2} ${b - p.h * 0.7} ${p.x - 10} ${b - p.h * 0.97}`
543 return `<path d="${a}" stroke="${p.c}" stroke-width="${p.w}" stroke-linecap="round" fill="none"><animate attributeName="d" values="${a};${z};${a};${y};${a}" dur="${p.d}s" repeatCount="indefinite"/></path>`
544 })
545 .join('')
546}
547
548const rays = (c: (typeof LIGHT)[AquaPeriod]) =>
549 c.rays
550 .map(
551 r =>
552 `<polygon points="${r.x},${TOP} ${r.x + r.w},${TOP} ${r.x + r.w - 60},${FLOOR} ${r.x - 90},${FLOOR}" fill="url(#ray)" opacity="${c.rayOp}">` +
553 `<animate attributeName="opacity" values="${f1(c.rayOp * 0.3)};${c.rayOp};${f1(c.rayOp * 0.3)}" dur="${r.d}s" repeatCount="indefinite"/>` +
554 `<animateTransform attributeName="transform" type="translate" values="0 0;14 0;0 0" dur="${r.d * 1.3}s" repeatCount="indefinite"/></polygon>`,
555 )
556 .join('')
557
558const bubbles = (n: number, fast: boolean) => {
559 const r = rng(777)
560 let s = ''
561 for (let i = 0; i < n; i++) {
562 const x = f1(20 + r() * 320)
563 const size = f1(1.8 + r() * 3.2)
564 const dur = f1((fast ? 2.6 : 5) + r() * (fast ? 2 : 4))
565 const begin = f1(-r() * dur)
566 s +=
567 `<circle cx="${x}" cy="${FLOOR}" r="${size}" fill="none" stroke="#e6f6ff" stroke-width="1" opacity=".7">` +
568 `<animate attributeName="cy" values="${FLOOR};${TOP + 2}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
569 `<animate attributeName="cx" values="${x};${f1(x + 6)};${f1(x - 5)};${x}" dur="${f1(dur / 2)}s" begin="${begin}s" repeatCount="indefinite"/>` +
570 `<animate attributeName="opacity" values=".8;.7;0" keyTimes="0;.85;1" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></circle>`
571 }
572 return s
573}
574
575const pellets = () => {
576 const r = rng(4242)
577 let s = ''
578 for (let i = 0; i < 9; i++) {
579 const x = f1(110 + r() * 150)
580 const begin = f1(r() * 1.2)
581 const dur = f1(3.4 + r() * 1.4)
582 const end = f1(FLOOR - 4 - r() * 10)
583 s +=
584 `<circle cx="${x}" cy="${TOP + 2}" r="2.6" fill="#d29922" stroke="#9e6a03" stroke-width=".8">` +
585 `<animate attributeName="cy" values="${TOP + 2};${end}" dur="${dur}s" begin="${begin}s" fill="freeze"/>` +
586 `<animate attributeName="cx" values="${x};${f1(x + 5)};${f1(x - 4)};${x}" dur="${dur}s" begin="${begin}s" fill="freeze"/>` +
587 `<animate attributeName="opacity" values="1;1;0" keyTimes="0;.8;1" dur="${dur}s" begin="${begin}s" fill="freeze"/></circle>`
588 }
589 return s
590}
591
592// ---- 時刻と季節の飾り (animateMotion と fill="freeze" は使わない: 魚とえさの目印なので)
593
594// 空の物は HUD の文字の間 (x 180〜230 あたり) に、HUD の後に描く (前に描くと HUD の地で隠れる)
595
596/** 夜: 三日月と小さな星、水面に映る光 */
597const moon = () =>
598 `<g id="moon"><path d="M206 7 A8 8 0 1 0 206 23 A4 8 0 1 1 206 7 Z" fill="#f2f0d0"/>` +
599 [[186, 10], [228, 18], [176, 20]].map(([x, y], k) => `<circle cx="${x}" cy="${y}" r=".9" fill="#f2f0d0" opacity=".3"><animate attributeName="opacity" values=".2;.9;.2" dur="${2 + k * 0.7}s" repeatCount="indefinite"/></circle>`).join('') +
600 `<ellipse cx="214" cy="${TOP + 3}" rx="18" ry="2" fill="#f2f0d0" opacity=".3"><animate attributeName="rx" values="18;24;18" dur="5s" repeatCount="indefinite"/></ellipse></g>`
601
602/** 夕: 沈みかけの橙の日と、水面に映る光 */
603const sunset = () =>
604 `<g id="sunset"><path d="M196 26 A11 11 0 0 1 218 26 Z" fill="#ffa552"/><path d="M190 26 H224" stroke="#ffcf8a" stroke-width="1" opacity=".7"/>` +
605 `<ellipse cx="207" cy="${TOP + 3}" rx="26" ry="2.6" fill="#ffb36b" opacity=".45"><animate attributeName="rx" values="22;30;22" dur="4s" repeatCount="indefinite"/></ellipse></g>`
606
607/** 春: 花びらが水面から舞い落ちる */
608const petals = () => {
609 const r = rng(3101)
610 let s = '<g id="petals">'
611 for (let i = 0; i < 10; i++) {
612 const x = f1(20 + r() * 320)
613 const dur = f1(9 + r() * 6)
614 const begin = f1(-r() * dur)
615 const end = f1(FLOOR - 6 - r() * 20)
616 const sway = f1(12 + r() * 16)
617 s +=
618 `<g><animateTransform attributeName="transform" type="translate" values="${x} ${TOP - 4};${f1(x + sway)} ${f1((TOP + end) / 2)};${f1(x - sway / 2)} ${end}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
619 `<ellipse rx="3.6" ry="2" fill="${i % 3 === 0 ? '#ffd6e0' : '#f9a8c0'}" opacity=".9"><animateTransform attributeName="transform" type="rotate" values="0;180;360" dur="${f1(dur / 3)}s" repeatCount="indefinite"/></ellipse></g>`
620 }
621 // 水面に浮かぶ花びら
622 for (const x of [96, 182, 268]) s += `<ellipse cx="${x}" cy="${TOP + 1}" rx="4" ry="1.6" fill="#ffd6e0" opacity=".85"><animate attributeName="cy" values="${TOP + 1};${TOP + 3};${TOP + 1}" dur="3s" repeatCount="indefinite"/></ellipse>`
623 return s + '</g>'
624}
625
626/** 夏: 水面のきらめき (泡は bubbles を増やす) */
627const glints = () => {
628 const r = rng(6203)
629 let s = '<g id="glints">'
630 for (let i = 0; i < 6; i++) {
631 const x = f1(30 + r() * 300)
632 const y = f1(TOP + 4 + r() * 16)
633 const d = f1(1.6 + r() * 1.6)
634 s += `<path d="M${x} ${f1(y - 3)} L${f1(x + 1)} ${y} L${x} ${f1(y + 3)} L${f1(x - 1)} ${y} Z" fill="#fffbe0" opacity="0"><animate attributeName="opacity" values="0;.9;0" dur="${d}s" begin="${f1(r() * d)}s" repeatCount="indefinite"/></path>`
635 }
636 return s + '</g>'
637}
638
639/** 秋: 落ち葉がゆっくり沈み、いくつかは砂の上に */
640const leaves = () => {
641 const r = rng(9307)
642 const colors = ['#d9822b', '#c2412d', '#e3b341', '#a0522d']
643 const leaf = (c: string) => `<path d="M-6 0 Q0 -5 6 0 Q0 5 -6 0 Z M-6 0 L-8 1" fill="${c}" stroke="${mix(c, '#0d1117', 0.4)}" stroke-width=".6"/>`
644 let s = '<g id="leaves">'
645 for (let i = 0; i < 7; i++) {
646 const x = f1(30 + r() * 300)
647 const dur = f1(14 + r() * 8)
648 const begin = f1(-r() * dur)
649 const end = f1(FLOOR - 4 - r() * 8)
650 s +=
651 `<g><animateTransform attributeName="transform" type="translate" values="${x} ${TOP};${f1(x + 14)} ${f1(TOP + (end - TOP) * 0.35)};${f1(x - 10)} ${f1(TOP + (end - TOP) * 0.7)};${x} ${end}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
652 `<g><animateTransform attributeName="transform" type="rotate" values="-30;30;-30" dur="${f1(3 + r() * 2)}s" repeatCount="indefinite"/>${leaf(colors[i % colors.length] ?? '#d9822b')}</g></g>`
653 }
654 for (const [x, a] of [[118, 20], [196, -15], [214, 40]] as const) s += `<g transform="translate(${x} ${FLOOR + 2}) rotate(${a})">${leaf('#a0522d')}</g>`
655 return s + '</g>'
656}
657
658/** 冬: 空から雪が降り、水に溶けていく。水面の近くは少し白む */
659const snow = () => {
660 const r = rng(1201)
661 let s = `<g id="snow"><rect x="0" y="${TOP}" width="${W}" height="24" fill="url(#frost)"/>`
662 for (let i = 0; i < 18; i++) {
663 const x = f1(10 + r() * 340)
664 const dur = f1(6 + r() * 6)
665 const begin = f1(-r() * dur)
666 const end = f1(TOP + 30 + r() * 150)
667 s +=
668 `<circle cx="${x}" cy="-4" r="${f1(1.1 + r() * 1.3)}" fill="#f0f6fc" opacity=".9">` +
669 `<animate attributeName="cy" values="-4;${end}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
670 `<animate attributeName="cx" values="${x};${f1(x + 7)};${f1(x - 5)};${x}" dur="${f1(dur / 2)}s" begin="${begin}s" repeatCount="indefinite"/>` +
671 `<animate attributeName="opacity" values=".9;.8;0" keyTimes="0;.7;1" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></circle>`
672 }
673 return s + '</g>'
674}
675
676type Mood = { isRunning: boolean; isUpset: boolean; isIdle: boolean; isHungry: boolean; isNight: boolean }
677
678/** 種類ごとの尾びれ・背びれ・模様 (左上 0,0 で右を向いた形) */
679const SHAPE: Record<KnownFishKind, { tail: string; dorsal: string; extra: (body: string) => string }> = {
680 maru: { tail: 'M-10 0 L-21 -8 Q-17 0 -21 8 Z', dorsal: 'M3 -8 Q-3 -15 -8 -7 Z', extra: () => '' },
681 shima: {
682 tail: 'M-10 0 L-21 -8 Q-17 0 -21 8 Z',
683 dorsal: 'M5 -8 Q-1 -16 -8 -7 Z',
684 extra: b => ['M8 -8 Q6 0 8 8', 'M2 -10 Q0 0 2 10', 'M-4 -9 Q-6 0 -4 9'].map(d => `<path d="${d}" stroke="${mix(b, '#0d1117', 0.55)}" stroke-width="2.4" fill="none" opacity=".7"/>`).join(''),
685 },
686 mizutama: {
687 tail: 'M-10 0 L-20 -9 Q-15 0 -20 9 Z',
688 dorsal: 'M3 -8 Q-3 -14 -8 -7 Z',
689 extra: () => [[3, -5], [-3, 2], [7, 3], [-1, 7], [-7, -3], [-6, 5]].map(([x, y]) => `<circle cx="${x}" cy="${y}" r="1.5" fill="#ffffff" opacity=".75"/>`).join(''),
690 },
691 hirenaga: {
692 tail: 'M-10 0 C-20 -6 -28 -16 -34 -14 Q-26 0 -34 14 C-28 16 -20 6 -10 0 Z',
693 dorsal: 'M8 -8 Q-2 -24 -12 -6 Z',
694 extra: b => `<path d="M2 7 Q-4 18 -10 16 L-6 6 Z" fill="${mix(b, '#ffffff', 0.2)}" opacity=".8"/>`,
695 },
696 ougi: {
697 tail: 'M-10 0 C-20 -18 -30 -14 -30 0 C-30 14 -20 18 -10 0 Z',
698 dorsal: 'M4 -8 Q-2 -17 -9 -7 Z',
699 extra: b => ['M-12 0 L-28 -10', 'M-12 0 L-30 0', 'M-12 0 L-28 10'].map(d => `<path d="${d}" stroke="${mix(b, '#ffffff', 0.4)}" stroke-width=".8" opacity=".7"/>`).join(''),
700 },
701 akari: {
702 tail: 'M-10 0 L-20 -7 Q-16 0 -20 7 Z',
703 dorsal: 'M2 -8 Q-3 -13 -8 -7 Z',
704 extra: () =>
705 `<path d="M11 -8 Q16 -20 24 -17" stroke="#8b949e" stroke-width="1.2" fill="none"/>` +
706 `<circle class="lure" cx="24" cy="-17" r="5" fill="#fff3a0" opacity=".35"><animate attributeName="opacity" values=".15;.55;.15" dur="2.2s" repeatCount="indefinite"/></circle>` +
707 `<circle cx="24" cy="-17" r="2.4" fill="#fff8c4"/>`,
708 },
709 // 体に小さな星が瞬く
710 hoshi: {
711 tail: 'M-10 0 L-22 -10 L-17 0 L-22 10 Z',
712 dorsal: 'M4 -8 L0 -16 L-3 -9 L-8 -13 L-8 -6 Z',
713 extra: () =>
714 ([[4, -3, 0], [-4, 3, 0.6], [8, 4, 1.2]] as const)
715 .map(
716 ([x, y, d]) =>
717 `<path d="M${x} ${y - 3} L${x + 0.9} ${y - 0.9} L${x + 3} ${y} L${x + 0.9} ${y + 0.9} L${x} ${y + 3} L${x - 0.9} ${y + 0.9} L${x - 3} ${y} L${x - 0.9} ${y - 0.9} Z" fill="#fffbe0">` +
718 `<animate attributeName="opacity" values=".35;1;.35" dur="1.8s" begin="${d}s" repeatCount="indefinite"/></path>`,
719 )
720 .join(''),
721 },
722 // 額の角と、とげの背びれ
723 tsuno: {
724 tail: 'M-10 0 L-20 -11 L-16 0 L-20 11 Z',
725 dorsal: 'M8 -8 L5 -15 L2 -9 L-1 -16 L-4 -9 L-7 -14 L-9 -6 Z',
726 extra: b => `<path d="M12 -6 L25 -13 L15 -2 Z" fill="${mix(b, '#ffffff', 0.55)}" stroke="${mix(b, '#0d1117', 0.3)}" stroke-width=".6"/>`,
727 },
728 // 赤・黄・青の筋が横に流れる
729 niji: {
730 tail: 'M-10 0 C-16 -10 -24 -12 -26 -10 Q-20 0 -26 10 C-24 12 -16 10 -10 0 Z',
731 dorsal: 'M5 -8 Q-2 -17 -9 -7 Z',
732 extra: () =>
733 [['#ff7b72', -4], ['#e3b341', 0], ['#79c0ff', 4]].map(([c, y]) => `<path d="M-10 ${y} Q2 ${Number(y) - 3} 14 ${y}" stroke="${c}" stroke-width="2" fill="none" opacity=".85"/>`).join(''),
734 },
735 // 一番珍しい: 長い赤いたてがみが揺れ、尾が細く長い
736 ryugu: {
737 tail: 'M-10 0 C-22 -3 -30 -2 -40 -6 C-32 0 -32 0 -40 6 C-30 2 -22 3 -10 0 Z',
738 dorsal: 'M10 -8 Q8 -12 4 -10 Z',
739 extra: () => {
740 const a = 'M10 -9 Q4 -24 -2 -12 Q-8 -22 -14 -9 Q-22 -18 -30 -5'
741 const z = 'M10 -9 Q2 -20 -2 -11 Q-10 -24 -14 -8 Q-20 -14 -30 -3'
742 return `<path d="${a}" stroke="#f85149" stroke-width="2.4" fill="none" stroke-linecap="round"><animate attributeName="d" values="${a};${z};${a}" dur="1.6s" repeatCount="indefinite"/></path>`
743 },
744 },
745}
746
747/** 魚の形 1 匹分。body は胴の色、fill は胴の塗り (グラデーションの url か色) */
748const fishShape = (f: AquaFish, body: string, fill: string, tailAnim: string, eye: string) => {
749 const s = (SHAPE as Partial<Record<FishKind, (typeof SHAPE)['maru']>>)[f.kind] ?? SHAPE.maru
750 return (
751 `<path d="${s.tail}" fill="${mix(body, '#0d1117', 0.25)}">${tailAnim}</path>` +
752 `<path d="M16 0 C10 -11 -6 -11 -12 0 C-6 11 10 11 16 0 Z" fill="${fill}"/>` +
753 s.extra(body) +
754 `<path d="${s.dorsal}" fill="${mix(body, '#0d1117', 0.2)}"/>` +
755 `<path d="M1 3 Q-3 9 -6 4 Z" fill="${mix(body, '#ffffff', 0.25)}" opacity=".8"/>` +
756 `<path d="M4 -7 Q1 0 4 7" stroke="#ffffff" stroke-width="1" fill="none" opacity=".3"/>` +
757 eye
758 )
759}
760
761const colorOf = (f: AquaFish) => FISH_COLORS[f.id % FISH_COLORS.length] ?? '#f78166'
762
763/** f は魚、i は並びの番号 (泳ぐ高さと速さの種、グラデーションの id)。home があれば、その x のかざりの周りを回る */
764const fish = (f: AquaFish, i: number, m: Mood, home?: number) => {
765 const r = rng(i * 7919 + 13)
766 r() // 0.2.x で大きさに使っていた 1 個を読み飛ばし、泳ぐ高さを前と同じにする
767 const color = colorOf(f)
768 const scale = STAGE_SCALE[stageOf(f.turnsSeen)]
769 const y = f1(TOP + 40 + r() * 150)
770 const x1 = f1(28 + r() * 40)
771 const x2 = f1(W - 28 - r() * 40)
772 const speed = m.isRunning ? 0.55 : m.isHungry ? 1.6 : 1
773 const dur = f1((16 + r() * 12) * speed)
774 const begin = f1(-r() * dur)
775 const tail = f1((m.isRunning ? 0.28 : m.isHungry ? 0.9 : 0.5) * (0.9 + r() * 0.3))
776 const body = m.isUpset ? '#f85149' : color
777 const id = `g${i}`
778
779 const drawing = fishShape(
780 f,
781 body,
782 `url(#${id})`,
783 `<animateTransform attributeName="transform" type="rotate" values="-16 -10 0;16 -10 0;-16 -10 0" dur="${m.isIdle ? 2.6 : tail}s" repeatCount="indefinite"/>`,
784 // 夜は目がほんのり光る
785 m.isNight
786 ? `<circle class="eye-glow" cx="9" cy="-2" r="5" fill="#fff3a0" opacity=".3"><animate attributeName="opacity" values=".15;.45;.15" dur="${f1(2.4 + (i % 3) * 0.5)}s" repeatCount="indefinite"/></circle>` +
787 `<circle cx="9" cy="-2" r="2.4" fill="#fff8c4"/><circle cx="9.7" cy="-2" r="1.2" fill="#0d1117"/>`
788 : `<circle cx="9" cy="-2" r="2.4" fill="#f0f6fc"/><circle cx="9.7" cy="-2" r="1.2" fill="#0d1117"/>`,
789 )
790
791 const grad = `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(body, '#ffffff', 0.3)}"/><stop offset=".6" stop-color="${body}"/><stop offset="1" stop-color="${mix(body, '#0d1117', 0.35)}"/></linearGradient>`
792
793 const shake = m.isUpset
794 ? `<animateTransform attributeName="transform" type="translate" values="0 0;-1.6 1;1.6 -1;-1 -1;0 0" dur=".22s" repeatCount="indefinite"/>`
795 : ''
796
797 if (m.isIdle) {
798 // 底でじっとする: ゆっくり上下するだけ
799 const x = f1(40 + ((i * 41 + r() * 30) % 280))
800 const yb = f1(FLOOR - 12 - r() * 10)
801 const face = i % 2 === 0 ? 1 : -1
802 return {
803 grad,
804 body:
805 `<g transform="translate(${x} ${yb})"><g><animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="${f1(4 + r() * 2)}s" begin="${begin}s" repeatCount="indefinite"/>` +
806 `<g transform="scale(${f1(scale * face)} ${scale})">${drawing}</g></g></g>`,
807 }
808 }
809
810 // かざりの周りは楕円で回る。左端から上を通って右へ (前半は右向き、後半は下を通って左向き。向きの返しと合う)
811 const cy = FLOOR - 46
812 const path =
813 home === undefined
814 ? `M${x1} ${y} C${f1(x1 + 60)} ${f1(y - 14)} ${f1(x2 - 60)} ${f1(y + 14)} ${x2} ${y} C${f1(x2 - 60)} ${f1(y - 12)} ${f1(x1 + 60)} ${f1(y + 12)} ${x1} ${y}`
815 : `M${home - 46} ${cy} A46 28 0 0 1 ${home + 46} ${cy} A46 28 0 0 1 ${home - 46} ${cy}`
816 return {
817 grad,
818 body:
819 `<g><animateMotion path="${path}" dur="${dur}s" begin="${begin}s" repeatCount="indefinite" calcMode="linear" keyPoints="0;0.5;1" keyTimes="0;0.5;1"/>` +
820 `<g>${shake}<g transform="scale(${scale})"><g><animateTransform attributeName="transform" type="scale" values="1 1;-1 1" keyTimes="0;0.5" calcMode="discrete" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/>` +
821 `${drawing}</g></g></g></g>`,
822 }
823}
824
825const zzz = () =>
826 [0, 1, 2]
827 .map(
828 k =>
829 `<text x="${120 + k * 8}" y="${FLOOR - 30}" font-size="${12 + k * 2}" fill="#8b949e" font-family="sans-serif" opacity="0">z` +
830 `<animate attributeName="y" values="${FLOOR - 30};${FLOOR - 70}" dur="4s" begin="${k * 1.3}s" repeatCount="indefinite"/>` +
831 `<animate attributeName="opacity" values="0;.8;0" dur="4s" begin="${k * 1.3}s" repeatCount="indefinite"/></text>`,
832 )
833 .join('')
834
835const murk = (level: number) => {
836 if (level <= 0) return ''
837 const r = rng(99)
838 let s = `<rect x="0" y="${TOP}" width="${W}" height="${H - TOP}" fill="#5b6a2e" opacity="${Math.min(0.5, level * 0.05).toFixed(2)}"/>`
839 for (let i = 0; i < level * 3; i++) {
840 const x = f1(r() * W)
841 const y = f1(TOP + 10 + r() * (FLOOR - TOP - 10))
842 const d = f1(6 + r() * 6)
843 s += `<circle cx="${x}" cy="${y}" r="${f1(0.8 + r() * 1.4)}" fill="#a3b18a" opacity=".45"><animate attributeName="cy" values="${y};${f1(y + 8)};${y}" dur="${d}s" repeatCount="indefinite"/></circle>`
844 }
845 return s
846}
847
848const hud = (t: AquaTank, v: AquaView) => {
849 const bar = 70
850 const fill = f1((bar * v.fullness) / 100)
851 const barColor = v.fullness < HUNGRY ? C.red : v.fullness < 60 ? C.yellow : C.green
852 const status = v.isUpset ? 'びっくり' : t.isRunning ? '作業中' : v.isIdle ? 'おやすみ' : '待機'
853 const statusColor = v.isUpset ? C.red : t.isRunning ? C.green : C.dim
854 return (
855 // 文字の地 (空の帯の上に、テーマの面の色で敷く)
856 `<rect x="6" y="5" width="${W - 12}" height="21" rx="8" fill="${C.face}" opacity=".88"/>` +
857 `<text x="14" y="20" font-size="12" fill="${C.dim}" font-family="sans-serif">満腹</text>` +
858 `<rect x="44" y="11" width="${bar}" height="10" rx="5" fill="${C.line}"/>` +
859 `<rect x="44" y="11" width="${fill}" height="10" rx="5" fill="${barColor}"/>` +
860 `<text x="${44 + bar + 6}" y="20" font-size="12" fill="${C.text}" font-family="sans-serif">${v.fullness}%</text>` +
861 `<text x="${W - 14}" y="20" font-size="12" fill="${statusColor}" font-family="sans-serif" text-anchor="end">${status} / ${v.season}の${v.period}</text>`
862 )
863}
864
865export const drawTank = (t: AquaTank): string => {
866 const v = t.view
867 const c = LIGHT[v.period]
868 const w = waterAt(v.slot, v.season)
869 const isNight = v.period === '夜'
870 const mood: Mood = {
871 isRunning: t.isRunning,
872 isUpset: v.isUpset,
873 isIdle: v.isIdle && !v.isUpset,
874 isHungry: v.fullness < HUNGRY,
875 isNight,
876 }
877 // 季節の飾り: 春は花びら、夏は泡を増やしてきらめき、秋は落ち葉、冬は雪
878 const deco = v.season === '春' ? petals() : v.season === '夏' ? glints() : v.season === '秋' ? leaves() : snow()
879 const extraBubbles = v.season === '夏' ? 8 : 0
880 // 育った魚を手前に (後に描く)。城・沈没船があれば、若い順に 1 匹ずつその周りを回る
881 const homes = homesOf(t.deco)
882 const fishes = shownFish(t)
883 .map((f, i) => ({ f, i }))
884 .sort((a, b) => a.f.turnsSeen - b.f.turnsSeen)
885 .map(({ f, i }, k) => fish(f, i, mood, homes[k]))
886 const surface0 = `M0 ${TOP} Q45 ${TOP - 3} 90 ${TOP} T180 ${TOP} T270 ${TOP} T360 ${TOP}`
887 const surface1 = `M0 ${TOP} Q45 ${TOP + 3} 90 ${TOP} T180 ${TOP} T270 ${TOP} T360 ${TOP}`
888
889 return (
890 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" font-family="sans-serif">` +
891 `<defs>` +
892 `<linearGradient id="water" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${w.top}"/><stop offset=".55" stop-color="${w.mid}"/><stop offset="1" stop-color="${w.bottom}"/></linearGradient>` +
893 (v.season === '冬' ? `<linearGradient id="frost" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6f0fa" stop-opacity=".22"/><stop offset="1" stop-color="#e6f0fa" stop-opacity="0"/></linearGradient>` : '') +
894 `<linearGradient id="ray" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c.ray}" stop-opacity=".9"/><stop offset="1" stop-color="${c.ray}" stop-opacity="0"/></linearGradient>` +
895 `<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c9a86a"/><stop offset="1" stop-color="#6b5330"/></linearGradient>` +
896 `<filter id="dull"><feColorMatrix type="saturate" values=".3"/></filter>` +
897 `<clipPath id="glass"><rect x="4" y="4" width="${W - 8}" height="${H - 8}" rx="12"/></clipPath>` +
898 fishes.map(f => f.grad).join('') +
899 `</defs>` +
900 `<rect width="${W}" height="${H}" rx="14" fill="${C.bg}"/>` +
901 `<g clip-path="url(#glass)">` +
902 `<rect x="0" y="0" width="${W}" height="${TOP}" fill="${c.sky}"/>` +
903 `<rect x="0" y="${TOP}" width="${W}" height="${H - TOP}" fill="url(#water)"/>` +
904 rays(c) +
905 sand(rng(31)) +
906 plants() +
907 decoSvg(t.deco, v) +
908 bubbles((t.isRunning ? 11 : 5) + extraBubbles, t.isRunning) +
909 deco +
910 (v.isFeeding ? pellets() : '') +
911 `<g${mood.isHungry && !mood.isUpset ? ' filter="url(#dull)" opacity=".85"' : ''}>${fishes.map(f => f.body).join('')}</g>` +
912 (mood.isIdle ? zzz() : '') +
913 murk(t.murk) +
914 `<rect x="0" y="${TOP}" width="${W}" height="${H - TOP}" fill="${c.tint}" opacity="${c.tintOp}"/>` +
915 `<path d="${surface0}" stroke="#e6f6ff" stroke-width="1.5" fill="none" opacity=".35"><animate attributeName="d" values="${surface0};${surface1};${surface0}" dur="3s" repeatCount="indefinite"/></path>` +
916 `<path d="M20 ${TOP + 14} L44 ${TOP + 14} L14 ${H - 20} L-10 ${H - 20} Z" fill="#ffffff" opacity=".04"/>` +
917 hud(t, v) +
918 (isNight ? moon() : v.period === '夕' ? sunset() : '') +
919 `</g>` +
920 `<rect x="2" y="2" width="${W - 4}" height="${H - 4}" rx="13" fill="none" stroke="${C.line}" stroke-width="3"/>` +
921 `</svg>`
922 )
923}
924
925// ---------------------------------------------------------------- 魚の一覧
926
927/** 日本時間の「10月20日」 */
928export const dayOf = (ms: number) => {
929 const d = new Date(ms + 9 * HOUR)
930 return `${d.getUTCMonth() + 1}月${d.getUTCDate()}日`
931}
932
933const stageColor = (s: FishStage) => (s === '稚魚' ? C.dim : s === '若魚' ? C.blue : s === '成魚' ? C.green : C.yellow)
934
935/** 魚の一覧の 1 行ぶんの言葉 (terminal と SVG で同じ物を使う) */
936export const fishLine = (f: AquaFish) => ({
937 title: `${(KIND_NAME as Partial<Record<FishKind, string>>)[f.kind] ?? 'なぞの魚'}${f.name ? ` ${f.name}` : ''}`,
938 stage: stageOf(f.turnsSeen),
939 came: `${dayOf(f.bornAt)}に来た / ${f.turnsSeen}ターン`,
940})
941
942/** 魚の一覧。種類・段階・来た日を、小さな絵と並べる */
943export const fishListSvg = (fish: readonly AquaFish[]): string => {
944 const ROW = 50
945 const top = 52
946 const h = top + fish.length * ROW + 8
947 const rows = fish
948 .map((f, k) => {
949 const y = top + k * ROW
950 const l = fishLine(f)
951 const s = Math.min(1, STAGE_SCALE[l.stage])
952 const color = colorOf(f)
953 const eye = `<circle cx="9" cy="-2" r="2.4" fill="#f0f6fc"/><circle cx="9.7" cy="-2" r="1.2" fill="#0d1117"/>`
954 return (
955 panel(10, y, 360, ROW - 6) +
956 `<g transform="translate(46 ${y + 24}) scale(${s})">${fishShape(f, color, color, '', eye)}</g>` +
957 `<text x="84" y="${y + 19}" font-size="14" font-weight="bold" fill="${C.text}">${esc(l.title)}</text>` +
958 `<text x="358" y="${y + 19}" font-size="13" font-weight="bold" fill="${stageColor(l.stage)}" text-anchor="end">${l.stage}</text>` +
959 `<text x="84" y="${y + 36}" font-size="12" fill="${C.dim}">${esc(l.came)}</text>`
960 )
961 })
962 .join('')
963 return frame(h, header('魚の一覧', `${fish.length} 匹`, C.blue) + rows)
964}
965hooks/style.ts 315 lines1// 全 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
315types/index.d.ts 89 lines1export type AquaPeriod = '朝' | '昼' | '夕' | '夜'
2export type AquaSeason = '春' | '夏' | '秋' | '冬'
3
4// 時刻から決まる「段」。60 秒ごとに計算し、変わった時だけ書く(描き直しを減らす。時刻は 15 分刻み)
5export type AquaView = {
6 period: AquaPeriod
7 season: AquaSeason
8 /** 日本時間の 15 分刻みの番号 0-95。水の色の段階に使う (15 分ごとに描き直す) */
9 slot: number
10 /** 満腹度 0-100、5 刻み */
11 fullness: number
12 /** 待機が 10 分を超えた */
13 isIdle: boolean
14 isUpset: boolean
15 isFeeding: boolean
16 /** 育てた日数 (1 日目から) */
17 days: number
18}
19
20/** この版の知っている魚の種類。並びは珍しさの順 (後ろほど珍しい) */
21export type KnownFishKind = 'maru' | 'shima' | 'mizutama' | 'hirenaga' | 'ougi' | 'akari' | 'hoshi' | 'tsuno' | 'niji' | 'ryugu'
22/** 魚の種類。新しい版が足した種類 (この版の知らない文字列) も消さずに持ち回る */
23export type FishKind = KnownFishKind | (string & {})
24/** 育ちの段階 */
25export type FishStage = '稚魚' | '若魚' | '成魚' | '大物'
26
27/** 水槽の魚 1 匹 ($.store に残す) */
28export type AquaFish = {
29 /** 来た順の番号 (0 から) */
30 id: number
31 kind: FishKind
32 /** 来た時刻 (ms) */
33 bornAt: number
34 /** 来てから数えたターン */
35 turnsSeen: number
36 name?: string
37}
38
39/** この版の知っているかざり。turn-gacha で持っているカードのレア度と種類の数で手に入る */
40export type KnownDecoId = 'kusa' | 'ishi' | 'ryuboku' | 'kai' | 'tsubo' | 'shiro' | 'takara' | 'chinbotsu'
41/** かざりの id。'' は空き。新しい版が足したかざり (この版の知らない文字列) も消さずに持ち回る */
42export type DecoId = KnownDecoId | (string & {})
43
44export type AquaTank = {
45 // $.store に残す値
46 turns: number
47 bornAt: number
48 /** fedAt 時点の満腹度 */
49 fullness: number
50 fedAt: number
51 /** 水の濁り 0-10 (エラーで増え、掃除で 0) */
52 murk: number
53 name: string
54 /** 魚。空なら turns から決まる数を描く (古い記録・見本の絵) */
55 fish: AquaFish[]
56 /** 底の置き場 (5 つ、左から)。'' は空き。新しい版が置き場を増やした分も消さずに持ち回る */
57 deco: DecoId[]
58 // セッションの間だけの値
59 /** かざりを選ぶ欄を出している */
60 isDecorating: boolean
61 /** かざりの欄で選んでいる置き場 (0 から) */
62 pickSlot: number
63 /** 控えから戻す欄。null は閉じている。days は控えのある日 (新しい順)、pick は選んだ日 ('' はまだ) */
64 restore: { days: string[]; pick: string } | null
65 isRunning: boolean
66 lastActiveAt: number
67 upsetUntil: number
68 feedUntil: number
69 isNaming: boolean
70 /** 魚の一覧を出している */
71 isListing: boolean
72 view: AquaView
73}
74
75declare module 'claude-code' {
76 interface PluginState {
77 'aqua-pet': { tank: AquaTank }
78 /**
79 * turn-gacha が他の mod に見せている物のうち、aqua-pet が読む分だけ (書くのは turn-gacha)。
80 * turn-gacha の types/index.d.ts の写し。plugin.json の dependencies は使わない (無くても水槽は動く)。
81 * 入っていない時は undefined なので、読んだ値は tank.ts の gachaCounts で確かめる
82 */
83 'turn-gacha': {
84 save: { book: Record<string, { count: number; firstAt: number }> }
85 cards: { id: string; rarity: 'N' | 'R' | 'SR' | 'SSR'; slot: string; name: string }[]
86 }
87 }
88}
89