SLOPSHOPPER

aqua-pet

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

newpaneguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aqua-pet
│ ┃ 水槽 ✕ › fix the failing auth test and add an audit log call │ ┃ 魚 1 匹 / 満腹度 70% / びっくり │ ┃ 育てた日数 1日 / 累計 1ターン / 濁り 1 / ⏺ Read(src/auth.ts) │ ┃ 秋の夕 ⎿ Read 6 lines │ ┃ [ えさをやる ] [ 名前 ] [ 掃除 ] [ 一覧 ] [ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /aquarium │ ⎿ aqua-pet: 水槽を開きました。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 水槽
魚 1 匹 / 満腹度 70% / びっくり 育てた日数 1日 / 累計 1ターン / 濁り 1 / 秋の夕 [ えさをやる ] [ 名前 ] [ 掃除 ] [ 一覧 ] [ かざり ] [ 控えか
README

claude-code-mods

English

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

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

入っている mod

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

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

画面

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

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

動く絵

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

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

明るいテーマ

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

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

動作環境

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

入れ方

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

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

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

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

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

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

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

まとめて入れる

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

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

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

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

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

PowerShell:

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

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

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

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

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

気を付けること

  • rule-switch の約束ごとは、サブエージェントのシステムプロンプトにも入ります。また、組織が Claude Code を管理している環境では、利用者が入れたプラグインのシステムプロンプトのフックは動きません
  • rule-switch を切り替えると、プロンプトのキャッシュが作り直されます
  • canvas-pane のツールに渡された生の SVG は、スクリプト・イベント属性・外部の参照を含む物を断ります(ペインでは画像として描かれ、スクリプトは動きません)
  • turn-haiku は、答えて終わったターンごと(ツールを使ったか 20 秒以上かかった時)に Haiku のモデルを 1 回呼び、トークンを使います。/haiku の「自動で詠む」で止められます
  • port-watch の「止める」は子プロセスごと止めます(Windows は taskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認です
  • pre-check の秘密情報の検出は手がかり程度です。見つからなくても安全とは限りません
  • done-chime は Windows では PowerShell の SoundPlayer で鳴らします。それ以外は Claude Code の音の再生を使い、Linux のターミナルでは鳴らないことがあります
  • spec-board の一覧は SpecStatus が GitHub の API を使うため、回数の上限に当たることがあります。読み直しは 5 分ごとです
  • ci-pane と git を読む mod は、所有者の違うフォルダ(別ドライブなど)では git が止まります。git config --global --add safe.directory <フォルダ> を足してください
  • aqua-pet の背景は 15 分ごとに描き直します。描き直すと泳ぎや泡のアニメが頭から始まります
  • aqua-pet 0.2 の記録は、開いた時に今の匹数の魚を作って引き継ぎます。0.2 と 0.3 のセッションを同時に開いていると、魚の来た日が作り直されることがあります
  • dev-rpg の装備は turn-gacha 0.2.0 以降と一緒に入れた時だけ使えます。ガチャの図鑑から消えたカードは外れた扱いになりますが、記録は残ります
  • toast-inbox が拾うのは、ほかの mod が $.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消します
  • usage-history は入れた後のターンから数えます。日付・曜日・時刻は日本時間です
  • file-map は失敗した呼び出しを数えません。Grep と Glob は path を渡した時だけ数えます
  • 明るいテーマでも、水槽・俳句の短冊・おみくじの紙・実況のスタジアム・RPG の窓は絵の色のままです
  • mod の版を上げたら、開いているほかのセッションも開き直してください。0.7.0 から記録は知らない欄を残して書くので、新しい版の記録は消えなくなりましたが、0.6 以前の mod を読んだまま動いているセッションは、新しい版で増えた欄(魚の種類・装備・SP の割り振りなど)を消して書き戻します
  • fail-log の記録は、同時に開いた全セッションで共有します。「今日」は日本時間の日付で、残している 200 件から数えます
  • todo-board は git の中では git grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してください
  • daily-quest のお題は日本時間の日付だけで決まります。数えるのはメインのターンだけで、「テスト」はコマンドの名前の形(npm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取ります
  • trophy-shelf はほかの mod の記録を読むだけです。入っていない mod の棚は「入っていません」と出ます。一度取ったトロフィーは、その mod を外しても残ります
  • cc-dash の「自分の並び」は記録に残るので、全セッションで共有されます
  • mod-check が版と「読み込みを拒まれた」を出せるのは、mod-check より後に読み込まれた mod だけです。「state まだ」は壊れているという意味ではありません(開
Source 4 files
hooks/register.tsx 404 lines
1import { 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}
404
hooks/tank.ts 965 lines
1import 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}
965
hooks/style.ts 315 lines
1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7  bg: string
8  face: string
9  line: string
10  text: string
11  dim: string
12  weak: string
13  blue: string
14  green: string
15  yellow: string
16  red: string
17  purple: string
18  orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23  bg: '#0d1117',
24  face: '#161b22',
25  line: '#30363d',
26  text: '#c9d1d9',
27  dim: '#8b949e',
28  weak: '#8b949e',
29  blue: '#58a6ff',
30  green: '#3fb950',
31  yellow: '#d29922',
32  red: '#f85149',
33  purple: '#bc8cff',
34  orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39  bg: '#ffffff',
40  face: '#f6f8fa',
41  line: '#d0d7de',
42  text: '#1f2328',
43  dim: '#656d76',
44  weak: '#656d76',
45  blue: '#0969da',
46  green: '#1a7f37',
47  yellow: '#9a6700',
48  red: '#cf222e',
49  purple: '#8250df',
50  orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65  isLightNow = light
66  Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75  const rows = await $.config.list().catch(() => [])
76  setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81  on('config.set', { key: 'theme' }, async ($, e, next) => {
82    const r = await next(e)
83    if (r.deny === undefined) {
84      setTheme(isLightTheme(r.value))
85      $.ui.invalidate('ui.render')
86    }
87    return r
88  })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98  clean(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109  const h = jstDate(now).getUTCHours()
110  return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115  const m = jstDate(now).getUTCMonth() + 1
116  return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137  if (!dir.trim() || value === undefined) return false
138  const day = jstDay(now)
139  const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140  // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141  const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142  if (old === day) return false
143  await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144  return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149  try {
150    const b = JSON.parse(text) as Partial<Backup> | null
151    return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152  } catch {
153    return undefined
154  }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159  if (!dir.trim()) return []
160  const all = await Promise.all(
161    [0, 1, 2, 3, 4, 5, 6].map(wd =>
162      $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163    ),
164  )
165  return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177  let h = 0x811c9dc5
178  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179  return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187  const root = $.plugin.root
188  let manifest: string | undefined
189  for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190    manifest = await $.fs.read(p).then(t => t, () => undefined)
191    if (manifest !== undefined) break
192  }
193  if (manifest === undefined) return undefined
194  const names = await $.fs.list(`${root}/hooks`).then(
195    es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196    () => [] as string[],
197  )
198  const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199  return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207  const root = normPath($.plugin.root)
208  const at = root.lastIndexOf('/plugins/cache/')
209  if (at < 0) return false
210  const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211  try {
212    const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213    const mine = Object.entries(plugins)
214      .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215      .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216      .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217    return mine.length > 0 && !mine.includes(root)
218  } catch {
219    return false
220  }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225  loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234  if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235  staleAt = now
236  if (await replacedInCache($)) return (isStaleNow = true)
237  if (loadedPrint === undefined) return false
238  const p = await diskPrint($)
239  isStaleNow = p !== undefined && p !== loadedPrint
240  return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248  if (!(await isStale($, now))) return true
249  if (!toldStale) {
250    toldStale = true
251    $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252  }
253  return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264  `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268  `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275  const pad = 14
276  return [
277    `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278    `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279    `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280    sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281    growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282  ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287  `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288  `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292  `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293  `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294  `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298  `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305  const STEPS = 8
306  const d = 0.08
307  const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308  if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309  const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310    const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311    return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312  })
313  return frames.join('') + last
314}
315
types/index.d.ts 89 lines
1export 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