diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index a18517f..3bbdf03 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -121,3 +121,128 @@ - Removed the remaining synchronous family-tree DFS fallback blocks in `ui_family_data.js` and `ui_family_render.js`; both now use `TarinaiFamilyGraph.connectedComponents()`. - Simplified the remaining manual ant proximity fallback in `tarinai_needs_items.js` to `world.nearbyAnts()`. - Verification: all `js/*.js` and `service-worker.js` pass `node --check`. + +## 15.25.0 save/load UI and pin UI fix +- Fixed `Item.draw()` crash caused by an undefined `isOshibyo` local in the pin drawing branch. +- Reworked the save dialog into a 3x3 slot grid and added per-slot canvas thumbnails. +- Added compressed `TARINAI_SAVE_V2:` hash export/import using browser gzip streams when available; legacy `TARINAI_SAVE_V1:` imports remain supported. +- Replaced browser confirm prompts in save/load/import/delete flows with in-game save dialog confirmation UI. +- Added missing tool UI icons for `pushpin` and `oshibyo` and updated their tooltips. +- Renamed tool categories: 操作, 飲食, 薬, 生活, 危険物. Moved freeze to 操作 and fences to 生活. +- Made the frozen individual panel initially collapsed. +- Made colony mood span the full colony UI width with a compact height. +- Moved push notification cards inside the top of the Event UI. +- Bumped static cache version to `15.25.0`. + +## v15.26.0 save/sunbath/signboard/freeze UI patch +- ずんちどれいの食対象を、ずんだ餅・草・水系を除く食べ物全般へ拡張。 +- 看板を大型化し、観察/看板ツールでクリック編集、配置直後編集に対応。 +- 日光浴の開始処理が呼ばれていなかった問題を修正し、発生確率と継続時間を大幅に増加。 +- フィールド内の非地形オブジェクト・たりない・アリをY座標で統合ソートし、下側が前に出る描画順へ調整。 +- 冷凍ボタンを操作カテゴリから廃止し、冷凍個体一覧を操作カテゴリ内部へ移動。 +- セーブスロットを小型化し、サムネイルをスロット全面背景として表示。 +- ハッシュ書き出しをV3コンパクト形式へ変更。イベントログ、エフェクト、吹き出し等の非本体情報は保存対象外。 +- α版のため、セーブ/ハッシュ互換性は当面保証しない旨をコードコメントに明記。 + +## v15.27.0 hash V4 / layer / warning patch +- ハッシュ書き出しを `TARINAI_SAVE_V4:` に更新。V3の密な配列形式から、短い疎配列形式へ変更。 +- V4ではゲームプレイ本体に必要な world / tarinai / item / ant / frozen 状態のみを保存し、ログ、イベント表示、エフェクト、吹き出し、サムネイル等は保存対象外。 +- アイテム種別・スプライト種別は固定辞書の数値コードで保存し、初期状態で全アイテムを1つずつ置いたケースでも短くなるようにした。 +- アイテムは `type,x,y` をヘッダ化し、その他はデフォルト値から変化した値だけを保存する方式へ変更。 +- 干草寝床の描画レイヤーを同じY座標の他オブジェクトより少し後ろへ調整。 +- ロード/インポート/削除の上書き警告を中央の大きなゲーム内UIとして表示。 +- キャッシュ対策としてバージョンを `15.27.0` へ更新。 + +## v15.28.0 machine / pin placement patch +- おしり鋲を約半分の表示サイズに縮小し、刺さり位置をたりないスプライト右側へ移動。 +- 画鋲/おしり鋲の刺さり表示は、たりないのスプライト反転に合わせて左右反転するよう調整。 +- 新アイテム「複製機」を追加。消費アイテムツールで複製機をクリックするとスロットにセットし、下側の皿へ無限補充する。 +- 新アイテム「大砲」を追加。消費アイテムツールで大砲をクリックするとスロットにセットし、クリック位置で360度向きを変更し、セット品を撃ち出す。 +- 大砲弾として飛んだ消費アイテムは、高速時にたりないへ当たるとボール同様に吹き飛ばす。 +- 複製機/大砲の道具UIアイコンを追加。 +- セーブV4のアイテム種別表・追加キーへ複製機/大砲および機械スロット情報を追加。 +- キャッシュ対策としてバージョンを `15.28.0` へ更新。 + +## v15.29.0 asset / save compression patch +- 全たりないスプライトを 512x468 以下に収まるよう縮小し、WebP を再圧縮。 +- `ALPHA_BOUNDS` を現行画像寸法と透過領域から再生成。 +- 音声サンプルを軽量 MP3 に置き換え、WAV を配布対象から除外。 +- セーブハッシュ内の座標系数値は小数点第一位までに丸めるよう変更。 +- コメントは削除せず、JS minify は行っていない。 +- キャッシュ対策としてバージョンを `15.29.0` へ更新。 + +## v15.30.0 save hash v5 compact patch +- ハッシュ書き出しを `TARINAI_SAVE_V5:` に更新。 +- V5はトップレベルを短い配列形式にし、world / たりない / item / ant / 冷凍個体をゲームプレイに必要な値だけで再パック。 +- たりないの性格・遺伝・関係性・効果タイマー・状態/欲求/アイテム種別を短い配列または数値コード化。 +- 初期値・決定論的に再生成できる値・UI/演出専用値はハッシュ保存対象から除外。 +- 座標は引き続き小数点第一位まで保存。 +- V4/V3/V2/V1の読み込み互換は一応残すが、α版のため今後の互換性保証はしない。 +- キャッシュ対策としてバージョンを `15.30.0` へ更新。 + +## 15.31.0 - Hash V6 compaction + +- Added `TARINAI_SAVE_V6:` export format. +- Further shortened hash text while preserving gameplay state. +- Removed individual observation history/name fields from hash exports: + - names + - former names + - parent display names + - personality daily history + - mystery drug history + - recent change records + - per-individual observation records +- Replaced long family keys in hash payload with compact numeric family references. +- Encoded live IDs like `t12` as numeric IDs in hash payload and restored them on load. +- Packed tarinai core gameplay stats into fixed compact rows. +- Kept gameplay-critical data: + - needs/status + - position and movement-relevant values + - personality and genetics + - parent/child links + - relationships + - diseases + - zunchi slave state + - item effects + - oshibyo/zunchi stock + - sunbath/fear/fall gameplay timers + - items, machines, ants, frozen tarinai +- Flattened item base records remains active from V6 export. +- Hash remains ASCII base64url + gzip for copy/paste and editor durability. + +## 15.32.0 - Hash V7 base91/name preservation + +- Added `TARINAI_SAVE_V7:` hash export. +- V7 uses gzip plus a 91-character printable ASCII transport alphabet instead of base64url. +- V7 keeps individual names in compact hash exports. +- V7 excludes dead tarinai rows from compact hash exports so family graph history does not revive/save dead individuals. +- V7 filters parent/child and relationship links to living or frozen individuals; pending frozen death notices remain gameplay data. +- V7 stores selected numeric tarinai stats as relative deltas from common defaults while preserving the existing compact numeric precision. +- V1-V6 import support remains. + +## 15.33.0 - Hash V8 current-only compact save +- Hash exports now use `TARINAI_SAVE_V8:` only. Old hash import compatibility was intentionally removed for the alpha save format. +- Added an untouched-initial-colony sentinel. Initial colonies now export as a compact reset marker plus names instead of full entity/item rows, targeting roughly 100 characters for the initial state depending on generated names. +- Frozen individual save data no longer preserves pending death notices or relationship/memory records. +- Dead family nodes remain omitted from compact hash saves. +- Individual names are preserved in V8 compact saves. +- V8 keeps printable ASCII transport using the 91-character alphabet, with an explicit compressed/uncompressed mode marker. + + +## 15.34.0 - Hash V9 binary compact format + +- Removed the V8 initial-state-only save sentinel. All states now use the same compact format. +- Export hash format is now `TARINAI_SAVE_V9:` and legacy hash imports are rejected during alpha. +- Replaced JSON-based compact hashes with a dedicated binary packet plus printable ASCII Base94 transport. +- Stopped saving elapsed day count in compact hash; loaded compact hashes restore day as 1. +- Kept custom individual names, while default four-character names made only from `は / う / ぅ / っ` are packed as 2-bit character codes. +- Replaced saved tarinai/item IDs and family keys with array-index references; IDs are regenerated on load. +- Uses default-relative numeric deltas, optional timer tags, boolean bit fields, and 0.1-unit integer coordinates. +- Keeps gameplay-critical tarinai needs, personality, genetics, family links, relationships, item states, machines, ants, and frozen individuals; frozen pending-loss memory remains omitted. + +## 15.35.0 - Hash V10 compact packet +- Changed exported hash prefix to `TARINAI-SAVE-V10:` so the full hash text no longer uses `_` in its version marker. +- Excluded `\\`, `*`, `'`, and `_` from the printable ASCII transport alphabet. +- Replaced the V9 generic array/value packet with a V10 field-specific binary packet for common gameplay data. +- Kept gameplay-critical state while reducing overhead for names, world data, tarinai core rows, item rows, ants, and frozen individuals. +- Kept elapsed day count out of the hash; loads restore to day 1. diff --git a/app_manifest.json b/app_manifest.json index fdd5ec8..d46ce4a 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "15.24.0", + "version": "15.35.0", "css": [ "css/base.css", "css/layout.css", @@ -20,6 +20,7 @@ "js/food_registry.js", "js/input_mode_manager.js", "js/math.js", + "js/need_behavior_system.js", "js/physics_helpers.js", "js/assets.js", "js/audio.js", @@ -74,7 +75,6 @@ "assets/ui/*.webp", "assets/objects/*.webp", "assets/sprites/*.webp", - "assets/sounds/*.wav", "assets/sounds/*.mp3" ], "notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list." diff --git a/assets/sounds/firecracker_explosion.mp3 b/assets/sounds/firecracker_explosion.mp3 index 9e23327..4637d8c 100644 Binary files a/assets/sounds/firecracker_explosion.mp3 and b/assets/sounds/firecracker_explosion.mp3 differ diff --git a/assets/sounds/voice_001_hau.mp3 b/assets/sounds/voice_001_hau.mp3 new file mode 100644 index 0000000..0d09a25 Binary files /dev/null and b/assets/sounds/voice_001_hau.mp3 differ diff --git a/assets/sounds/voice_002_flee.mp3 b/assets/sounds/voice_002_flee.mp3 new file mode 100644 index 0000000..22d3e76 Binary files /dev/null and b/assets/sounds/voice_002_flee.mp3 differ diff --git a/assets/sounds/voice_003_po.mp3 b/assets/sounds/voice_003_po.mp3 new file mode 100644 index 0000000..fa22f4a Binary files /dev/null and b/assets/sounds/voice_003_po.mp3 differ diff --git a/assets/sounds/voice_004_eat.mp3 b/assets/sounds/voice_004_eat.mp3 new file mode 100644 index 0000000..5104258 Binary files /dev/null and b/assets/sounds/voice_004_eat.mp3 differ diff --git a/assets/sounds/voice_005_stress.mp3 b/assets/sounds/voice_005_stress.mp3 new file mode 100644 index 0000000..f11ed1c Binary files /dev/null and b/assets/sounds/voice_005_stress.mp3 differ diff --git a/assets/sounds/voice_006_sleep.mp3 b/assets/sounds/voice_006_sleep.mp3 new file mode 100644 index 0000000..a594c7c Binary files /dev/null and b/assets/sounds/voice_006_sleep.mp3 differ diff --git a/assets/sprites/tarinai_01_smile.webp b/assets/sprites/tarinai_01_smile.webp index bcaab40..34aa6f8 100644 Binary files a/assets/sprites/tarinai_01_smile.webp and b/assets/sprites/tarinai_01_smile.webp differ diff --git a/assets/sprites/tarinai_02_angry.webp b/assets/sprites/tarinai_02_angry.webp index 643c5ad..1ee98e4 100644 Binary files a/assets/sprites/tarinai_02_angry.webp and b/assets/sprites/tarinai_02_angry.webp differ diff --git a/assets/sprites/tarinai_03_teary.webp b/assets/sprites/tarinai_03_teary.webp index 85ecf3b..5b65d0e 100644 Binary files a/assets/sprites/tarinai_03_teary.webp and b/assets/sprites/tarinai_03_teary.webp differ diff --git a/assets/sprites/tarinai_04_jito.webp b/assets/sprites/tarinai_04_jito.webp index 068dd35..71ba6e6 100644 Binary files a/assets/sprites/tarinai_04_jito.webp and b/assets/sprites/tarinai_04_jito.webp differ diff --git a/assets/sprites/tarinai_05_drool.webp b/assets/sprites/tarinai_05_drool.webp index b0cca44..48fe575 100644 Binary files a/assets/sprites/tarinai_05_drool.webp and b/assets/sprites/tarinai_05_drool.webp differ diff --git a/assets/sprites/tarinai_06_cry.webp b/assets/sprites/tarinai_06_cry.webp index a823445..c99d104 100644 Binary files a/assets/sprites/tarinai_06_cry.webp and b/assets/sprites/tarinai_06_cry.webp differ diff --git a/assets/sprites/tarinai_07_sleep.webp b/assets/sprites/tarinai_07_sleep.webp index c378529..244a9b1 100644 Binary files a/assets/sprites/tarinai_07_sleep.webp and b/assets/sprites/tarinai_07_sleep.webp differ diff --git a/assets/sprites/tarinai_08_pokan.webp b/assets/sprites/tarinai_08_pokan.webp index e1e6b2a..32debce 100644 Binary files a/assets/sprites/tarinai_08_pokan.webp and b/assets/sprites/tarinai_08_pokan.webp differ diff --git a/assets/sprites/tarinai_09_stretch.webp b/assets/sprites/tarinai_09_stretch.webp index c8a245b..e1920ae 100644 Binary files a/assets/sprites/tarinai_09_stretch.webp and b/assets/sprites/tarinai_09_stretch.webp differ diff --git a/assets/sprites/tarinai_10_hurt.webp b/assets/sprites/tarinai_10_hurt.webp index e53c759..38aeeff 100644 Binary files a/assets/sprites/tarinai_10_hurt.webp and b/assets/sprites/tarinai_10_hurt.webp differ diff --git a/assets/sprites/tarinai_11_weak.webp b/assets/sprites/tarinai_11_weak.webp index 4a36e52..7e8e906 100644 Binary files a/assets/sprites/tarinai_11_weak.webp and b/assets/sprites/tarinai_11_weak.webp differ diff --git a/assets/sprites/tarinai_12_back.webp b/assets/sprites/tarinai_12_back.webp index 3a60bfb..59b4913 100644 Binary files a/assets/sprites/tarinai_12_back.webp and b/assets/sprites/tarinai_12_back.webp differ diff --git a/assets/sprites/tarinai_13_flee.webp b/assets/sprites/tarinai_13_flee.webp index 047449c..aee8512 100644 Binary files a/assets/sprites/tarinai_13_flee.webp and b/assets/sprites/tarinai_13_flee.webp differ diff --git a/assets/sprites/tarinai_14_zunda_eat.webp b/assets/sprites/tarinai_14_zunda_eat.webp index 06c956b..b42cb65 100644 Binary files a/assets/sprites/tarinai_14_zunda_eat.webp and b/assets/sprites/tarinai_14_zunda_eat.webp differ diff --git a/assets/sprites/tarinai_15_hurt2.webp b/assets/sprites/tarinai_15_hurt2.webp index a79cf25..261edb7 100644 Binary files a/assets/sprites/tarinai_15_hurt2.webp and b/assets/sprites/tarinai_15_hurt2.webp differ diff --git a/assets/sprites/tarinai_16_normal_smirk.webp b/assets/sprites/tarinai_16_normal_smirk.webp index 4029f70..1809751 100644 Binary files a/assets/sprites/tarinai_16_normal_smirk.webp and b/assets/sprites/tarinai_16_normal_smirk.webp differ diff --git a/assets/sprites/tarinai_17_normal_tongue.webp b/assets/sprites/tarinai_17_normal_tongue.webp index 8623cec..09acce1 100644 Binary files a/assets/sprites/tarinai_17_normal_tongue.webp and b/assets/sprites/tarinai_17_normal_tongue.webp differ diff --git a/assets/sprites/tarinai_18_fear.webp b/assets/sprites/tarinai_18_fear.webp index 245190b..81e0b15 100644 Binary files a/assets/sprites/tarinai_18_fear.webp and b/assets/sprites/tarinai_18_fear.webp differ diff --git a/assets/sprites/tarinai_19_flee_fear2.webp b/assets/sprites/tarinai_19_flee_fear2.webp index f10ac23..cd9185d 100644 Binary files a/assets/sprites/tarinai_19_flee_fear2.webp and b/assets/sprites/tarinai_19_flee_fear2.webp differ diff --git a/assets/sprites/tarinai_20_sleep2.webp b/assets/sprites/tarinai_20_sleep2.webp index 63b558f..c5da8b3 100644 Binary files a/assets/sprites/tarinai_20_sleep2.webp and b/assets/sprites/tarinai_20_sleep2.webp differ diff --git a/assets/sprites/tarinai_21_normal_happy.webp b/assets/sprites/tarinai_21_normal_happy.webp index 2d1df1f..b7a724c 100644 Binary files a/assets/sprites/tarinai_21_normal_happy.webp and b/assets/sprites/tarinai_21_normal_happy.webp differ diff --git a/assets/sprites/tarinai_22_fear_blue.webp b/assets/sprites/tarinai_22_fear_blue.webp index 99eb408..7c35c7d 100644 Binary files a/assets/sprites/tarinai_22_fear_blue.webp and b/assets/sprites/tarinai_22_fear_blue.webp differ diff --git a/assets/sprites/tarinai_23_fear_cry.webp b/assets/sprites/tarinai_23_fear_cry.webp index cd7687a..f59bdab 100644 Binary files a/assets/sprites/tarinai_23_fear_cry.webp and b/assets/sprites/tarinai_23_fear_cry.webp differ diff --git a/assets/sprites/tarinai_24_stress_dizzy.webp b/assets/sprites/tarinai_24_stress_dizzy.webp index b0367ed..65a477e 100644 Binary files a/assets/sprites/tarinai_24_stress_dizzy.webp and b/assets/sprites/tarinai_24_stress_dizzy.webp differ diff --git a/assets/sprites/tarinai_25_stress_sweat.webp b/assets/sprites/tarinai_25_stress_sweat.webp index 1a537e1..763d8c2 100644 Binary files a/assets/sprites/tarinai_25_stress_sweat.webp and b/assets/sprites/tarinai_25_stress_sweat.webp differ diff --git a/assets/sprites/tarinai_26_intimidate.webp b/assets/sprites/tarinai_26_intimidate.webp index 4575fb1..efa6be7 100644 Binary files a/assets/sprites/tarinai_26_intimidate.webp and b/assets/sprites/tarinai_26_intimidate.webp differ diff --git a/assets/sprites/tarinai_27_birth_ritual.webp b/assets/sprites/tarinai_27_birth_ritual.webp index 1a75b86..d166672 100644 Binary files a/assets/sprites/tarinai_27_birth_ritual.webp and b/assets/sprites/tarinai_27_birth_ritual.webp differ diff --git a/assets/sprites/tarinai_28_zunchi_slave.webp b/assets/sprites/tarinai_28_zunchi_slave.webp index 8a4c59a..2fcf377 100644 Binary files a/assets/sprites/tarinai_28_zunchi_slave.webp and b/assets/sprites/tarinai_28_zunchi_slave.webp differ diff --git a/assets/sprites/tarinai_29_hungry_70.webp b/assets/sprites/tarinai_29_hungry_70.webp index becfb9f..85d93d9 100644 Binary files a/assets/sprites/tarinai_29_hungry_70.webp and b/assets/sprites/tarinai_29_hungry_70.webp differ diff --git a/assets/sprites/tarinai_30_zunchi_slave_alt.webp b/assets/sprites/tarinai_30_zunchi_slave_alt.webp index 07aac89..1f98640 100644 Binary files a/assets/sprites/tarinai_30_zunchi_slave_alt.webp and b/assets/sprites/tarinai_30_zunchi_slave_alt.webp differ diff --git a/assets/sprites/tarinai_31_sunbath_01.webp b/assets/sprites/tarinai_31_sunbath_01.webp index ea641b1..cbd7267 100644 Binary files a/assets/sprites/tarinai_31_sunbath_01.webp and b/assets/sprites/tarinai_31_sunbath_01.webp differ diff --git a/assets/sprites/tarinai_32_sunbath_02.webp b/assets/sprites/tarinai_32_sunbath_02.webp index edbbaa9..013dc52 100644 Binary files a/assets/sprites/tarinai_32_sunbath_02.webp and b/assets/sprites/tarinai_32_sunbath_02.webp differ diff --git a/assets/sprites/tarinai_33_sunbath_03.webp b/assets/sprites/tarinai_33_sunbath_03.webp index fb95759..316ceda 100644 Binary files a/assets/sprites/tarinai_33_sunbath_03.webp and b/assets/sprites/tarinai_33_sunbath_03.webp differ diff --git a/assets/ui/tool_cannon.webp b/assets/ui/tool_cannon.webp new file mode 100644 index 0000000..aa6db08 Binary files /dev/null and b/assets/ui/tool_cannon.webp differ diff --git a/assets/ui/tool_duplicator.webp b/assets/ui/tool_duplicator.webp new file mode 100644 index 0000000..8532570 Binary files /dev/null and b/assets/ui/tool_duplicator.webp differ diff --git a/assets/ui/tool_oshibyo.webp b/assets/ui/tool_oshibyo.webp new file mode 100644 index 0000000..8e2984d Binary files /dev/null and b/assets/ui/tool_oshibyo.webp differ diff --git a/css/base.css b/css/base.css index cb7924c..cf9df20 100644 --- a/css/base.css +++ b/css/base.css @@ -400,15 +400,15 @@ h1 { .tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.webp"); } /* Generated parameter item tool icons. */ -.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); } -.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); } -.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); } -.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); } -.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); } -.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); } -.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); } -.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); } -.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); } +.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.33.0"); } +.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.33.0"); } +.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.33.0"); } +.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.33.0"); } +.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.33.0"); } +.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.33.0"); } +.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.33.0"); } +.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.33.0"); } +.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.33.0"); } .tool[data-tool="freeze"] { --tool-icon: none; } .tool[data-tool="freeze"]::before { content: "🧊"; @@ -418,3 +418,11 @@ h1 { text-align: center; } .tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; } + +/* v15.28.0: missing pin tool icons. */ +.tool[data-tool="pushpin"] { --tool-icon: url("../assets/ui/tool_pushpin.webp?v=15.33.0"); } +.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp?v=15.33.0"); } + +/* v15.28.0: machine tool icons. */ +.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp?v=15.33.0"); } +.tool[data-tool="cannon"] { --tool-icon: url("../assets/ui/tool_cannon.webp?v=15.33.0"); } diff --git a/css/components.css b/css/components.css index 1e02c43..0423b9e 100644 --- a/css/components.css +++ b/css/components.css @@ -454,6 +454,14 @@ .selected-info .info-row[data-metric="zunchi"] { background: rgba(112, 142, 70, 0.10); } .selected-info .info-row[data-metric="mood"] { background: rgba(222, 196, 86, 0.10); } .selected-info .info-row[data-metric="lack"] { background: rgba(142, 112, 184, 0.10); } +.selected-info .selected-need-row.active { + background: rgba(124, 198, 70, 0.18); + box-shadow: inset 3px 0 0 rgba(99, 166, 52, 0.78); +} +.selected-info .selected-need-mark { + color: rgba(72, 126, 42, 0.95); + font-size: 0.82em; +} .selected-personality-changes { align-items: start; } @@ -621,3 +629,179 @@ justify-content: flex-start; } } + +/* v15.25.0: save menu grid, thumbnails, compact import/export confirmation. */ +.save-dialog-panel { + width: min(860px, calc(100vw - 32px)); + max-height: min(88vh, 790px); +} +.save-slot-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 14px 0; +} +.save-slot-card { + display: grid; + grid-template-rows: auto auto 1fr auto; + gap: 6px; + min-height: 190px; + padding: 9px; + border: 1px solid rgba(115, 91, 62, 0.20); + border-radius: 14px; + background: rgba(255, 255, 255, 0.58); + box-shadow: 0 2px 8px rgba(68, 52, 33, 0.06); +} +.save-slot-card.empty { + background: rgba(250, 247, 240, 0.54); +} +.save-slot-thumb { + height: 82px; + border-radius: 10px; + overflow: hidden; + background: linear-gradient(180deg, rgba(231, 221, 204, 0.80), rgba(245, 240, 230, 0.90)); + border: 1px solid rgba(115, 91, 62, 0.14); + display: grid; + place-items: center; +} +.save-slot-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.save-slot-thumb-empty { + font-size: 11px; + letter-spacing: .04em; + color: rgba(88, 72, 54, 0.48); + font-weight: 800; +} +.save-slot-title { + font-weight: 800; + color: #4f3928; + line-height: 1.15; +} +.save-slot-meta { + color: #6d5a46; + font-size: 12px; + line-height: 1.35; + min-height: 48px; +} +.save-slot-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; + justify-content: stretch; +} +.save-slot-actions .btn.mini { + min-width: 0; + padding-inline: 5px; +} +.save-confirm-box { + margin-top: 10px; + padding: 10px; + border-radius: 14px; + border: 1px solid rgba(169, 90, 73, 0.26); + background: rgba(255, 245, 236, 0.96); + box-shadow: inset 0 0 0 1px rgba(255,255,255,.62); +} +.save-confirm-box.hidden { display: none; } +.save-confirm-message { font-size: 13px; line-height: 1.45; color: #4f3928; } +.save-confirm-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:8px; } +.save-hash-text { min-height: 82px; } +@media (max-width: 740px) { + .save-slot-grid { grid-template-columns: 1fr; } + .save-slot-card { min-height: 0; } + .save-slot-thumb { height: 96px; } +} + +/* v15.25.0: compact full-width colony mood. */ +.colony-stats .stat:nth-child(3) { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 9px; + min-height: 28px; + text-align: left; +} +.colony-stats .stat:nth-child(3) span { font-size: 10px; } +.colony-stats .stat:nth-child(3) strong { font-size: 14px; line-height: 1.15; text-align: right; } + +/* v15.25.0: event push cards are contained at the top of the event UI. */ +.log-push-overlay { + position: static; + z-index: auto; + width: 100%; + margin: 0 0 8px; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; +} +.log-push-toast { + box-shadow: 0 6px 18px rgba(46, 34, 24, 0.13); + padding: 8px 10px 8px 13px; +} + +/* v15.28.0: smaller save slots with full-card thumbnails. */ +.save-dialog-panel { width: min(760px, calc(100vw - 32px)); max-height: min(88vh, 760px); } +.save-slot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; } +.save-slot-card { position: relative; overflow: hidden; min-height: 126px; padding: 7px; grid-template-rows: auto 1fr auto; gap: 4px; background: rgba(255,255,255,.64); } +.save-slot-thumb { position: absolute; inset: 0; height: auto; border: 0; border-radius: inherit; z-index: 0; opacity: .88; } +.save-slot-thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,250,241,.42), rgba(255,250,241,.78)); } +.save-slot-thumb img { object-fit: cover; } +.save-slot-thumb-empty { width:100%; height:100%; display:grid; place-items:center; background:rgba(240,234,222,.80); } +.save-slot-title, .save-slot-meta, .save-slot-actions { position: relative; z-index: 1; } +.save-slot-title { padding: 3px 6px; border-radius: 999px; background: rgba(255,251,244,.76); width: fit-content; } +.save-slot-meta { min-height: 34px; font-size: 11px; line-height: 1.25; padding: 5px 6px; border-radius: 10px; background: rgba(255,251,244,.70); } +.save-slot-actions { gap: 4px; } +.save-slot-actions .btn.mini { padding: 5px 4px; font-size: 11px; } +@media (max-width: 740px) { .save-slot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .save-slot-card { min-height: 118px; } } +@media (max-width: 520px) { .save-slot-grid { grid-template-columns: 1fr; } .save-slot-card { min-height: 128px; } } + +/* v15.28.0: frozen list lives inside the Operation tool category. */ +.tool-category[data-tool-category="operate"] .frozen-panel { grid-column: 1 / -1; margin: 4px 0 0; } + + +/* v15.28.0: large centered load/overwrite warning. */ +.save-confirm-box { + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 12000; + width: min(560px, calc(100vw - 32px)); + margin: 0; + padding: 22px 24px; + border-radius: 22px; + border: 2px solid rgba(169, 90, 73, 0.34); + background: rgba(255, 248, 239, 0.98); + box-shadow: 0 24px 70px rgba(42, 30, 18, 0.30), inset 0 0 0 1px rgba(255,255,255,.74); +} +.save-confirm-box::before { + content: ""; + position: fixed; + left: -100vw; + right: -100vw; + top: -100vh; + bottom: -100vh; + background: rgba(30, 22, 14, 0.24); + z-index: -1; +} +.save-confirm-message { + font-size: 18px; + line-height: 1.55; + font-weight: 800; + color: #4a2f23; +} +.save-confirm-actions { + margin-top: 18px; + gap: 12px; +} +.save-confirm-actions .btn { + min-width: 118px; + padding: 10px 16px; + font-size: 15px; +} diff --git a/css/mobile.css b/css/mobile.css index 9fedacb..60ec5ba 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -180,15 +180,15 @@ } /* Item icons and mobile input mode */ -.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); } -.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); } -.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); } -.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); } -.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); } -.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); } -.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); } -.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); } -.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); } +.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.30.0"); } +.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.30.0"); } +.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.30.0"); } +.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.30.0"); } +.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.30.0"); } +.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.30.0"); } +.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.30.0"); } +.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.30.0"); } +.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.30.0"); } .mobile-mode-controls { display: none; position: absolute; @@ -239,3 +239,21 @@ body.touch-mobile-ui.mobile-mode-family .lineage-main-card { z-index: 9; } text-align: center; } .tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; } + +/* v15.28.0: keep push notifications inside the event card on mobile too. */ +.log-push-overlay { + position: static; + z-index: auto; + width: 100%; + margin: 0 0 8px; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; +} +.tool[data-tool="pushpin"] { --tool-icon: url("../assets/ui/tool_pushpin.webp?v=15.30.0"); } +.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp?v=15.30.0"); } + +/* v15.28.0: machine tool icons. */ +.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp?v=15.30.0"); } +.tool[data-tool="cannon"] { --tool-icon: url("../assets/ui/tool_cannon.webp?v=15.30.0"); } diff --git a/docs/prompt.txt b/docs/prompt.txt index 636a065..1950df9 100644 --- a/docs/prompt.txt +++ b/docs/prompt.txt @@ -1,466 +1,860 @@ -You are working on the browser game "たりないコロニー" based on the current ZIP/project state, assumed to be v15.20.3. - -Goal: resolve the remaining internal design confusion and underdeveloped systems, while preserving existing gameplay unless explicitly requested below. - -Hard exclusions: - -* Do NOT implement field-size balance tuning. Specifically do not implement the previously proposed "field-specific balance correction" work. -* Do NOT implement save/load. -* Do NOT implement debug scenario modes. -* Do NOT redesign or expand the zunchi system. Keep zunchi mechanics and general design as-is, except where existing code must be connected through registries/events without changing behavior. -* Do NOT modify `docs/prompt.txt` if present. -* Do not introduce a build toolchain requirement. Plain static HTML/CSS/JS must still run directly. - -Primary requirements: - -1. Make item visuals single-source-of-truth. - -* Current problem: field item rendering, tool UI icons, generated WebP icons, documentation, and item definitions can diverge. -* Establish one canonical visual definition per item/tool. -* Field rendering must be the primary visual source, or both field rendering and UI preview must derive from the same canonical visual descriptor. -* The tool UI must never have an independently invented appearance. -* Create or consolidate a visual registry, for example `js/item_visual_registry.js`, with descriptors such as: - - * `renderer` - * `shape` - * `palette` - * `scale` - * `uiPreviewScale` - * `fieldScale` - * `accessibleShapeTraits` - * optional `emojiFallback` -* Update field rendering and UI icon generation/preview so they share this registry. -* Fix visual drift for all known drift-prone items: - - * ammo - * mystery drug - * laxative - * sleeping drug - * protein - * nietorop - * giant drug - * dwarf drug - * zunda juice - * mercury - * rain-water item - * ball -* Keep the rule in docs: UI icon is a preview of the field visual, not a separate design. -* Ensure scalable items correctly respond to small/medium/large placement size. - -2. Data-drive tool categories. - -* Current problem: tool category membership/order/color is partly hardcoded in HTML/CSS and not owned by tool data. -* Move tool categories into a data registry, for example `js/tool_registry.js` or `js/data.js`. -* The registry must define: - - * category id - * Japanese label - * color/theme class - * sort order - * tool ids included in that category -* Generate/build the tool UI from this registry at runtime. -* Keep collapsible category behavior. -* Keep simple category color coding. -* Remove duplicated static category definitions from `index.html` where possible. -* Category changes must require editing only the registry, not HTML. - -3. Convert EffectRegistry from display-only to real behavior ownership. - -* Current problem: `effect_registry.js` mainly handles labels/display/random candidates, while actual behavior remains scattered in Tarinai/item code. -* Promote `EffectRegistry` into the canonical owner for item effect metadata and behavior. -* Each effect should define, where relevant: - - * `id` - * Japanese label - * duration or permanent flag - * exclusive group - * stack behavior - * display color - * display summary - * modifiers - * `onApply(tarinai, context)` - * `onTick(tarinai, dt, context)` - * `onRemove(tarinai, context)` - * visual effect id - * bubble policy - * whether it is eligible for "怪しい薬" - * whether it is removed by "ずんだ汁" -* Migrate these item effects into the registry: - - * laxative - * protein - * nietorop - * ammo - * mystery drug - * mercury - * giant drug - * dwarf drug - * zunda juice clearing behavior - * heko mochi timed effect - * fight mochi timed effect - * sleeping drug if it changes behavior -* Existing semantics must remain: - - * laxative: emits zunchi every 3 seconds during effect; eating does not reduce hunger because it immediately passes through. - * protein: permanent; damage dealt x1.75; disease chance x0.5; red upward particles; mutually exclusive with nietorop; latest wins. - * nietorop: permanent; damage dealt x0.5; disease chance x2; purple downward particles; mutually exclusive with protein; latest wins. - * ammo: timed; walking speed x10; while moving, collision with Tarinai/ants knocks them away. - * mystery drug: grants random two effects from behavior-changing item effects and diseases. - * mercury: each consumption multiplicatively increases lifespan by x1.3. - * giant drug: permanent; size x3; damage x2; speed x1.5; 1 damage/sec; mutually exclusive with dwarf drug; latest wins. - * dwarf drug: permanent; size x0.25; damage x0.25; speed x0.5; 1 damage/sec; mutually exclusive with giant drug; latest wins. - * zunda juice: removes all removable item effects. -* "たりないデータ" must read active item effects from the registry, not hand-built scattered conditions. - -4. Add DiseaseRegistry and connect disease behavior. - -* Current problem: diseases are individual booleans/branches and are not uniformly represented. -* Add `DiseaseRegistry` or equivalent. -* It must define: - - * disease id - * Japanese label - * symptoms/behavior flags - * infection chance modifiers - * cure rules - * display text - * visual/bubble policy - * eligibility for mystery drug -* Migrate these diseases into registry metadata while preserving behavior: - - * zunchi disease - * sleep disease - * explosion disease - * fight disease -* Explosion disease infection probability must remain 15% where currently defined, with protein/nietorop disease multipliers applied to that base. -* Mystery drug must sample diseases through this registry. -* Zunda juice should remove item effects, not necessarily diseases, unless current semantics already remove a given disease; do not silently change disease cure rules. - -5. Add FoodRegistry and centralize food semantics. - -* Current problem: food interest, nutrition, decay, hygiene penalty, cures, and special exceptions are scattered. -* Add `FoodRegistry`. -* It must define: - - * item id/type - * Japanese label - * nutrition - * palatability/interest class - * decay profile - * hygiene penalty on decay - * special effect id, if eating applies an effect - * whether it counts as grass-like interest, zunda-mochi high interest, medicine-like low interest, water, corpse, etc. -* Preserve existing behavior: - - * zunda mochi remains highly attractive. - * most non-zunda foods remain only slightly more interesting than grass. - * all food naturally decays about twice per half-day, not continuously. - * decay slightly reduces hygiene stats at those discrete decay ticks. - * laxative effect prevents hunger reduction from eating. -* `Tarinai` food-seeking logic must consult `FoodRegistry`. -* Food decay logic must consult `FoodRegistry`. -* Tooltips/descriptions should remain Japanese and current user-facing wording must not regress. - -6. Separate WeatherSystem from water items. - -* Current problem: rain visual effects and rain-spawned items/water-like items are conceptually mixed. -* Create or isolate a `WeatherSystem`. -* Explicitly separate: - - * `rain_visual`: diagonal blue-ish streaks/lines falling at an angle. - * `rain_water_item`: horizontal oval water item dropped by rain. - * `water_bowl`: placed water object. - * `zunda_juice`: green horizontal oval liquid item. - * `mercury`: gray horizontal oval liquid item. -* Do not reuse rain visual renderer for item droplets. -* Do not render rain effect as oval droplets. Rain effect must be angled falling lines. -* Do not render zunda juice or mercury with diagonal lines. -* Preserve current rain gameplay behavior. -* Add code comments documenting the distinction so future changes do not confuse them. - -7. Apply event bus more broadly. - -* Current problem: logs, sounds, notifications, stats, effects, and debug traces are still partly direct calls. -* Use the existing event bus or strengthen it. -* Add domain events for: - - * `tool:placed` - * `item:eaten` - * `item:decayed` - * `effect:applied` - * `effect:removed` - * `disease:infected` - * `disease:cured` - * `tarinai:damaged` - * `tarinai:died` - * `tarinai:born` - * `fight:hit` - * `fight:lost` - * `fight:ended` - * `ant:spawned` - * `ant:grabbed` - * `ant:carried` - * `ant:died` - * `weather:changed` - * `rain:itemDropped` -* Logs, push notifications, audio, stats, and debug history should subscribe to events where practical. -* Do not rewrite everything destructively. Add event emission at central points first, then migrate obvious direct calls. -* Avoid duplicate logs/sounds. - -8. Sound system source-of-truth cleanup. - -* Current problem: `sound_pack.js` and `audio.js` both contain overlapping settings. -* Make `sound_pack.js` the canonical metadata source for: - - * sound id - * category - * volume multiplier - * cooldown - * sample path - * fallback synthesis profile -* `audio.js` should be the playback engine only: - - * loading samples - * fallback synthesis - * category enable/disable - * master gain - * cooldown enforcement - * concurrent voice limiting -* Keep the current user settings: - - * master effect sound on/off - * Tarinai voice - * notification - * operation -* Do not regress volume. The previous issue was that only the first sound was loud and subsequent sounds became too quiet. Preserve the fixed master gain behavior. -* Keep `?debug=1` showing recent sound IDs and sound-pack version. - -9. Mobile input manager. - -* Current problem: mobile operation UI has appeared on PC repeatedly. -* Add a single `InputModeManager` or equivalent. -* It must own: - - * `isTouchDevice` - * `isCompactViewport` - * `hasHover` - * `pointerCoarse` - * `userAgentMobile` - * `currentMode` - * `gestureState` - * whether canvas can pan - * whether canvas can place tools - * whether family tree is allowed to capture touch -* Mobile operation UI must appear only when the device is clearly touch-first: - - * coarse pointer - * no hover - * real touch support - * and either compact viewport or mobile/tablet UA -* Desktop PCs with touch screens must not automatically show the mobile UI unless the viewport/UA also indicates mobile/tablet. -* Add debug display of the input classification under `?debug=1`. -* Keep existing modes: - - * auto - * camera - * tool - * family tree - -10. Ant system cleanup and extension hooks, without broad balance changes. - -* Do not create a full new ant ecology balance pass. -* Do consolidate ant domain events and state names. -* Preserve existing behavior: - - * ants can carry sleeping Tarinai. - * sleeping Tarinai do not wake just because ants carry them. - * sleeping Tarinai do not counterattack ants while asleep. - * ants without nests either join another available nest or wander. - * empty nests are removed. - * queen ants are visually larger. -* Add clear extension hooks or comments for future ant ecology: - - * nest hunger - * nest growth - * queen reproduction - * worker lifecycle - * carrying success rate - * territorial behavior -* Do not implement those future mechanics now unless already present. - -11. Family tree: add personality tags. - -* The family tree currently records lineage but should expose personality more directly. -* Add compact personality tags to each Tarinai node/card in the family tree. -* Use existing personality axes: - - * aggression - * openness - * sociability - * neuroticism -* Tags should be short Japanese labels, for example: - - * aggressive/high aggression - * calm/low aggression - * curious/high openness - * cautious/low openness - * social/high sociability - * solitary/low sociability - * nervous/high neuroticism - * stable/low neuroticism -* Use at most 2-3 tags per node to avoid clutter. -* Make tags visually subtle and compact. -* Do not break existing family tree panning/zooming/layout. -* If a Tarinai is dead, still show its personality tags if data exists. - -12. Tarinai data panel: show recent change causes. - -* The Tarinai individual data panel must include recent reasons for personality/state changes. -* Add a small "recent change causes" section. -* Record recent causes when personality or important state changes happen, for example: - - * genkotsu damage: fear increase and openness decrease - * fight win/loss - * being carried by ants - * disease infection/cure - * protein/nietorop - * giant/dwarf drug - * mercury consumption - * zunda juice clearing effects - * sleep/wake transitions where meaningful - * stress/fear significant changes -* Store only a short rolling history per Tarinai, for example last 5-8 entries. -* Each entry should include: - - * Japanese short reason - * affected stat/effect - * approximate in-game time or "recent" -* This is UI/diagnostic information, not save/load. -* Do not add full persistence. - -13. Personality system visibility. - -* The existing personality axes are meaningful but currently not visible enough. -* In addition to family tree tags and recent change causes, improve the Tarinai data panel to make personality effects understandable: - - * show current personality values already present, if available - * show the most recent cause next to changed values where practical - * avoid long prose; use compact labels. -* Do not add new personality axes. - -14. Documentation consolidation and English-only high-density docs. - -* Keep `docs/prompt.txt` untouched. -* Keep documentation concise, English-only, and high information density. -* Update or consolidate docs so the following source-of-truth rules are explicit: - - * item visuals come from the canonical visual registry. - * UI icons are previews, not independent art. - * tool categories come from the tool registry. - * item effects come from EffectRegistry. - * diseases come from DiseaseRegistry. - * food behavior comes from FoodRegistry. - * rain visuals are separate from rain-spawned water items. - * zunchi is intentionally unchanged. - * no field balance, save/load, or debug scenario implementation in this pass. -* Avoid many small docs. Prefer a small set such as: - - * `docs/ARCHITECTURE.md` - * `docs/CONTENT_GUIDE.md` - * `docs/MAINTENANCE.md` -* Remove obsolete docs if safely redundant. -* Do not delete `docs/prompt.txt`. - -15. Preserve user-facing Japanese UI text unless intentionally specified. - -* Game UI remains Japanese. -* Docs are English. -* The Codex implementation may use English comments for architecture, but visible labels/tooltips remain Japanese. - -16. Regression fixes to preserve. - Do not regress the following existing fixes: - -* Removed unwanted bubbles: - - * ドン - * ぐるる - * すっきり - * Hg xN - * ちまっ - * でかっ - * へにゃ - * ムキッ - * mm... -* Ball tool UI icon must be soccer ball emoji. -* Time periods: - - * morning starts at 05:00 - * noon/day starts at 10:00 - * evening starts at 17:00 - * night starts at 20:00 -* Genkotsu: - - * stronger knockback for living things - * zunchi is knocked away - * damaged Tarinai gain fear and lose openness -* Rain visual: - - * diagonal angled line rain, not droplets. -* Rain-spawned water item / zunda juice / mercury: - - * horizontal oval item visuals. -* Sleeping Tarinai: - - * can be carried by ants - * do not wake from ant carrying - * do not attack ants while sleeping - * after waking, remain awake for at least 10 seconds. -* Tarinai data: - - * fight win rate instead of cumulative defeats. -* Fight: - - * on taking damage, Tarinai may lose/flee probabilistically. - * high aggression lowers loss chance. - * high damage and low HP increase loss chance. - -17. Versioning and cache. - -* Bump app version to the next patch/minor version consistently. -* Update: - - * version file - * service worker cache name - * asset query versions - * manifest if present -* Ensure `service-worker.js` itself remains no-cache/no-store. -* Ensure generated app manifest validation still passes. - -18. Validation. - Run and report: - -* `node --check js/*.js service-worker.js` -* manifest validation/generation check if scripts exist -* zip integrity check if packaging -* basic static grep checks: - - * no stale app version references - * no removed bubble strings still emitted as bubbles - * mobile UI visibility logic uses the new InputModeManager - * tool categories are not duplicated in `index.html` - * sound metadata is not duplicated as canonical data in both `sound_pack.js` and `audio.js` - * item visual drift-prone items use canonical visual registry - * docs are English except `docs/prompt.txt` - -Deliverables: - -* Updated project files. -* Updated ZIP. -* Brief implementation summary. -* Validation results. -* Mention any deliberately deferred items: - - * field balance tuning - * save/load - * debug scenario modes - * zunchi expansion +Implement the new need-driven behavior system for the existing “tarinai” observation game. + +Keep the implementation simple, localized, and compatible with the existing architecture. Do not perform a broad rewrite unless absolutely necessary. + +Important source-code text rule: +All newly added Japanese UI strings or Japanese reason/action strings in source code must be written as Unicode escape sequences, not raw Japanese characters. + +Example: +Do not write: +"あぶないけど、食べた。" + +Write: +"\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002" + +Runtime text may display Japanese, but source literals must be escaped. + +============================================================ +GOAL +============================================================ + +Replace or absorb the existing detailed emotion-driven behavior system into a simple six-need system. + +Behavior should be selected from six needs: + +- food +- sleep +- health +- safety +- social +- fulfill + +The system should allow imperfect behavior. + +Example: +If safety and food are both maxed, the tarinai may randomly choose food and eat even though it is dangerous. + +============================================================ +1. SIX NEEDS +============================================================ + +Each tarinai should have: + +needs = { + food: 0, + sleep: 0, + health: 0, + safety: 0, + social: 0, + fulfill: 0 +}; + +Meaning: + +food: +- eating +- drinking + +sleep: +- sleep +- rest +- tiredness + +health: +- illness +- injury +- dirt +- poison +- recovery + +safety: +- danger +- fear +- ants +- explosions +- punches +- being attacked + +social: +- friends +- parent/child +- mating/reproduction +- enemies if existing relation logic supports it + +fulfill: +- play +- owned place +- owned structure +- grass bed +- plushie +- simple construction + +Each need is internally 0–100. + +After raw calculation, quantize each value to 10-point steps: + +0, 10, 20, ... 100 + +Use a helper like: + +function quantizeNeed(value) { + return Math.max(0, Math.min(100, Math.round(value / 10) * 10)); +} + +For UI display, show only the tens digit: + +0, 1, 2, ... 10 + +Example: +internal value 80 -> display 8 +internal value 100 -> display 10 + +Safety is capped at 100 like every other need. +Safety is not a hard interrupt. + +============================================================ +2. NEED CALCULATION +============================================================ + +Calculate needs from existing tarinai/world state where possible. + +Suggested mapping: + +food: +- hunger +- thirst if available + +sleep: +- low energy +- long awake time if available +- night/time-of-day bonus if available + +health: +- disease +- injury +- dirtiness +- poison or negative status if available + +safety: +- nearby ants +- nearby dangerous items +- recent damage +- fear/panic-related existing state if any +- being attacked +- owned structure destruction shock + +social: +- loneliness if available +- nearby friend / parent / child +- reproduction readiness +- enemy proximity if current relation system supports it + +fulfill: +- wants to play +- has no owned structure +- wants to return to owned grass bed +- wants to carry/use plushie +- wants to build a simple owned structure + +Use existing personality fields if available: + +aggression: +- safety rises slightly less from danger +- may choose intimidate instead of panic when there is a breaker/attacker + +openness: +- fulfill rises more easily +- play/build actions become more likely + +sociability: +- social rises more easily +- reacts less negatively when others use owned structures + +neuroticism: +- safety and health rise more easily +- owned structure damage/destruction has stronger effect + +Keep personality modifiers small and easy to tune. + +============================================================ +3. ACTION SELECTION +============================================================ + +Use this behavior flow: + +1. Calculate all six raw needs. +2. Quantize all needs. +3. Find the maximum need value. +4. Collect all needs equal to the maximum. +5. Randomly choose one need from that tied top set. +6. Choose an executable action associated with that chosen need. +7. Store the chosen need and action in tarinai.intent. +8. Highlight only the chosen need in the tarinai data UI. + +If safety and food are both 100, either may be chosen randomly. + +Add helpers: + +- createDefaultNeeds() +- quantizeNeed(value) +- updateNeeds(tarinai, world) +- chooseTopNeedRandom(needs) +- chooseActionForNeed(need, tarinai, world) +- buildReasonText(chosenNeed, tiedNeeds, action) +- getNeedDisplayValue(value) + +============================================================ +4. SIMPLE ACTION DEFINITIONS +============================================================ + +Use a simple action list, not a complex behavior tree. + +Each action should have: + +- id +- need +- label +- phrase +- weight +- condition(tarinai, world) +- run(tarinai, world) + +Example shape: + +const TARINAI_ACTIONS = [ + { + id: "eat_food", + need: "food", + label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", + phrase: "\u98df\u3079\u305f", + weight: 50, + condition(t, world) { + return !!findNearestItemWithRole(world, t, "food"); + }, + run(t, world) { + const item = findNearestItemWithRole(world, t, "food"); + moveToOrUse(t, item); + } + } +]; + +Suggested actions: + +food: +- eat_food +- drink_water + +sleep: +- sleep_in_bed +- sleep_in_owned_structure +- sleep_anywhere + +health: +- use_medicine +- wash_or_drink_water +- rest_to_recover + +safety: +- flee +- hide_in_nest +- hide_at_owned_structure + +social: +- approach_friend +- approach_parent_or_child +- approach_mate +- intimidate_enemy + +fulfill: +- play +- wander_lightly +- build_grass_bed +- build_plushie +- return_owned_structure +- use_plushie + +Use weighted random among executable actions for the chosen need. + +If no action is executable, fall back to idle, wander, or rest. + +Do not make tarinai perfectly optimal. + +============================================================ +5. TIE REASON TEXT +============================================================ + +If multiple needs are tied for highest and the chosen need is only one of them, generate: + +“[ignored need phrase]\u3051\u3069\u3001[chosen action phrase]\u3002” + +Examples: +- "\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002" +- "\u306d\u3080\u3044\u3051\u3069\u3001\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f\u3002" +- "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b\u3051\u3069\u3001\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f\u3002" +- "\u8abf\u5b50\u304c\u60aa\u3044\u3051\u3069\u3001\u904a\u3093\u3060\u3002" + +When multiple ignored needs exist, mention the ignored need with highest priority: + +safety > health > food > sleep > social > fulfill + +Conflict phrases: + +food: +"\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b" + +sleep: +"\u306d\u3080\u3044" + +health: +"\u8abf\u5b50\u304c\u60aa\u3044" + +safety: +"\u3042\u3076\u306a\u3044" + +social: +"\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b" + +fulfill: +"\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b" + +If there is no tie, use: + +“[need phrase]\u306e\u3067\u3001[action phrase]\u3002” + +Store: + +tarinai.intent = { + need: chosenNeed, + tiedNeeds, + actionId: action.id, + actionLabel: action.label, + reasonText +}; + +============================================================ +6. NEED UI +============================================================ + +Update the tarinai data UI. + +Show six needs as 0–10 values. + +Labels must be escaped in source: + +food label: +"\u6442\u990c" + +sleep label: +"\u7761\u7720" + +health label: +"\u5065\u5eb7" + +safety label: +"\u5b89\u5168" + +social label: +"\u95a2\u4fc2" + +fulfill label: +"\u5145\u8db3" + +Example UI: + +摂餌 8 +睡眠 3 +健康 1 +安全 10 +関係 4 +充足 10 ★ + +Only the need actually chosen for the current action receives the highlight. + +Also show: + +"\u3044\u307e": +[action label] + +"\u7406\u7531": +[reasonText] + +Do not show detailed need breakdowns. +Do not add graphs or extra panels. + +============================================================ +7. ITEM ROLES AND NEED EFFECTS +============================================================ + +Use minimal item roles and needEffects. + +Do not create a broad tag system. + +Item example: + +item.roles = { + food: true, + drink: true, + medicine: true, + sleepPlace: true, + danger: true, + grassMaterial: true +}; + +item.needEffects = { + food: 0, + sleep: 0, + health: 0, + safety: 0, + social: 0, + fulfill: 0 +}; + +Only add roles that are actually needed by the new behavior. + +Useful helpers: + +- findNearestItemWithRole(world, tarinai, role) +- findNearestSleepPlace(world, tarinai) +- findNearbyDanger(world, tarinai) +- findNearbyMaterial(world, tarinai, materialRole) + +Adapt to existing item registries where possible. + +============================================================ +8. STRUCTURE REGISTRY +============================================================ + +Keep StructureRegistry because tarinai-built structures will expand in the future. + +Do not collapse structures into one-off special item cases. + +Create or adapt a small StructureRegistry for buildable tarinai structures. + +Shared structure instance shape: + +{ + id, + type, + isStructure: true, + ownerId, + hp, + attachment, + usedCount, + x, + y, + roles, + needEffects +} + +Do not implement: +- repair +- inheritance +- ownership privacy +- material-carrying inventory +- complex construction logistics + +First buildable structures: + +- grass_bed +- plushie + +============================================================ +9. STRUCTURE: GRASS BED +============================================================ + +Structure type: +"grass_bed" + +Japanese label, escaped: +"\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9" + +Concept: +A simple bed made from grass. It is an owned place. The owner prefers it for sleep, safety, and fulfill. + +Example registry entry shape: + +grass_bed: { + type: "grass_bed", + label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 100, + materials: { + grassMaterial: 1 + }, + roles: { + sleepPlace: true, + ownedStructure: true + }, + needEffects: { + food: 0, + sleep: 60, + health: 5, + safety: 25, + social: 0, + fulfill: 40 + }, + create(owner, x, y, world) { + return { + id: createId("structure"), + type: "grass_bed", + isStructure: true, + ownerId: owner.id, + hp: 100, + attachment: 20, + usedCount: 0, + x, + y, + roles: this.roles, + needEffects: this.needEffects + }; + }, + onUse(user, structure, world) { + // owner gets stronger effects + // non-owner gets weaker effects + // owner use increases attachment + }, + onDestroyed(structure, world, breaker) { + // increase owner safety and fulfill + // trigger panic or intimidate through need shock logic + } +} + +Build condition: +- chosen need is fulfill +- tarinai has no owned grass_bed +- grass material is nearby +- tarinai is not sleeping, fighting, or panicking + +Build process: +- move near grass +- spend short build time if existing state system supports it +- consume/reduce grass if easy +- create grass_bed through StructureRegistry + +Owner behavior: +- owner prefers own grass_bed for sleep, safety, and fulfill +- using own grass_bed reduces sleep, safety, and fulfill +- using own grass_bed increases attachment + +Other tarinai: +- may use it with weaker effects +- if owner sees another tarinai using it: + - aggression may cause intimidate + - neuroticism may increase safety/fulfill + - sociability reduces negative reaction + +Destruction: +- hp can be reduced by explosions, punches, weather, repeated use, or existing damage sources if easy to connect +- when hp reaches 0, call onDestroyed and remove structure + +============================================================ +10. STRUCTURE: PLUSHIE +============================================================ + +Add a new tarinai-built structure: + +type: +"plushie" + +Japanese label, escaped: +"\u306c\u3044\u3050\u308b\u307f" + +Concept: +A small plushie item that resembles a tarinai. It is made from grass. When completed, it attaches to the owner tarinai’s head. While attached, it reduces fulfill. If damaged, it breaks and disappears. The owner receives a strong need shock and enters panic or intimidate. + +Example registry entry shape: + +plushie: { + type: "plushie", + label: "\u306c\u3044\u3050\u308b\u307f", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 1, + materials: { + grassMaterial: 1 + }, + roles: { + carriedObject: true, + ownedStructure: true, + fulfillObject: true + }, + needEffects: { + food: 0, + sleep: 0, + health: 0, + safety: 5, + social: 0, + fulfill: 50 + }, + create(owner, x, y, world) { + return { + id: createId("structure"), + type: "plushie", + isStructure: true, + ownerId: owner.id, + hp: 1, + attachment: 30, + usedCount: 0, + x, + y, + carriedById: owner.id, + onHead: true, + roles: this.roles, + needEffects: this.needEffects + }; + }, + onUse(user, structure, world) { + // if owner carries/uses it, reduce fulfill + // optionally reduce safety slightly + // increase attachment slightly + }, + onDestroyed(structure, world, breaker) { + // large shock to owner safety and fulfill + // trigger panic or intimidate + } +} + +Build condition: +- chosen need is fulfill +- tarinai does not already own/carry a plushie +- grass material is nearby +- tarinai is not sleeping, fighting, or panicking + +Build process: +- move near grass +- spend short build time if existing state system supports it +- consume/reduce grass if easy +- create plushie through StructureRegistry +- attach it to owner’s head + +Behavior while attached: +- follow owner position +- render above owner’s head +- use same or similar sprite as tarinai, scaled down +- reduce fulfill over time or when used +- optionally reduce safety slightly +- do not implement inventory + +Damage/destruction: +- any meaningful damage destroys it +- remove plushie +- increase owner safety and fulfill strongly +- trigger panic or intimidate through need shock logic + +Do not implement: +- repair +- inheritance +- trading +- inventory +- multiple plushies per tarinai + +============================================================ +11. EMOTION SYSTEM SIMPLIFICATION +============================================================ + +Remove the old detailed emotion system as a behavior driver. + +Behavior must be selected only from the six needs: +food, sleep, health, safety, social, fulfill. + +Old emotions should be removed or absorbed: + +fear -> safety +loneliness -> social +happiness -> low stress / low fulfill pressure +sadness -> fulfill/social/safety pressure +comfort -> sleep/safety/fulfill satisfaction +boredom -> do not implement +anger -> intimidate state only +panic -> need shock reaction +stress -> derived pressure from needs + +Do not keep happiness, sadness, fear, loneliness, comfort, boredom, or anger as independent behavior-driving values. + +============================================================ +12. STRESS REDEFINITION +============================================================ + +Stress is not an independent emotion. + +Stress is a derived value calculated from current need pressure. + +Do not directly add to stress for shock events. +For shock events, increase relevant needs first, then recalculate stress from needs. + +Stress must not be used as a primary action-selection need. + +Behavior is selected only from the six needs. + +Stress may affect: +- expression +- minor visual intensity +- UI display if already present +- minor secondary effects if existing systems need it + +Suggested pressure function: + +function needPressure(value) { + const x = value / 100; + return x * x * 100; +} + +Suggested stress calculation: + +stress = + needPressure(food) * 0.10 + + needPressure(sleep) * 0.12 + + needPressure(health) * 0.25 + + needPressure(safety) * 0.30 + + needPressure(social) * 0.08 + + needPressure(fulfill) * 0.15; + +Adjust weights if needed, but keep stress derived from needs. + +============================================================ +13. PANIC REDEFINITION +============================================================ + +Panic is not a need. +Panic is not a persistent emotion. + +Panic is a short temporary reaction state caused by large sudden increases in safety, health, or fulfill. + +Food, sleep, and social should not normally trigger panic. + +Track previous needs or otherwise compute need delta. + +Suggested rule: + +panic triggers if: +- safety increases suddenly by 40 or more +- health increases suddenly by 45 or more +- fulfill increases suddenly by 50 or more + +Panic should last for a short timer, such as 2–4 seconds. + +After panic timer ends, return to normal need-based action selection. + +If panic would be triggered and the tarinai has high aggression and a known breaker/attacker, use intimidate instead of panic. + +Example: + +if (shockTriggered) { + if (breaker && t.personality?.aggression > 0.6) { + t.state = "intimidate"; + t.target = breaker; + } else { + t.state = "panic"; + t.panicTimer = 3.0; + } +} + +============================================================ +14. EXPRESSION / VISUAL EMOTION COMPATIBILITY +============================================================ + +If the old emotion system is deeply tied to expressions, keep only a small compatibility layer for visuals. + +Expressions should be derived from: +- current state +- chosen need +- stress level +- panic/intimidate + +Suggested mapping: + +state panic: +panic expression + +state intimidate: +angry/threat expression + +chosen need safety: +worried expression + +chosen need food: +hungry expression + +chosen need sleep: +sleepy expression + +chosen need health: +unwell expression + +chosen need social: +social/looking expression + +chosen need fulfill: +neutral/pleased expression + +Do not let old expression emotions drive behavior. + +============================================================ +15. SAVE COMPATIBILITY +============================================================ + +Backward compatibility is not required. + +Do not implement save migrations. +Do not add compatibility shims for old saves unless the current code would immediately crash during normal startup. + +It is acceptable if old saves are incompatible after this feature. + +============================================================ +16. DO NOT ADD +============================================================ + +Do not add: +- long-term memory +- boredom/habit system +- player-intervention profile +- ReactionRegistry +- WorldRules +- full behavior tree +- complex utility AI +- detailed need breakdown UI +- repair system +- inheritance system +- material-carrying inventory +- structure privacy system + +============================================================ +17. ACCEPTANCE CRITERIA +============================================================ + +After implementation: + +- Each tarinai has six needs: + - food + - sleep + - health + - safety + - social + - fulfill + +- Needs are clamped 0–100. +- Needs are quantized to 10-point steps. +- UI displays needs as 0–10. +- Only the chosen action-driving need is highlighted. +- If multiple needs are tied for highest, one is chosen randomly. +- Safety does not force interruption. +- If safety and another need are tied, non-safety behavior can happen. +- Tie reason text uses the “Xけど、Yした。” format. +- Newly added Japanese source strings are Unicode escaped. +- Actions are defined in a simple action list. +- Items expose minimal roles and needEffects. +- StructureRegistry exists and supports at least grass_bed and plushie. +- grass_bed consumes grass to build. +- grass_bed is preferred by its owner. +- grass_bed destruction increases owner safety/fulfill and can trigger panic/intimidate. +- plushie consumes grass to build. +- plushie attaches to the owner’s head. +- plushie visually resembles a small tarinai. +- plushie reduces fulfill while carried/used. +- plushie breaks and disappears when damaged. +- plushie destruction strongly increases owner safety/fulfill and triggers panic/intimidate. +- Old detailed emotion values no longer drive behavior. +- Stress is derived from needs. +- Panic is caused by sudden safety/health/fulfill increases. +- Old save compatibility is not required. \ No newline at end of file diff --git a/index.html b/index.html index 75ac774..a7e7257 100644 --- a/index.html +++ b/index.html @@ -5,11 +5,11 @@ たりない観察 - - - - - + + + + +
@@ -99,6 +99,7 @@
+
@@ -182,67 +183,67 @@
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/data.js b/js/data.js index 6773a1a..4547cbe 100644 --- a/js/data.js +++ b/js/data.js @@ -50,7 +50,7 @@ const TOOL_DEFINITIONS = Object.freeze({ observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" }, delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" }, poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, - pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" }, + pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "たりないや物を移動させる。冷凍庫にも持っていける。" }, new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, freeze: { id: "freeze", label: "冷凍", placeable: false, scalable: false, iconText: "\u{1F9CA}", tooltip: "つまむでたりないを冷凍個体UIまで運ぶと冷凍保存する。" }, @@ -77,10 +77,12 @@ const TOOL_DEFINITIONS = Object.freeze({ nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, - signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\u{1FAA7}", collisionShape: "circle", tooltip: "\u77ed\u3044\u30c6\u30ad\u30b9\u30c8\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u3002\u305f\u308a\u306a\u3044\u306b\u5bfe\u3059\u308b\u52b9\u679c\u306f\u7121\u3057" }, + signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 42, amount: 999, iconText: "\u{1FAA7}", collisionShape: "circle", tooltip: "短いテキストを書き込める大きめの看板。クリックで編集できる。たりないに対する効果は無し" }, firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, - pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" }, - oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "おしり鋲", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/objects/oshibyo.webp", tooltip: "刺さるとずんちが出なくなる。つまんで抜くと溜まったずんちが弾け飛ぶ。" }, + pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "転がってたりないに刺さる。" }, + oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "おしり鋲", placeable: true, scalable: false, radius: 5, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "転がってずんちのおでかけを防止。" }, + duplicator: { id: "duplicator", itemType: "duplicator", label: "複製機", placeable: true, scalable: false, radius: 44, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "セットした消費アイテムを減らさず皿に出し続ける。" }, + cannon: { id: "cannon", itemType: "cannon", label: "大砲", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_cannon.webp", collisionShape: "circle", tooltip: "セットした消費アイテムを向いている方向へ撃ち出す。クリック位置で向きを変える。" }, fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true }, @@ -92,13 +94,14 @@ const TOOL_DEFINITIONS = Object.freeze({ const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "nest_box", "bed", "fence_v", "fence_h"]), + obstacle: new Set(["stone", "ball", "nest_box", "bed", "fence_v", "fence_h", "duplicator", "cannon"]), food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), pin: new Set(["pushpin", "oshibyo"]), kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), + machine: new Set(["duplicator", "cannon"]), sleepFurniture: new Set(["bed", "nest_box"]), - draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "pushpin", "oshibyo"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "pushpin", "oshibyo", "duplicator", "cannon"]), }); function itemHasTrait(type = "", trait = "") { @@ -113,6 +116,13 @@ function itemTraitsFor(type = "") { function isPinType(type = "") { return itemHasTrait(type, "pin"); } function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } +function isMachineType(type = "") { return itemHasTrait(type, "machine"); } +function isMachineConsumableType(type = "") { + const t = String(type || ""); + if (!t) return false; + if (typeof isServingFoodType === "function" && isServingFoodType(t)) return true; + return ["grass", "water", "water_bowl", "zunchi", "ant_corpse"].includes(t); +} function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } function lodgedPinFor(tarinai = null, worldRef = null) { if (!tarinai?.stuckPushpinId) return null; @@ -124,7 +134,7 @@ function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lod const PIN_BEHAVIORS = Object.freeze({ pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, - oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true }, + oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true, scale: 0.50 }, }); function pinBehaviorFor(type = "") { @@ -133,12 +143,11 @@ function pinBehaviorFor(type = "") { const TOOL_CATEGORIES = Object.freeze([ - { id: "operate", label: "\u64cd\u4f5c\u30fb\u89b3\u5bdf", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, - { id: "food", label: "\u98df\u3079\u7269\u30fb\u6c34\u30fb\u8349", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] }, - { id: "medicine", label: "\u85ac\u30fb\u500b\u4f53\u52b9\u679c", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, - { id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard"] }, - { id: "hazard", label: "\u5371\u967a\u30fb\u969c\u5bb3\u7269", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h"] }, - { id: "freeze", label: "冷凍", themeClass: "tool-category-freeze", order: 99, toolIds: ["freeze"] }, + { id: "operate", label: "操作", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, + { id: "food", label: "飲食", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] }, + { id: "medicine", label: "薬", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, + { id: "habitat", label: "生活", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator", "cannon", "fence_v", "fence_h"] }, + { id: "hazard", label: "危険物", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo"] }, ]); function toolCategories() { @@ -194,12 +203,12 @@ function toolTipsFromDefinitions() { const ALPHA_BOUNDS = { smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 }, angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 }, - teary: { x: 17, y: 17, w: 478, h: 438, imageW: 512, imageH: 472 }, - jito: { x: 18, y: 18, w: 476, h: 451, imageW: 512, imageH: 487 }, + teary: { x: 17, y: 17, w: 474, h: 434, imageW: 508, imageH: 468 }, + jito: { x: 18, y: 18, w: 456, h: 432, imageW: 492, imageH: 468 }, drool: { x: 18, y: 18, w: 476, h: 425, imageW: 512, imageH: 461 }, - cry: { x: 15, y: 15, w: 482, h: 448, imageW: 512, imageH: 478 }, - sleep: { x: 18, y: 18, w: 475, h: 439, imageW: 511, imageH: 475 }, - pokan: { x: 18, y: 18, w: 476, h: 433, imageW: 512, imageH: 469 }, + cry: { x: 15, y: 15, w: 471, h: 438, imageW: 501, imageH: 468 }, + sleep: { x: 18, y: 18, w: 467, h: 432, imageW: 503, imageH: 468 }, + pokan: { x: 18, y: 17, w: 475, h: 434, imageW: 511, imageH: 468 }, stretch: { x: 15, y: 15, w: 482, h: 354, imageW: 512, imageH: 384 }, hurt: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 }, weak: { x: 53, y: 18, w: 405, h: 435, imageW: 512, imageH: 468 }, @@ -218,17 +227,22 @@ const ALPHA_BOUNDS = { stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 }, stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 }, intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 }, - zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 }, + birth_ritual: { x: 0, y: 0, w: 512, h: 456, imageW: 512, imageH: 456 }, + zunchi_slave: { x: 17, y: 22, w: 477, h: 431, imageW: 512, imageH: 468 }, hungry_70: { x: 0, y: 0, w: 430, h: 366, imageW: 430, imageH: 366 }, - zunchi_slave_alt: { x: 42, y: 22, w: 425, h: 426, imageW: 512, imageH: 468 }, + zunchi_slave_alt: { x: 33, y: 56, w: 446, h: 402, imageW: 512, imageH: 468 }, + sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, + sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 }, + sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 }, zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 }, ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 }, ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 }, genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 }, - sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, - sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 }, - sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, + pushpin: { x: 6, y: 6, w: 82, h: 186, imageW: 95, imageH: 200 }, + pushpin_stuck: { x: 7, y: 7, w: 135, h: 135, imageW: 150, imageH: 150 }, + oshibyo: { x: 0, y: 0, w: 198, h: 449, imageW: 198, imageH: 449 }, + oshibyo_stuck: { x: 0, y: 0, w: 247, h: 247, imageW: 247, imageH: 247 }, }; const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"]; diff --git a/js/freeze_system.js b/js/freeze_system.js index 506a2a6..e25f3cc 100644 --- a/js/freeze_system.js +++ b/js/freeze_system.js @@ -44,8 +44,12 @@ const style = document.createElement("style"); style.id = "freezeSystemStyles"; style.textContent = ` - .frozen-panel { margin-top: 10px; padding: 10px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); } - .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:700; font-size:13px; color:#4f5f70; margin-bottom:8px; } + .frozen-panel { margin-top: 10px; padding: 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); } + .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; border:0; background:transparent; padding:0; font-weight:700; font-size:13px; color:#4f5f70; margin:0; cursor:pointer; text-align:left; } + .frozen-panel-title::before { content:"▸"; width:14px; color:rgba(79,95,112,.74); } + .frozen-panel:not(.collapsed) .frozen-panel-title::before { content:"▾"; } + .frozen-panel-body { margin-top:8px; } + .frozen-panel.collapsed .frozen-panel-body { display:none; } .frozen-panel-count { font-size:12px; opacity:.72; } .frozen-list { display:grid; grid-template-columns: 1fr; gap:7px; } .frozen-card { display:flex; align-items:center; gap:8px; width:100%; border:1px solid rgba(72, 98, 124, .20); border-radius:12px; padding:7px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); } @@ -64,16 +68,23 @@ function ensurePanel() { ensureStyles(); let panel = document.getElementById("frozenPanel"); - if (panel) return panel; + if (panel) { + const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body'); + if (operateBody && panel.parentNode !== operateBody) operateBody.appendChild(panel); + return panel; + } panel = document.createElement("section"); panel.id = "frozenPanel"; panel.className = "frozen-panel"; + panel.classList.add("collapsed"); panel.innerHTML = ` -
冷凍個体0
-
+ +
`; + const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body'); const anchor = global.ui?.toolPalette || document.getElementById("toolPalette"); - if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); + if (operateBody) operateBody.appendChild(panel); + else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); else document.body.appendChild(panel); return panel; } @@ -246,6 +257,12 @@ const panel = ensurePanel(); if (panel.dataset.freezeBound !== "1") { panel.dataset.freezeBound = "1"; + panel.addEventListener("click", (e) => { + const title = e.target.closest(".frozen-panel-title"); + if (!title) return; + const collapsed = !panel.classList.toggle("collapsed"); + title.setAttribute("aria-expanded", collapsed ? "true" : "false"); + }); panel.addEventListener("dragstart", (e) => { const card = e.target.closest(".frozen-card[data-frozen-id]"); if (!card) return; diff --git a/js/items.js b/js/items.js index 1989a4b..223bcab 100644 --- a/js/items.js +++ b/js/items.js @@ -174,6 +174,43 @@ function drawBulletSprite(ctx, r) { ctx.restore(); } +function machineDishPosition(machine) { + const r = machine?.r || 42; + return { x: machine.x, y: machine.y + r * 1.18 }; +} + +function machineMuzzlePosition(machine) { + const r = machine?.r || 42; + const a = Number.isFinite(machine?.cannonAngle) ? machine.cannonAngle : 0; + return { x: machine.x + Math.cos(a) * r * 0.98, y: machine.y + Math.sin(a) * r * 0.98 }; +} + +function makeMachineConsumableCopy(type, x, y, source = null, opts = {}) { + const it = new Item(type, x, y); + it.machineProduced = true; + it.machineProducerId = source?.id || ""; + it.sourceMachineType = source?.type || ""; + it.noDecay = true; + if (opts.infiniteServing) it.infiniteServing = true; + if (opts.projectile) { + it.cannonProjectile = true; + it.projectileSourceId = source?.id || ""; + it.projectileTimer = 0; + it.projectileHitMemo = {}; + } + if (typeof isServingFoodType === "function" && isServingFoodType(type)) { + it.toolSize = source?.machineSlotSize || "medium"; + it.foodServingScale = 1; + it.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize) : 5; + it.foodServingsRemaining = it.foodServingsMax; + it.amount = it.foodServingsMax; + } + if (type === "water") it.amount = 120; + if (type === "grass") { it.amount = Math.max(it.amount || 0, 120); it.growth = Math.max(it.growth || 0, 0.82); } + if (type === "zunchi") { it.amount = 240; it.stage = "fresh"; it.stageTimer = 0; it.freshness = 1; } + return it; +} + class Item { constructor(type, x, y) { this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; @@ -206,6 +243,14 @@ class Item { this.text = ""; this.textEditedAt = -999; } + if (type === "duplicator" || type === "cannon") { + this.machineSlotType = ""; + this.machineSlotSize = "medium"; + this.machineSpawnTimer = rand(0.4, 1.1); + this.machineLastSlotType = ""; + this.cannonAngle = type === "cannon" ? -Math.PI * 0.25 : 0; + this.cannonFireTimer = rand(0.8, 1.6); + } if (type === "ball") { this.vx = rand(-5, 5); this.vy = rand(-5, 5); @@ -278,6 +323,8 @@ class Item { this.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval()); this.amount = this.foodServingsRemaining; } + if (window.TarinaiNeedSystem?.itemRolesFor) window.TarinaiNeedSystem.itemRolesFor(this); + if (window.TarinaiNeedSystem?.ensureItemNeedEffects) window.TarinaiNeedSystem.ensureItemNeedEffects(this); } update(dt, worldRef = world) { this.age += dt; @@ -289,8 +336,8 @@ class Item { if (worldRef.itemDropImpact) worldRef.itemDropImpact(this); } } - if (this.type === "water") this.amount -= dt * 0.9; - if (isServingFoodType(this.type)) { + if (this.type === "water" && !this.noDecay && !this.infiniteServing) this.amount -= dt * 0.9; + if (isServingFoodType(this.type) && !this.noDecay && !this.infiniteServing) { const interval = passiveFoodDecayInterval(worldRef); this.passiveFoodDecayTimer = (this.passiveFoodDecayTimer || 0) + dt; let ticks = 0; @@ -344,6 +391,9 @@ class Item { if (this.type === "ball") this.updateBall(dt, worldRef); if (isPinType(this.type)) this.updatePushpin(dt, worldRef); if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); + if (this.cannonProjectile) this.updateConsumableProjectile(dt, worldRef); + if (this.type === "duplicator") this.updateDuplicator(dt, worldRef); + if (this.type === "cannon") this.updateCannon(dt, worldRef); if (this.type === "grass") { this.lifecycleTimer += dt; const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1; @@ -364,6 +414,152 @@ class Item { } } + machineDishPosition() { + return machineDishPosition(this); + } + + clearMachineProducedItems(worldRef, keepType = "") { + for (const it of worldRef?.items || []) { + if (!it || it.dead || it.machineProducerId !== this.id) continue; + if (keepType && it.type === keepType && this.type === "duplicator") continue; + it.amount = 0; + } + } + + updateDuplicator(dt, worldRef) { + if (!worldRef || !this.machineSlotType) return; + if (this.machineLastSlotType && this.machineLastSlotType !== this.machineSlotType) this.clearMachineProducedItems(worldRef); + this.machineLastSlotType = this.machineSlotType; + this.machineSpawnTimer = Math.max(0, (this.machineSpawnTimer || 0) - dt); + const dish = this.machineDishPosition(); + let serving = null; + for (const it of worldRef.items || []) { + if (!it || it.dead || it.machineProducerId !== this.id || it.type !== this.machineSlotType) continue; + serving = it; + break; + } + if (!serving && this.machineSpawnTimer <= 0) { + serving = makeMachineConsumableCopy(this.machineSlotType, dish.x, dish.y, this, { infiniteServing: true }); + serving.r = Math.max(6, (serving.r || 12) * 0.92); + worldRef.items.push(serving); + worldRef.itemCounts[serving.type] = (worldRef.itemCounts[serving.type] || 0) + 1; + this.machineSpawnTimer = 1.2; + worldRef.rebuildSpatial?.(true); + } + if (serving) { + serving.x = dish.x; + serving.y = dish.y; + serving.vx = 0; + serving.vy = 0; + serving.prevX = serving.x; + serving.prevY = serving.y; + serving.noDecay = true; + serving.infiniteServing = true; + serving.machineProduced = true; + serving.machineProducerId = this.id; + if (typeof isServingFoodType === "function" && isServingFoodType(serving.type)) { + if (!Number.isFinite(serving.foodServingsMax) || serving.foodServingsMax <= 0) serving.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(serving.toolSize || "medium") : 5; + serving.foodServingsRemaining = serving.foodServingsMax; + serving.amount = serving.foodServingsMax; + } else if (serving.type === "water") serving.amount = Math.max(serving.amount || 0, 120); + else if (serving.type === "grass") serving.amount = Math.max(serving.amount || 0, 120); + else if (serving.type === "zunchi") serving.amount = Math.max(serving.amount || 0, 240); + } + } + + updateCannon(dt, worldRef) { + if (!worldRef || !this.machineSlotType) return; + this.cannonFireTimer = Math.max(0, (this.cannonFireTimer || 0) - dt); + if (this.cannonFireTimer > 0) return; + this.cannonFireTimer = rand(2.2, 3.4); + const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0; + const muzzle = machineMuzzlePosition(this); + const shot = makeMachineConsumableCopy(this.machineSlotType, muzzle.x, muzzle.y, this, { projectile: true }); + shot.r = Math.max(5, (shot.r || 12) * 0.78); + const speed = rand(470, 620); + shot.vx = Math.cos(a) * speed + rand(-18, 18); + shot.vy = Math.sin(a) * speed + rand(-18, 18); + shot.prevX = shot.x; + shot.prevY = shot.y; + shot.spinVelocity = rand(-6, 6) + speed / Math.max(8, shot.r || 10) * 0.12; + shot.amount = Math.max(shot.amount || 0, typeof isServingFoodType === "function" && isServingFoodType(shot.type) ? (shot.foodServingsMax || 5) : itemAmountFor(shot.type, 60)); + worldRef.items.push(shot); + worldRef.itemCounts[shot.type] = (worldRef.itemCounts[shot.type] || 0) + 1; + worldRef.effects?.push(new Effect("ring", muzzle.x, muzzle.y, { size: Math.max(16, (this.r || 42) * 0.42), life: 0.22, color: "rgba(255,238,160,0.52)" })); + worldRef.log?.(`大砲が${toolLabel(shot.type)}を撃ち出した。`, "accident"); + audio.place?.(shot.type); + worldRef.rebuildSpatial?.(true); + } + + updateConsumableProjectile(dt, worldRef) { + if (!worldRef) return; + this.projectileTimer = (this.projectileTimer || 0) + dt; + this.prevX = this.x; + this.prevY = this.y; + const speedBefore = Math.hypot(this.vx || 0, this.vy || 0); + if (speedBefore > 0.05) { + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 28, bounce: 0.46, frictionBase: 0.62, frictionRate: 1.25, spin: false, stopSpeed: 0.12, zeroBelow: 14 }); + } else { + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.46; } + if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.46; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.46; } + if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.46; } + const slow = Math.pow(0.62, dt * 1.25); + this.vx *= slow; + this.vy *= slow; + } + this.spin = (this.spin || 0) + speedBefore * dt / Math.max(6, this.r || 10); + this.resolveProjectileTarinaiCollision(dt, worldRef, speedBefore); + } + const speedAfter = Math.hypot(this.vx || 0, this.vy || 0); + if (speedAfter < 18 && (this.projectileTimer || 0) > 0.30) { + this.cannonProjectile = false; + this.noDecay = false; + this.projectileHitMemo = {}; + this.vx = 0; + this.vy = 0; + } + if ((this.projectileTimer || 0) > 28) this.cannonProjectile = false; + } + + resolveProjectileTarinaiCollision(dt, worldRef, speed = 0) { + if (!worldRef || speed < 210 || (this.amount || 0) <= 0) return; + const range = Math.max(42, (this.r || 12) + speed * Math.max(dt || 0.016, 0.016) + 22); + for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const d = distXY(this.x, this.y, t.x, t.y); + if (d > (t.radius || 20) * 0.72 + (this.r || 12)) continue; + const now = worldRef.time || 0; + this.projectileHitMemo = this.projectileHitMemo || {}; + if (this.projectileHitMemo[t.id] && this.projectileHitMemo[t.id] + 0.58 > now) continue; + this.projectileHitMemo[t.id] = now; + const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1); + const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1); + const impulse = clamp(speed * 0.95, 130, 560); + t.vx = (t.vx || 0) + nx * impulse + rand(-18, 18); + t.vy = (t.vy || 0) + ny * impulse * 0.70 + rand(-14, 12); + t.fallTimer = Math.max(t.fallTimer || 0, 0.72); + t.fallMax = Math.max(t.fallMax || 0.72, t.fallTimer); + t.fallDir = nx < 0 ? -1 : 1; + const label = toolLabel(this.type) || "食べ物"; + if (t.enterPanic) t.enterPanic({ threat: this, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, fear: 0.72, stress: 6, stressDuration: 3.0, surpriseTimer: 0.48, cause: "cannon_projectile_hit" }); + else t.setActionState?.("panic", { target: { x: this.x, y: this.y }, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, wake: true }); + if (worldRef.applyImpactDamage) worldRef.applyImpactDamage(t, clamp((speed - 190) / 30, 1.4, 16), "大砲"); + worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.50, 0.78); + worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(17, (this.r || 12) * 1.15), life: 0.22, color: "rgba(220,160,70,0.52)" })); + if (worldRef.relationNotice?.(t.id, this.id || "projectile", "cannon-projectile-hit", 2.8)) worldRef.log?.(`${t.name}は大砲から飛んできた${label}にぶつかって吹き飛ばされた。`, "accident", { participants: [t] }); + this.vx *= -0.12; + this.vy *= -0.12; + this.cannonProjectile = false; + this.noDecay = false; + break; + } + } + updateZunchiMotion(dt, worldRef) { const speed = Math.hypot(this.vx || 0, this.vy || 0); if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } @@ -668,9 +864,11 @@ class Item { this.pinState = "lodged"; this.deletable = false; this.pinTargetId = t.id; - this.pinAttachAngle = isOshibyo ? 0.62 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1)); - this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.58 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); - this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.30 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + const visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1; + this.pinAttachAngle = isOshibyo ? (visualSide < 0 ? -0.48 : 0.48) : Math.atan2(dy || -1, dx || visualSide); + this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.76 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); + this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.22 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + this.pinFlipX = visualSide; this.pinDamageTick = 0; this.pinFallCheckTimer = 0; this.vx = 0; @@ -714,11 +912,13 @@ class Item { const isOshibyo = Boolean(behavior?.blocksZunchi); t.stuckPushpinId = this.id; this.deletable = false; - this.x = isOshibyo ? t.x + (t.radius || 16) * 0.42 : t.x + (t.radius || 16) * 0.36; - this.y = isOshibyo ? t.y + (t.radius || 16) * 0.34 : t.y - (t.radius || 16) * 0.04; + const visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1; + this.pinFlipX = visualSide; + this.x = t.x + visualSide * (isOshibyo ? (t.radius || 16) * 0.72 : (t.radius || 16) * 0.36); + this.y = isOshibyo ? t.y + (t.radius || 16) * 0.22 : t.y - (t.radius || 16) * 0.04; this.prevX = this.x; this.prevY = this.y; - this.spin = isOshibyo ? 0.52 : 0.28; + this.spin = (isOshibyo ? 0.42 : 0.28) * (this.pinFlipX < 0 ? -1 : 1); this.vx = t.vx || 0; this.vy = t.vy || 0; if (isOshibyo) { @@ -1114,20 +1314,89 @@ class Item { ctx.fill(); } ctx.restore(); + } else if (this.type === "duplicator" || this.type === "cannon") { + const r = this.r || 42; + const isCannon = this.type === "cannon"; + ctx.save(); + ctx.shadowColor = "rgba(40,35,30,0.24)"; + ctx.shadowBlur = 6; + ctx.shadowOffsetY = 3; + if (!isCannon) { + // Upper machine body. + const g = ctx.createLinearGradient(0, -r * 1.0, 0, r * 0.32); + g.addColorStop(0, "#e7f1ff"); + g.addColorStop(1, "#8aa2bd"); + ctx.fillStyle = g; + ctx.strokeStyle = "rgba(48,68,92,0.70)"; + ctx.lineWidth = Math.max(2, r * 0.06); + roundedRect(ctx, -r * 0.82, -r * 1.05, r * 1.64, r * 0.92, r * 0.18); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = this.machineSlotType ? "rgba(100,214,102,0.92)" : "rgba(255,180,72,0.92)"; + ctx.beginPath(); ctx.arc(r * 0.48, -r * 0.76, r * 0.12, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = "rgba(42,55,68,0.54)"; + ctx.lineWidth = Math.max(1.6, r * 0.04); + ctx.beginPath(); ctx.moveTo(-r * 0.48, -r * 0.78); ctx.lineTo(r * 0.20, -r * 0.78); ctx.stroke(); + // Plate for tarinai below. + ctx.fillStyle = "rgba(245,244,233,0.96)"; + ctx.strokeStyle = "rgba(116,96,75,0.58)"; + ctx.lineWidth = Math.max(2, r * 0.05); + ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.95, r * 0.34, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(196,180,154,0.22)"; + ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.58, r * 0.16, 0, 0, Math.PI * 2); ctx.fill(); + if (this.machineSlotType) { + ctx.fillStyle = "rgba(42,36,28,0.72)"; + ctx.font = `${Math.max(10, Math.round(r * 0.25))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(toolLabel(this.machineSlotType), 0, r * 0.52); + } + } else { + const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0; + // Base plate. + ctx.fillStyle = "#6c7685"; + ctx.strokeStyle = "rgba(42,48,58,0.72)"; + ctx.lineWidth = Math.max(2, r * 0.06); + ctx.beginPath(); ctx.ellipse(0, r * 0.72, r * 0.78, r * 0.30, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + // Barrel. + ctx.rotate(a); + const bg = ctx.createLinearGradient(-r * 0.34, -r * 0.26, r * 1.08, r * 0.26); + bg.addColorStop(0, "#596575"); + bg.addColorStop(0.55, "#2f3946"); + bg.addColorStop(1, "#1f2630"); + ctx.fillStyle = bg; + ctx.strokeStyle = "rgba(16,20,26,0.82)"; + roundedRect(ctx, -r * 0.28, -r * 0.26, r * 1.32, r * 0.52, r * 0.18); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(10,12,16,0.88)"; + ctx.beginPath(); ctx.ellipse(r * 1.02, 0, r * 0.16, r * 0.23, 0, 0, Math.PI * 2); ctx.fill(); + ctx.rotate(-a); + ctx.fillStyle = "#8793a5"; + ctx.beginPath(); ctx.arc(0, 0, r * 0.36, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + if (this.machineSlotType) { + ctx.fillStyle = "rgba(42,36,28,0.72)"; + ctx.font = `${Math.max(10, Math.round(r * 0.23))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(toolLabel(this.machineSlotType), 0, -r * 0.62); + } + } + ctx.restore(); } else if (isPinType(this.type)) { const stuck = this.pinState === "lodged"; const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin"); + const isOshibyo = Boolean(behavior?.blocksZunchi); const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId); ctx.save(); if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5); + else if ((this.pinFlipX || 1) < 0) ctx.scale(-1, 1); ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)"; ctx.shadowBlur = 3; ctx.shadowOffsetY = 2; if (img) { const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; const ratio = metrics?.ratio || (stuck ? 1 : 2.0); - const w = isOshibyo ? (stuck ? this.r * 3.1 : this.r * 1.55) : (stuck ? this.r * 2.15 : this.r * 1.78); + const w = isOshibyo ? (stuck ? this.r * 2.45 : this.r * 1.25) : (stuck ? this.r * 2.15 : this.r * 1.78); const h = w * ratio; ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); } else if (stuck) { diff --git a/js/main.js b/js/main.js index 8003425..0c32d50 100644 --- a/js/main.js +++ b/js/main.js @@ -134,7 +134,7 @@ loadImages(null, (done, total) => { if ("serviceWorker" in navigator) { window.addEventListener("load", () => { - const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.20.16"; + const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.30.0"; navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined); }); } diff --git a/js/need_behavior_system.js b/js/need_behavior_system.js new file mode 100644 index 0000000..6681fc7 --- /dev/null +++ b/js/need_behavior_system.js @@ -0,0 +1,547 @@ +"use strict"; + +(function (global) { + const NEED_KEYS = Object.freeze(["food", "sleep", "health", "safety", "social", "fulfill"]); + const NEED_LABELS = Object.freeze({ + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", + }); + const NEED_PHRASES = Object.freeze({ + food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b", + sleep: "\u306d\u3080\u3044", + health: "\u8abf\u5b50\u304c\u60aa\u3044", + safety: "\u3042\u3076\u306a\u3044", + social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", + fulfill: "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b", + }); + const NEED_PRIORITY = Object.freeze(["safety", "health", "food", "sleep", "social", "fulfill"]); + + function createId(prefix = "id") { + if (global.crypto?.randomUUID) return `${prefix}_${global.crypto.randomUUID()}`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`; + } + + function createDefaultNeeds() { + return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + } + + function quantizeNeed(value) { + return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10)); + } + + function getNeedDisplayValue(value) { + return Math.round(quantizeNeed(value) / 10); + } + + function personalityValue(t, key) { + if (typeof global.effectivePersonalityValue === "function") return Number(global.effectivePersonalityValue(t, key)) || 0; + return Number(t?.currentPersonality?.[key] ?? t?.birthPersonality?.[key] ?? 0) || 0; + } + + function needPressure(value) { + const x = clamp((Number(value) || 0) / 100, 0, 1); + return x * x * 100; + } + + function deriveStressFromNeeds(needs = {}) { + return clamp( + needPressure(needs.food) * 0.10 + + needPressure(needs.sleep) * 0.12 + + needPressure(needs.health) * 0.25 + + needPressure(needs.safety) * 0.30 + + needPressure(needs.social) * 0.08 + + needPressure(needs.fulfill) * 0.15, + 0, + 130 + ); + } + + function itemRolesFor(item) { + if (!item) return {}; + if (!item.roles) item.roles = {}; + const type = String(item.type || ""); + const roles = item.roles; + if (["sweet", "food", "grass", "ant_corpse", "zunchi", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(type)) roles.food = true; + if (type === "water" || type === "water_bowl") roles.drink = true; + if (["sweet", "protein", "water", "water_bowl", "zunda_juice"].includes(type)) roles.medicine = true; + if (type === "bed" || type === "nest_box") roles.sleepPlace = true; + if (["firecracker", "genkotsu", "pushpin", "oshibyo", "splat", "zunchi", "ant_nest", "cannon"].includes(type)) roles.danger = true; + if (type === "grass") roles.grassMaterial = true; + if (type === "ball") roles.playObject = true; + if (item.isStructure) roles.ownedStructure = true; + return roles; + } + + function ensureItemNeedEffects(item) { + if (!item) return createDefaultNeeds(); + if (!item.needEffects) item.needEffects = createDefaultNeeds(); + return item.needEffects; + } + + function findNearestItemWithRole(world, t, role, maxDist = 640, predicate = null) { + let best = null; + let bestD = maxDist; + const items = Number.isFinite(maxDist) && world?.nearbyItems ? world.nearbyItems(t.x, t.y, maxDist) : (world?.items || []); + for (const it of items) { + if (!it || it.dead || (it.amount != null && it.amount <= 0)) continue; + const roles = itemRolesFor(it); + if (!roles[role]) continue; + if (predicate && !predicate(it)) continue; + if (t?.shouldAvoidTarget?.(it)) continue; + const d = distXY(t.x, t.y, it.x, it.y); + if (d < bestD) { best = it; bestD = d; } + } + return best; + } + + function findNearestSleepPlace(world, t, maxDist = 560) { + const own = findOwnedStructure(world, t, "grass_bed", maxDist); + return own || findNearestItemWithRole(world, t, "sleepPlace", maxDist); + } + + function findNearbyDanger(world, t, maxDist = 180) { + const ant = (world?.nearbyAnts?.(t.x, t.y, maxDist, true) || []).find(a => a && !a.dead); + if (ant) return ant; + return findNearestItemWithRole(world, t, "danger", maxDist); + } + + function findNearbyMaterial(world, t, materialRole, maxDist = 520) { + return findNearestItemWithRole(world, t, materialRole, maxDist); + } + + function ownedStructures(world, t, type = "") { + return (world?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id && (!type || it.type === type)); + } + + function findOwnedStructure(world, t, type = "", maxDist = Infinity) { + let best = null; + let bestD = maxDist; + for (const it of ownedStructures(world, t, type)) { + const d = distXY(t.x, t.y, it.x, it.y); + if (d < bestD) { best = it; bestD = d; } + } + return best; + } + + function hasOwnedStructure(world, t, type) { + return Boolean(findOwnedStructure(world, t, type)); + } + + function canActNormally(t) { + return !t.dead && t.state !== "sleep" && t.state !== "fight" && t.state !== "panic" && t.state !== "intimidate" && t.birthRitualTimer <= 0.04 && t.fightTimer <= 0.04 && t.defeatedTimer <= 0.04; + } + + function updateAttachedStructures(world, t) { + for (const s of ownedStructures(world, t)) { + if (s.type !== "plushie" || s.carriedById !== t.id || !s.onHead) continue; + s.x = t.x + (t.facingDir ? t.facingDir() : 1) * t.radius * 0.12; + s.y = t.y - t.radius * 1.10; + s.amount = Math.max(1, s.hp || 1); + if (t.needs) t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 0.08) }; + } + } + + function updateNeeds(t, world) { + const prev = t.needsInitialized && t.needs ? { ...t.needs } : null; + const pAgg = personalityValue(t, "aggression"); + const pOpen = personalityValue(t, "openness"); + const pSoc = personalityValue(t, "sociability"); + const pNeu = personalityValue(t, "neuroticism"); + const raw = createDefaultNeeds(); + raw.food = clamp((t.hunger || 0) * 0.88 + (findNearestItemWithRole(world, t, "drink", 120) ? 6 : 0), 0, 100); + const dayP = world?.dayProgress?.() ?? 0.5; + const night = dayP >= 0.62 || dayP < 0.18 ? 12 : 0; + raw.sleep = clamp((100 - (t.energy || 0)) * 0.98 + night, 0, 100); + raw.health = clamp( + (t.zunchiDisease ? 38 : 0) + (t.sleepDisease ? 22 : 0) + (t.explosionDisease ? 30 : 0) + (t.fightDisease ? 34 : 0) + + clamp((100 - (t.energy || 100)) * 0.22, 0, 26) + clamp((t.zunchiStain || 0) * 0.34, 0, 34) + Number(t.needShock?.health || 0), + 0, + 100 + ); + const danger = findNearbyDanger(world, t, 210); + const recentDamage = (world?.time || 0) - (t.lastDamageAt || -999) < 8 ? clamp((t.lastDamageAmount || 0) * 2.4, 0, 52) : 0; + const fear = clamp((t.fearTimer || 0) * 22, 0, 45); + raw.safety = clamp((danger ? 42 : 0) + recentDamage + fear + Number(t.needShock?.safety || 0), 0, 100); + raw.social = clamp((t.loneliness || 0) * (0.78 + Math.max(0, pSoc) * 0.16) + (t.reproductionTimer <= 4 ? 18 : 0), 0, 100); + const hasBed = hasOwnedStructure(world, t, "grass_bed"); + const hasPlushie = hasOwnedStructure(world, t, "plushie"); + const play = findNearestItemWithRole(world, t, "playObject", 280) ? 10 : 0; + raw.fulfill = clamp((hasBed ? 0 : 26) + (hasPlushie ? 0 : 20) + play + Math.max(0, pOpen) * 16 + Number(t.needShock?.fulfill || 0), 0, 100); + raw.safety *= clamp(1 - Math.max(0, pAgg) * 0.12 + Math.max(0, pNeu) * 0.16, 0.78, 1.22); + raw.health *= clamp(1 + Math.max(0, pNeu) * 0.12, 0.9, 1.16); + raw.social *= clamp(1 + Math.max(0, pSoc) * 0.12, 0.9, 1.16); + raw.fulfill *= clamp(1 + Math.max(0, pOpen) * 0.12, 0.9, 1.18); + const needs = createDefaultNeeds(); + for (const key of NEED_KEYS) needs[key] = quantizeNeed(raw[key]); + t.previousNeeds = prev; + t.needs = needs; + t.needsInitialized = true; + t.stress = deriveStressFromNeeds(needs); + handleNeedShockReaction(t, prev, needs); + return needs; + } + + function handleNeedShockReaction(t, prev, needs, breaker = null) { + if (!prev || !needs || t.birthRitualTimer > 0.04 || t.fightTimer > 0.04) return false; + const safetyDelta = (needs.safety || 0) - (prev.safety || 0); + const healthDelta = (needs.health || 0) - (prev.health || 0); + const fulfillDelta = (needs.fulfill || 0) - (prev.fulfill || 0); + if (safetyDelta < 40 && healthDelta < 45 && fulfillDelta < 50) return false; + const attacker = breaker || t.needShockBreaker || null; + if (attacker && personalityValue(t, "aggression") > 0.6) { + t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.6); + t.intimidateTargetId = attacker.id || null; + t.setActionState?.("intimidate", { target: attacker, reason: "\u5927\u4e8b\u306a\u3082\u306e\u3092\u58ca\u3055\u308c\u3066\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true }); + } else { + t.panicTimer = Math.max(t.panicTimer || 0, 3.0); + t.enterPanic?.({ target: t.panicDestination?.(attacker, true) || null, reason: "\u304a\u3069\u308d\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.8, wake: true, cause: "need_shock" }); + } + t.needShockBreaker = null; + return true; + } + + function addNeedShock(t, values = {}, breaker = null) { + if (!t) return; + const shock = { ...(t.needShock || {}) }; + for (const key of NEED_KEYS) shock[key] = clamp(Number(shock[key] || 0) + Number(values[key] || 0), 0, 100); + t.needShock = shock; + t.needShockBreaker = breaker || t.needShockBreaker || null; + const prev = t.needsInitialized && t.needs ? { ...t.needs } : null; + updateNeeds(t, t.world); + handleNeedShockReaction(t, prev, t.needs, breaker); + } + + function decayNeedShock(t, dt) { + if (!t?.needShock) return; + for (const key of NEED_KEYS) t.needShock[key] = Math.max(0, Number(t.needShock[key] || 0) - dt * (key === "fulfill" ? 2.2 : 2.8)); + } + + function chooseTopNeedRandom(needs) { + const maxNeed = Math.max(...NEED_KEYS.map(key => Number(needs?.[key] || 0))); + const tiedNeeds = NEED_KEYS.filter(key => Number(needs?.[key] || 0) === maxNeed); + return tiedNeeds[Math.floor(Math.random() * tiedNeeds.length)] || "fulfill"; + } + + function weightedRandom(items) { + const total = items.reduce((sum, a) => sum + Math.max(0.01, Number(a.weight || 1)), 0); + let pickPoint = Math.random() * total; + for (const item of items) { + pickPoint -= Math.max(0.01, Number(item.weight || 1)); + if (pickPoint <= 0) return item; + } + return items[items.length - 1] || null; + } + + function buildReasonText(chosenNeed, tiedNeeds, action) { + const tied = Array.isArray(tiedNeeds) ? tiedNeeds : [chosenNeed]; + const ignored = NEED_PRIORITY.find(key => key !== chosenNeed && tied.includes(key)); + const phrase = action?.phrase || "\u5f85\u3063\u305f"; + if (ignored) return `${NEED_PHRASES[ignored]}\u3051\u3069\u3001${phrase}\u3002`; + return `${NEED_PHRASES[chosenNeed]}\u306e\u3067\u3001${phrase}\u3002`; + } + + function moveTowardOrUse(t, target, state, label) { + if (!target) return false; + if (distXY(t.x, t.y, target.x, target.y) > Math.max(34, (target.r || 12) + t.radius * 0.75)) { + t.setActionState?.(state, { target, reason: label }); + return true; + } + return false; + } + + function createStructure(world, owner, type, x, y) { + const def = StructureRegistry[type]; + if (!def) return null; + const structure = def.create(owner, x, y, world); + structure.update = updateStructure; + structure.draw = drawStructure; + structure.damage = damageStructure; + world.items.push(structure); + world.itemCounts[structure.type] = (world.itemCounts[structure.type] || 0) + 1; + world.markTerrainDirty?.(`structure:${structure.type}`); + world.rebuildSpatial?.(true); + return structure; + } + + function consumeGrassMaterial(grass) { + if (!grass || grass.dead) return false; + grass.amount = Math.max(0, Number(grass.amount || 0) - 34); + grass.growth = clamp((grass.growth || 0.5) - 0.24, 0.04, 1.2); + if (grass.amount <= 1) grass.dead = true; + return true; + } + + function finishBuild(t, type, grass) { + const world = t.world; + const x = clamp(t.x + rand(-18, 18), CONFIG.worldPadding, world.w - CONFIG.worldPadding); + const y = clamp(t.y + rand(10, 30), CONFIG.worldPadding, world.h - CONFIG.worldPadding); + consumeGrassMaterial(grass); + const s = createStructure(world, t, type, x, y); + if (s) { + t.buildTimer = 0; + t.buildType = ""; + t.buildMaterialId = ""; + t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 26) }; + StructureRegistry[type].onUse?.(t, s, world); + } + return Boolean(s); + } + + function runBuildAction(t, type, label) { + const grass = findNearbyMaterial(t.world, t, "grassMaterial", 520); + if (!grass) return false; + if (moveTowardOrUse(t, grass, "seek_food", label)) { + t.buildType = type; + t.buildMaterialId = grass.id; + return true; + } + t.buildType = type; + t.buildMaterialId = grass.id; + t.buildTimer = Math.max(0, Number(t.buildTimer || 0)) + 1; + t.setActionState?.("idle", { target: grass, reason: label }); + if (t.buildTimer >= (StructureRegistry[type]?.buildTime || 4)) return finishBuild(t, type, grass); + return true; + } + + const TARINAI_ACTIONS = [ + { id: "eat_food", need: "food", label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u98df\u3079\u305f", weight: 50, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it))), + run(t, world) { const item = findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it)); return moveTowardOrUse(t, item, "seek_food", this.label) || t.beginEating?.(item, this.label); } }, + { id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 22, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "drink", 620)), + run(t, world) { const item = findNearestItemWithRole(world, t, "drink", 620); return moveTowardOrUse(t, item, "seek_water", this.label) || (t.setActionState?.("seek_water", { target: item, reason: this.label }), true); } }, + { id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42, + condition: (t, world) => Boolean(findNearestSleepPlace(world, t, 620)), + run(t, world) { const bed = findNearestSleepPlace(world, t, 620); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; return t.startSleeping?.(bed, this.label); } }, + { id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true, + run(t) { return t.startSleeping?.(null, this.label); } }, + { id: "use_medicine", need: "health", label: "\u4f53\u306b\u3088\u3055\u305d\u3046\u306a\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u624b\u5f53\u3066\u3057\u305f", weight: 35, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "medicine", 620)), + run(t, world) { const item = findNearestItemWithRole(world, t, "medicine", 620); return moveTowardOrUse(t, item, itemRolesFor(item).drink ? "seek_water" : "seek_food", this.label) || t.beginEating?.(item, this.label); } }, + { id: "rest_to_recover", need: "health", label: "\u4f53\u3092\u4f11\u3081\u3066\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true, + run(t) { t.setActionState?.("idle", { target: null, reason: this.label }); t.energy = clamp((t.energy || 0) + 1.0, 0, 100); return true; } }, + { id: "flee", need: "safety", label: "\u96e2\u308c\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u9003\u3052\u305f", weight: 32, + condition: (t, world) => Boolean(findNearbyDanger(world, t, 260)), + run(t, world) { const danger = findNearbyDanger(world, t, 260); t.setActionState?.("panic", { target: t.panicDestination?.(danger, true) || null, reason: this.label, wake: true }); t.panicTimer = Math.max(t.panicTimer || 0, 2.0); return true; } }, + { id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u308d\u3046\u3068\u3057\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 28, + condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)), + run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return t.startSleeping?.(bed, this.label); } }, + { id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38, + condition: (t, world) => Boolean(world.nearestOther?.(t, 560)), + run(t, world) { const other = world.nearestOther?.(t, 560); t.setActionState?.(t.parentToFollow?.() === other ? "follow_parent" : "seek_friend", { target: other, reason: this.label }); return true; } }, + { id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 8, + condition: (t, world) => personalityValue(t, "aggression") > 0.45 && Boolean(world.nearestOther?.(t, 220)), + run(t, world) { const other = world.nearestOther?.(t, 220); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.0); t.intimidateTargetId = other?.id || null; t.setActionState?.("intimidate", { target: other, reason: this.label }); return true; } }, + { id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 26, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "playObject", 460)), + run(t, world) { const ball = findNearestItemWithRole(world, t, "playObject", 460); t.setActionState?.("play_ball", { target: ball, reason: this.label }); t.goodMode = "smile"; return true; } }, + { id: "build_grass_bed", need: "fulfill", label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3092\u4f5c\u3063\u305f", weight: 24, + condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "grass_bed") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)), + run(t) { return runBuildAction(t, "grass_bed", this.label); } }, + { id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 18, + condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "plushie") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)), + run(t) { return runBuildAction(t, "plushie", this.label); } }, + { id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 18, + condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)), + run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return true; } }, + { id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u5927\u4e8b\u306b\u3057\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3067\u843d\u3061\u7740\u3044\u305f", weight: 22, + condition: (t, world) => Boolean(findOwnedStructure(world, t, "plushie", Infinity)), + run(t, world) { const plushie = findOwnedStructure(world, t, "plushie", Infinity); StructureRegistry.plushie.onUse(t, plushie, world); t.setActionState?.("idle", { target: plushie, reason: this.label }); return true; } }, + { id: "wander_lightly", need: "fulfill", label: "\u8efd\u304f\u6563\u6b69\u3057\u3066\u3044\u308b", phrase: "\u6563\u6b69\u3057\u305f", weight: 12, condition: () => true, + run(t) { t.wanderAngle += rand(-1.7, 1.7); t.setActionState?.("idle", { target: null, reason: this.label }); return true; } }, + ]; + + function chooseActionForNeed(need, t, world) { + const candidates = TARINAI_ACTIONS.filter(a => a.need === need && (!a.condition || a.condition(t, world))); + return weightedRandom(candidates) || TARINAI_ACTIONS.find(a => a.id === "wander_lightly"); + } + + function runNeedDecision(t, world, dt = 0.1) { + updateAttachedStructures(world, t); + decayNeedShock(t, dt); + const needs = updateNeeds(t, world); + const maxNeed = Math.max(...NEED_KEYS.map(key => needs[key])); + const tiedNeeds = NEED_KEYS.filter(key => needs[key] === maxNeed); + const chosenNeed = chooseTopNeedRandom(needs); + const action = chooseActionForNeed(chosenNeed, t, world); + if (!action || !action.run(t, world, dt)) { + t.setActionState?.("idle", { target: null, reason: "\u5f85\u3063\u3066\u3044\u308b" }); + } + const reasonText = buildReasonText(chosenNeed, tiedNeeds, action); + t.intent = { + need: chosenNeed, + tiedNeeds: tiedNeeds.slice(), + actionId: action?.id || "idle", + actionLabel: action?.label || "\u5f85\u3063\u3066\u3044\u308b", + reasonText, + }; + t.need = chosenNeed; + t.thought = reasonText; + return t.intent; + } + + function updateStructure(dt, world) { + if (this.dead) return; + this.age = (this.age || 0) + dt; + if (this.type === "plushie" && this.carriedById) { + const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === this.carriedById); + if (owner) { + this.x = owner.x + (owner.facingDir ? owner.facingDir() : 1) * owner.radius * 0.12; + this.y = owner.y - owner.radius * 1.10; + } else { + this.carriedById = ""; + this.onHead = false; + } + } + this.amount = Math.max(0, this.hp || 0); + } + + function damageStructure(amount = 1, breaker = null, world = null) { + this.hp = Math.max(0, Number(this.hp || 0) - Math.max(0, Number(amount) || 0)); + if (this.hp > 0) return false; + const w = world || global.world; + const def = StructureRegistry[this.type]; + def?.onDestroyed?.(this, w, breaker); + this.dead = true; + this.amount = 0; + w?.markTerrainDirty?.(`structure-destroyed:${this.type}`); + return true; + } + + function drawStructure(ctx, time, lighting) { + const r = this.r || (this.type === "plushie" ? 14 : 28); + ctx.save(); + ctx.translate(this.x, this.y); + if (this.type === "grass_bed") { + ctx.fillStyle = "rgba(175, 196, 102, 0.88)"; + ctx.strokeStyle = "rgba(90, 118, 55, 0.72)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.ellipse(0, 3, r * 1.55, r * 0.74, -0.08, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = "rgba(71, 112, 48, 0.55)"; + ctx.lineWidth = 1.6; + for (let i = 0; i < 15; i++) { + const x = randSeed((this.seed || 1) + i, -r * 1.1, r * 1.1); + const y = randSeed((this.seed || 1) + i + 20, -r * 0.32, r * 0.46); + const len = randSeed((this.seed || 1) + i + 40, r * 0.30, r * 0.68); + const a = randSeed((this.seed || 1) + i + 60, -0.7, 0.7); + ctx.beginPath(); + ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5); + ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5); + ctx.stroke(); + } + } else if (this.type === "plushie") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : null; + ctx.rotate(Math.sin(time * 2.2 + (this.seed || 0)) * 0.08); + if (img) ctx.drawImage(img, -r * 0.8, -r * 0.8, r * 1.6, r * 1.45); + else { + ctx.fillStyle = "#f4ead6"; + ctx.strokeStyle = "rgba(92,71,49,0.48)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.ellipse(0, 0, r * 0.68, r * 0.58, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); + } + + const StructureRegistry = { + grass_bed: { + type: "grass_bed", + label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 100, + materials: { grassMaterial: 1 }, + roles: { sleepPlace: true, ownedStructure: true }, + needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 }, + create(owner, x, y) { + return { id: createId("structure"), type: "grass_bed", isStructure: true, ownerId: owner.id, hp: 100, attachment: 20, usedCount: 0, x, y, r: 28, amount: 100, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } }; + }, + onUse(user, structure) { + const owner = user?.id === structure?.ownerId; + structure.usedCount = (structure.usedCount || 0) + 1; + structure.attachment = clamp((structure.attachment || 0) + (owner ? 2 : 0.4), 0, 100); + user.needShock = { ...(user.needShock || {}) }; + user.needShock.sleep = Math.max(0, Number(user.needShock.sleep || 0) - (owner ? 18 : 8)); + user.needShock.safety = Math.max(0, Number(user.needShock.safety || 0) - (owner ? 12 : 4)); + user.needShock.fulfill = Math.max(0, Number(user.needShock.fulfill || 0) - (owner ? 18 : 6)); + }, + onDestroyed(structure, world, breaker) { + const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId); + if (owner) addNeedShock(owner, { safety: 45, fulfill: 52 }, breaker); + }, + }, + plushie: { + type: "plushie", + label: "\u306c\u3044\u3050\u308b\u307f", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 1, + materials: { grassMaterial: 1 }, + roles: { carriedObject: true, ownedStructure: true, fulfillObject: true }, + needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 }, + create(owner, x, y) { + return { id: createId("structure"), type: "plushie", isStructure: true, ownerId: owner.id, hp: 1, attachment: 30, usedCount: 0, x, y, r: 14, amount: 1, carriedById: owner.id, onHead: true, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } }; + }, + onUse(user, structure) { + if (!user || !structure) return; + structure.attachment = clamp((structure.attachment || 0) + 1.5, 0, 100); + user.needShock = { ...(user.needShock || {}), fulfill: Math.max(0, Number(user.needShock?.fulfill || 0) - 20), safety: Math.max(0, Number(user.needShock?.safety || 0) - 3) }; + }, + onDestroyed(structure, world, breaker) { + const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId); + if (owner) addNeedShock(owner, { safety: 58, fulfill: 70 }, breaker); + }, + }, + }; + + global.TarinaiNeedSystem = { + NEED_KEYS, + NEED_LABELS, + NEED_PHRASES, + TARINAI_ACTIONS, + StructureRegistry, + createDefaultNeeds, + quantizeNeed, + updateNeeds, + chooseTopNeedRandom, + chooseActionForNeed, + buildReasonText, + getNeedDisplayValue, + findNearestItemWithRole, + findNearestSleepPlace, + findNearbyDanger, + findNearbyMaterial, + findOwnedStructure, + ownedStructures, + itemRolesFor, + ensureItemNeedEffects, + deriveStressFromNeeds, + addNeedShock, + runNeedDecision, + createStructure, + damageStructure, + }; + + global.createDefaultNeeds = createDefaultNeeds; + global.quantizeNeed = quantizeNeed; + global.updateNeeds = updateNeeds; + global.chooseTopNeedRandom = chooseTopNeedRandom; + global.chooseActionForNeed = chooseActionForNeed; + global.buildReasonText = buildReasonText; + global.getNeedDisplayValue = getNeedDisplayValue; + global.findNearestItemWithRole = findNearestItemWithRole; + global.findNearestSleepPlace = findNearestSleepPlace; + global.findNearbyDanger = findNearbyDanger; + global.findNearbyMaterial = findNearbyMaterial; + global.StructureRegistry = StructureRegistry; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index 5ea4cb6..4f06772 100644 --- a/js/render.js +++ b/js/render.js @@ -249,8 +249,8 @@ const terrainCache = { function isCachedTerrainItem(it) { if (!it || it.dead) return false; + if (it.machineProduced) return false; if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true; - if (it.type === "zunchi") return Math.hypot(it.vx || 0, it.vy || 0) < 0.08; return false; } @@ -740,15 +740,6 @@ function render() { for (const ef of world.effects) { if (ef.type === "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } - for (const it of world.items) { - if (isCachedTerrainItem(it)) continue; - if (isPinType(it.type) && it.pinState === "lodged") continue; - if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); - } - for (const it of world.items) { - if (it.type === "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); - } - ctx.save(); ctx.lineWidth = 1.15; ctx.setLineDash([4, 4]); @@ -765,13 +756,20 @@ function render() { ctx.setLineDash([]); ctx.restore(); - const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai; - for (const t of sorted) { - if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting); - } - for (const it of world.items) { - if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + const fieldEntries = []; + const pushFieldEntry = (entity, kind, order = 0) => { + if (!entity || entity.dead || !isEntityVisibleInRect(entity, visibleRect)) return; + fieldEntries.push({ entity, kind, order, y: Number.isFinite(entity.y) ? entity.y : 0 }); + }; + for (const it of world.items || []) { + if (isCachedTerrainItem(it)) continue; + const layerOffset = it.type === "plushie" && it.onHead ? 999 : (it.type === "bed" ? -1.0 : ((typeof isMachineType === "function" && isMachineType(it.type)) ? (it.r || 42) * 0.58 : (isPinType(it.type) && it.pinState === "lodged" ? 0.15 : 0))); + pushFieldEntry(it, "item", layerOffset); } + for (const t of world.tarinai || []) pushFieldEntry(t, "actor", 0.1); + for (const a of world.ants || []) pushFieldEntry(a, "actor", 0.05); + fieldEntries.sort((a, b) => ((a.y + a.order) - (b.y + b.order))); + for (const entry of fieldEntries) entry.entity.draw(ctx, world.time, lighting); for (const ef of world.effects) { if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } diff --git a/js/save_system.js b/js/save_system.js index 00cab54..4840cf8 100644 --- a/js/save_system.js +++ b/js/save_system.js @@ -3,11 +3,81 @@ (function (global) { const STORAGE_PREFIX = "tarinai_save_slot_v1_"; const EXPORT_PREFIX = "TARINAI_SAVE_V1:"; + const EXPORT_PREFIX_COMPRESSED = "TARINAI-SAVE-V11:"; + // Alpha note: save/import compatibility is not guaranteed yet. + // Hash imports intentionally accept only the current V11 format during alpha. + // V11 stores compact semantic early-game saves when possible, falls back to the V10 binary packet for complex states, and encodes bytes with printable ASCII excluding \, *, ' and _. const SLOT_COUNT = 9; const Snapshot = global.TarinaiSnapshot; if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js"); const createSnapshot = (...args) => Snapshot.createSnapshot(...args); const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args); + const htmlEscape = global.TarinaiUIHelpers?.htmlEscape || ((v) => String(v ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]))); + + + const BASE94_ALPHABET = (() => { + const excluded = new Set(["\\", "*", "'", "_"]); + let out = ""; + for (let i = 33; i <= 126; i++) { + const ch = String.fromCharCode(i); + if (!excluded.has(ch)) out += ch; + } + return out; + })(); + const BASE94_RADIX = BASE94_ALPHABET.length; + const BASE94_DECODE = (() => { + const map = Object.create(null); + for (let i = 0; i < BASE94_ALPHABET.length; i++) map[BASE94_ALPHABET[i]] = i; + return map; + })(); + + function bytesToBase94(bytes) { + if (!bytes || !bytes.length) return "!"; + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + const digits = [0]; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + for (let j = 0; j < digits.length; j++) { + const x = digits[j] * 256 + carry; + digits[j] = x % BASE94_RADIX; + carry = Math.floor(x / BASE94_RADIX); + } + while (carry > 0) { + digits.push(carry % BASE94_RADIX); + carry = Math.floor(carry / BASE94_RADIX); + } + } + let out = BASE94_ALPHABET[0].repeat(zeros); + for (let i = digits.length - 1; i >= 0; i--) out += BASE94_ALPHABET[digits[i]]; + return out || BASE94_ALPHABET[0]; + } + + function base94ToBytes(text) { + const str = String(text || "").replace(/\s/g, ""); + if (!str) return new Uint8Array(); + let zeros = 0; + while (zeros < str.length && str[zeros] === BASE94_ALPHABET[0]) zeros++; + const bytes = [0]; + for (let i = zeros; i < str.length; i++) { + const digit = BASE94_DECODE[str[i]]; + if (digit === undefined) throw new Error("invalid base94 character"); + let carry = digit; + for (let j = 0; j < bytes.length; j++) { + const x = bytes[j] * BASE94_RADIX + carry; + bytes[j] = x & 255; + carry = x >> 8; + } + while (carry > 0) { + bytes.push(carry & 255); + carry >>= 8; + } + } + const out = new Uint8Array(zeros + bytes.length); + for (let i = 0; i < zeros; i++) out[i] = 0; + for (let i = 0; i < bytes.length; i++) out[out.length - 1 - i] = bytes[i]; + return out; + } function bytesToBase64(bytes) { let binary = ""; @@ -16,8 +86,18 @@ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } + function stripExportPrefix(text, prefixes) { + let value = String(text || "").trim(); + const hashless = value.replace(/^#+/, ""); + if (prefixes.some(prefix => hashless.startsWith(prefix))) value = hashless; + for (const prefix of prefixes) { + if (value.startsWith(prefix)) return value.slice(prefix.length); + } + return value; + } + function base64ToBytes(text) { - let b64 = String(text || "").trim().replace(/^#+/, "").replace(EXPORT_PREFIX, "").replace(/-/g, "+").replace(/_/g, "/"); + let b64 = stripExportPrefix(text, [EXPORT_PREFIX]).replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4) b64 += "="; const binary = atob(b64); const bytes = new Uint8Array(binary.length); @@ -25,25 +105,88 @@ return bytes; } - function encodeSnapshot(snapshot) { + async function gzipBytes(bytes) { + if (typeof CompressionStream !== "function") return null; + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + + async function gunzipBytes(bytes) { + if (typeof DecompressionStream !== "function") throw new Error("compressed import is not supported in this browser"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + + function encodeSnapshotLegacy(snapshot) { const json = JSON.stringify(snapshot); const bytes = new TextEncoder().encode(json); return EXPORT_PREFIX + bytesToBase64(bytes); } - function decodeSnapshot(text) { + async function encodeSnapshot(snapshot) { + if (snapshot?.__tarinaiCompactBytes && snapshot.bytes) { + const raw = snapshot.bytes instanceof Uint8Array ? snapshot.bytes : new Uint8Array(snapshot.bytes); + const compressed = await gzipBytes(raw); + if (compressed && compressed.length < raw.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed); + return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(raw); + } + const json = JSON.stringify(snapshot); + const bytes = new TextEncoder().encode(json); + const compressed = await gzipBytes(bytes); + if (compressed && compressed.length < bytes.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed); + return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(bytes); + } + + async function decodeSnapshot(text) { const raw = String(text || "").trim(); - if (!raw) throw new Error("empty import text"); - if (raw.startsWith("{") || raw.startsWith("[")) return JSON.parse(raw); - const bytes = base64ToBytes(raw); - const json = new TextDecoder().decode(bytes); - return JSON.parse(json); + const normalized = raw.replace(/^#+/, ""); + if (!normalized) throw new Error("empty import text"); + if (!normalized.startsWith(EXPORT_PREFIX_COMPRESSED)) throw new Error("unsupported save hash version"); + const body = stripExportPrefix(normalized, [EXPORT_PREFIX_COMPRESSED]); + const mode = body[0] || "r"; + const payload = body.slice(1); + const bytes = base94ToBytes(payload); + const packet = mode === "z" ? await gunzipBytes(bytes) : bytes; + if (!Snapshot.decodeV11SnapshotBytes) throw new Error("V11 decoder is not available"); + return Snapshot.decodeV11SnapshotBytes(packet); } function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } - function saveSlot(slot) { + function makeThumbnail() { + const source = global.canvas || document.getElementById("gameCanvas"); + if (!source || !source.width || !source.height) return ""; + try { + const tw = 168; + const th = 96; + const c = document.createElement("canvas"); + c.width = tw; + c.height = th; + const ctx = c.getContext("2d", { alpha: false }); + if (!ctx) return ""; + ctx.fillStyle = "#f6efe3"; + ctx.fillRect(0, 0, tw, th); + const scale = Math.min(tw / source.width, th / source.height); + const w = Math.max(1, Math.round(source.width * scale)); + const h = Math.max(1, Math.round(source.height * scale)); + const x = Math.floor((tw - w) / 2); + const y = Math.floor((th - h) / 2); + ctx.imageSmoothingEnabled = true; + ctx.drawImage(source, x, y, w, h); + return c.toDataURL("image/jpeg", 0.58); + } catch (_) { + return ""; + } + } + + function makeSaveSnapshot() { const snapshot = createSnapshot(global.world); + snapshot.thumbnail = makeThumbnail(); + return snapshot; + } + + function saveSlot(slot) { + const snapshot = makeSaveSnapshot(); localStorage.setItem(slotKey(slot), JSON.stringify(snapshot)); return snapshot; } @@ -62,14 +205,11 @@ try { return JSON.parse(raw); } catch (_) { return null; } } - const htmlEscape = global.TarinaiUIHelpers.htmlEscape; - - function formatSlotSummary(snapshot) { if (!snapshot) return "空き"; const d = snapshot.summary || {}; const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明"; - return `${date} / ${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""} / ${d.population || 0}匹`; + return `${date}\n${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""}\n${d.population || 0}匹 / 道具${d.items || 0}`; } function ensureDialog() { @@ -83,14 +223,21 @@ dialog.innerHTML = `

セーブ / ロード

-

9つのスロットに現在のコロニーを保存できます。ハッシュテキストは別端末や別ブラウザへの移動に使えます。

-
+

9つのスロットに現在のコロニーを保存できます。ハッシュテキストは圧縮して短く書き出します。

+
- + +
+
@@ -100,6 +247,35 @@ return dialog; } + function confirmInGame(message, okLabel = "実行") { + const dialog = ensureDialog(); + const box = dialog.querySelector("#saveConfirmBox"); + if (!box) return Promise.resolve(false); + box.querySelector(".save-confirm-message").textContent = message; + const ok = box.querySelector('[data-save-confirm="ok"]'); + if (ok) ok.textContent = okLabel; + box.classList.remove("hidden"); + return new Promise(resolve => { + const finish = (value) => { + box.classList.add("hidden"); + box.removeEventListener("click", onClick); + resolve(value); + }; + const onClick = (e) => { + const btn = e.target.closest("[data-save-confirm]"); + if (!btn) return; + finish(btn.dataset.saveConfirm === "ok"); + }; + box.addEventListener("click", onClick); + }); + } + + function slotThumbnailHtml(snapshot) { + const src = snapshot?.thumbnail || ""; + if (src) return ``; + return `
No Image
`; + } + function renderSlotList() { const dialog = ensureDialog(); const list = dialog.querySelector("#saveSlotList"); @@ -108,10 +284,11 @@ for (let i = 1; i <= SLOT_COUNT; i++) { const snapshot = readSlot(i); const row = document.createElement("div"); - row.className = "save-slot-row"; + row.className = `save-slot-card ${snapshot ? "filled" : "empty"}`; row.innerHTML = ` +
${slotThumbnailHtml(snapshot)}
スロット ${i}
-
${htmlEscape(formatSlotSummary(snapshot))}
+
${htmlEscape(formatSlotSummary(snapshot)).replace(/\n/g, "
")}
@@ -140,8 +317,9 @@ openDialog(); }); const dialog = ensureDialog(); - dialog.addEventListener("click", (e) => { + dialog.addEventListener("click", async (e) => { if (e.target === dialog) { closeDialog(); return; } + if (e.target.closest("#saveConfirmBox")) return; const saveBtn = e.target.closest("[data-save-slot]"); const loadBtn = e.target.closest("[data-load-slot]"); const deleteBtn = e.target.closest("[data-delete-slot]"); @@ -160,7 +338,7 @@ } if (loadBtn) { const slot = Number(loadBtn.dataset.loadSlot); - if (!global.confirm?.(`スロット${slot}を読み込みます。現在の状態は上書きされます。`)) return; + if (!await confirmInGame(`スロット${slot}を読み込みます。現在の状態は上書きされます。`, "読み込む")) return; try { loadSlot(slot); closeDialog(); @@ -174,7 +352,7 @@ } if (deleteBtn) { const slot = Number(deleteBtn.dataset.deleteSlot); - if (!global.confirm?.(`スロット${slot}を削除しますか?`)) return; + if (!await confirmInGame(`スロット${slot}を削除しますか?`, "削除")) return; deleteSlot(slot); renderSlotList(); global.audio?.delete?.(); @@ -184,21 +362,37 @@ if (e.target.closest("#saveCloseBtn")) closeDialog(); if (e.target.closest("#saveExportBtn")) { const area = dialog.querySelector("#saveHashText"); - if (area) { - area.value = encodeSnapshot(createSnapshot(global.world)); - area.focus(); - area.select(); + const exportBtn = dialog.querySelector("#saveExportBtn"); + try { + if (exportBtn) exportBtn.disabled = true; + if (area) { + area.value = "書き出し中…"; + const fullSnapshot = createSnapshot(global.world); + const exportSnapshot = Snapshot.createCompactSnapshot ? Snapshot.createCompactSnapshot(global.world) : fullSnapshot; + const text = await encodeSnapshot(exportSnapshot); + area.value = text; + area.focus(); + area.select(); + const legacy = encodeSnapshotLegacy(fullSnapshot); + const rate = legacy.length ? Math.round((1 - text.length / legacy.length) * 100) : 0; + global.showToast?.(rate > 0 ? `軽量ハッシュを書き出しました(約${rate}%短縮)。` : "現在の状態を書き出しました。"); + } + } catch (error) { + console.warn(error); + global.showToast?.("書き出しに失敗しました。"); + } finally { + if (exportBtn) exportBtn.disabled = false; } - global.showToast?.("現在の状態をハッシュテキストに書き出しました。"); } if (e.target.closest("#saveImportBtn")) { const area = dialog.querySelector("#saveHashText"); const text = area?.value || ""; if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; } - if (!global.confirm?.("ハッシュテキストを読み込みます。現在の状態は上書きされます。")) return; + if (!await confirmInGame("ハッシュテキストを読み込みます。現在の状態は上書きされます。", "読み込む")) return; try { - restoreSnapshot(decodeSnapshot(text), global.world); + restoreSnapshot(await decodeSnapshot(text), global.world); closeDialog(); + global.audio?.notify?.(); global.showToast?.("ハッシュテキストから読み込みました。"); } catch (error) { console.warn(error); diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 68c1e10..6ee9789 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -2,7 +2,11 @@ (function (global) { const SNAPSHOT_VERSION = 1; + // Alpha note: save/import data is intentionally not stable yet. + // During alpha, hash-text compatibility may be broken by balance and data-shape changes. + const COMPACT_SNAPSHOT_VERSION = 9; const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]); + const COORDINATE_KEYS = new Set(["x", "y", "prevX", "prevY", "foundingX", "foundingY", "cameraX", "cameraY"]); function isPlainObject(value) { if (!value || typeof value !== "object") return false; @@ -101,6 +105,2758 @@ return data; } + + const COMPACT_WORLD_KEYS = ["w", "h", "time", "day", "speed", "fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount", "liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "colonyMood", "lastColonyMoodDay"]; + const COMPACT_TARINAI_KEYS = ["familyKey", "id", "liveToken", "name", "type", "x", "y", "vx", "vy", "impulseVx", "impulseVy", "adultScale", "scale", "age", "lifeSpan", "generation", "birthTime", "hunger", "loneliness", "energy", "stress", "affection", "need", "state", "dead", "deathReason", "birthPersonality", "currentPersonality", "personalityDaily", "genetics", "parents", "parentNames", "children", "relationships", "favorite", "hasPaired", "reproductionTimer", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "formerName", "zunchiSlaveSpriteVariant", "zunchiStain", "zunchiStainSeed", "digest", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "mysteryDrugHistory", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "sunbathSpriteVariant", "goodMode", "facing", "visualFacing", "fallTimer", "fallDir", "hurtTimer", "fearTimer", "stressBarTimer", "hpBarTimer"]; + const COMPACT_ITEM_KEYS = ["id", "type", "x", "y", "r", "amount", "age", "seed", "dead", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "passiveFoodDecayTimer", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "textEditedAt", "vx", "vy", "prevX", "prevY", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "decayTimer", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "dropImpactDone", "impactFlash", "lingerTimer", "lingerDuration", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinDamageTick", "pinFallCheckTimer", "pinLogAt", "pinFlipX", "deletable", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + const COMPACT_ANT_KEYS = ["id", "kind", "homeId", "targetId", "targetToken", "x", "y", "vx", "vy", "r", "maxHp", "hp", "state", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingX", "foundingY", "foundingDelayUntil", "carryLogAt", "foundingAttempts", "dead"]; + + + // v4 hash format is intentionally alpha-only and compatibility may break. + // It stores gameplay state in short sparse arrays: non-gameplay UI/log/effect data is omitted. + const V4_WORLD_KEYS = COMPACT_WORLD_KEYS; + const V4_TARINAI_KEYS = COMPACT_TARINAI_KEYS; + const V4_ANT_KEYS = COMPACT_ANT_KEYS; + + const V4_ITEM_TYPES = ["zunchi","sweet","love_mochi","fight_mochi","water_bowl","sleep_drug","laxative","protein","niteropu","ammo","mystery_drug","mercury","giant_drug","dwarf_drug","zunda_juice","grass","stone","genkotsu","bed","nest_box","ant_nest","ball","signboard","firecracker","pushpin","oshibyo","duplicator","cannon","fence_v","fence_h","water","trace","splat","food","ant_corpse"]; + const V4_SPRITE_TYPES = ["smile","angry","teary","jito","drool","cry","sleep","pokan","stretch","hurt","weak","back","flee","zunda_eat","hurt2","normal_smirk","normal_tongue","fear","flee_fear2","sleep2","normal_happy","fear_blue","fear_cry","stress_dizzy","stress_sweat","intimidate","birth_ritual","zunchi_slave","hungry_70","zunchi_slave_alt","sunbath_1","sunbath_2","sunbath_3"]; + const V4_ITEM_EXTRA_KEYS = ["id", "r", "amount", "age", "dead", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "vx", "vy", "prevX", "prevY", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "dropImpactDone", "impactFlash", "lingerTimer", "lingerDuration", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinDamageTick", "pinFallCheckTimer", "pinLogAt", "pinFlipX", "deletable", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + + function itemTypeList() { + return V4_ITEM_TYPES; + } + + function spriteTypeList() { + return V4_SPRITE_TYPES; + } + + function codeFromList(value, list) { + const idx = list.indexOf(value); + return idx >= 0 ? idx : value; + } + + function valueFromList(value, list) { + return typeof value === "number" ? (list[value] || "") : value; + } + + function isEmptyCompactValue(value) { + return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0) || (isPlainObject(value) && Object.keys(value).length === 0); + } + + function sameCompactValue(a, b, key = "") { + const ca = compactValue(a, 0, key); + const cb = compactValue(b, 0, key); + return JSON.stringify(ca) === JSON.stringify(cb); + } + + function packSparseByKeys(data, keys, defaults = null, options = {}) { + const out = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (options.skip?.has?.(key)) continue; + let value = data?.[key]; + if (key === "relationships") value = compactRelationships(value); + const c = compactValue(value, 0, key); + if (isEmptyCompactValue(c)) continue; + if (defaults && sameCompactValue(c, defaults[key], key)) continue; + out.push(i, c); + } + return out; + } + + function unpackSparseByKeys(pairs, keys) { + const out = {}; + if (!Array.isArray(pairs)) return out; + for (let i = 0; i < pairs.length - 1; i += 2) { + const key = keys[pairs[i]]; + if (!key) continue; + out[key] = clonePlain(pairs[i + 1]); + } + return out; + } + + function itemDefaultsFor(type = "") { + const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; + const out = { + r: typeof itemRadiusFor === "function" ? itemRadiusFor(type, 12) : (def?.radius ?? 12), + amount: typeof itemAmountFor === "function" ? itemAmountFor(type, 80) : (def?.amount ?? 80), + age: 0, + dead: false, + dropTimer: 0, + dropMax: 0, + dropImpactDone: false, + }; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) { + out.toolSize = "medium"; + out.foodServingScale = 1; + out.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize("medium") : 5; + out.foodServingsRemaining = out.foodServingsMax; + out.amount = out.foodServingsMax; + } + if (type === "grass") { + out.health = 1; + out.eatenAmount = 0; + out.fertilityBoost = 0; + out.wither = 0; + } + if (type === "bed") out.wear = 0; + if (type === "signboard") out.text = ""; + if (type === "duplicator" || type === "cannon") Object.assign(out, { machineSlotType: "", machineSlotSize: "medium", machineSpawnTimer: 0, machineLastSlotType: "", cannonAngle: type === "cannon" ? -Math.PI * 0.25 : 0, cannonFireTimer: 0 }); + if (type === "zunchi") { + out.stage = "fresh"; + out.stageTimer = 0; + out.fertility = 0; + out.freshness = 1; + } + if (type === "firecracker") { + out.fuseTimer = 5; + out.fuseMax = 5; + } + if (typeof isPinType === "function" && isPinType(type)) { + Object.assign(out, { amount: 100, pinState: "loose", pinTargetId: "", pinAttachAngle: 0, pinAttachDistance: 0, pinOffsetY: 0, pinDamageTick: 0, pinFallCheckTimer: 0, pinLogAt: -999, deletable: true }); + } + return out; + } + + function packV4Item(worldRef, item, typeList) { + const data = snapshotEntity(worldRef, item); + const type = data.type || item?.type || ""; + const defaults = itemDefaultsFor(type); + const extras = packSparseByKeys(data, V4_ITEM_EXTRA_KEYS, defaults, { skip: new Set(["type", "x", "y"]) }); + const row = [codeFromList(type, typeList), compactCoordinate(data.x || 0), compactCoordinate(data.y || 0)]; + if (extras.length) row.push(extras); + return row; + } + + function unpackV4Item(row, typeList) { + if (!Array.isArray(row)) return null; + const type = valueFromList(row[0], typeList); + const data = unpackSparseByKeys(row[3] || [], V4_ITEM_EXTRA_KEYS); + data.type = type; + data.x = row[1] || 0; + data.y = row[2] || 0; + return data; + } + + function packV4Tarinai(worldRef, tarinai, spriteList) { + const data = snapshotEntity(worldRef, tarinai); + const pairs = packSparseByKeys(data, V4_TARINAI_KEYS, null); + for (let i = 0; i < pairs.length - 1; i += 2) { + if (V4_TARINAI_KEYS[pairs[i]] === "type") pairs[i + 1] = codeFromList(pairs[i + 1], spriteList); + } + return pairs; + } + + function unpackV4Tarinai(row, spriteList) { + const data = unpackSparseByKeys(row, V4_TARINAI_KEYS); + if (data.type !== undefined) data.type = valueFromList(data.type, spriteList); + return data; + } + + function createV4Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const typeList = itemTypeList(); + const spriteList = spriteTypeList(); + const world = worldData(worldRef); + const frozen = Array.isArray(world.frozenTarinai) ? world.frozenTarinai : []; + delete world.frozenTarinai; + return { + v: COMPACT_SNAPSHOT_VERSION, + w: packSparseByKeys(world, V4_WORLD_KEYS, { + w: 1000, h: 720, time: 0, day: 1, speed: 1, fieldType: "garden", fieldZoom: 1, cameraX: 0, cameraY: 0, weather: "sunny", deadCount: 0, liveIdNext: 1, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, family: {}, colonyMood: { id: "relaxed", label: "relaxed", description: "", effects: { personality: {} } }, lastColonyMoodDay: 0 + }), + t: (worldRef.tarinai || []).filter(Boolean).map(t => packV4Tarinai(worldRef, t, spriteList)), + i: (worldRef.items || []).filter(it => it && !it.dead && !["trace", "splat"].includes(it.type)).map(it => packV4Item(worldRef, it, typeList)), + a: (worldRef.ants || []).filter(a => a && !a.dead).map(a => packSparseByKeys(snapshotEntity(worldRef, a), V4_ANT_KEYS)), + f: frozen.map(compactFrozenEntry), + }; + } + + function expandV4Snapshot(snapshot) { + if (!snapshot || snapshot.v !== 4) return snapshot; + const typeList = itemTypeList(); + const spriteList = spriteTypeList(); + const world = unpackSparseByKeys(snapshot.w, V4_WORLD_KEYS); + world.frozenTarinai = (snapshot.f || []).map(expandFrozenEntry); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: Array.isArray(snapshot.t) ? snapshot.t.length : 0, items: Array.isArray(snapshot.i) ? snapshot.i.length : 0 }, + world, + tarinai: (snapshot.t || []).map(row => unpackV4Tarinai(row, spriteList)), + items: (snapshot.i || []).map(row => unpackV4Item(row, typeList)).filter(Boolean), + ants: (snapshot.a || []).map(row => unpackSparseByKeys(row, V4_ANT_KEYS)), + effects: [], + logs: [], + selectedRef: null, + }; + } + + + // v5 hash format: shorter alpha-only hash text. It keeps gameplay state, but intentionally excludes logs, bubbles, UI-only timers, thumbnails, and other presentation state. + const V5_SNAPSHOT_VERSION = 5; + const V5_WORLD_KEYS = ["w", "h", "time", "day", "speed", "fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount", "liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "colonyMood", "lastColonyMoodDay"]; + const V5_TARINAI_KEYS = ["familyKey", "id", "liveToken", "name", "type", "x", "y", "vx", "vy", "impulseVx", "impulseVy", "adultScale", "scale", "age", "lifeSpan", "generation", "birthTime", "hunger", "loneliness", "energy", "stress", "affection", "need", "state", "dead", "deathReason", "birthPersonality", "currentPersonality", "personalityDaily", "genetics", "parents", "children", "relationships", "favorite", "hasPaired", "reproductionTimer", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "formerName", "zunchiSlaveSpriteVariant", "zunchiStain", "zunchiStainSeed", "digest", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "mysteryDrugHistory", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "sunbathSpriteVariant", "goodMode", "facing", "visualFacing", "fallTimer", "fallDir", "hurtTimer", "fearTimer"]; + const V5_ITEM_EXTRA_KEYS = ["id", "r", "amount", "age", "dead", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "vx", "vy", "prevX", "prevY", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "dropImpactDone", "impactFlash", "lingerTimer", "lingerDuration", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinDamageTick", "pinFallCheckTimer", "pinLogAt", "pinFlipX", "deletable", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + const V5_ANT_KEYS = ["id", "kind", "homeId", "targetId", "targetToken", "x", "y", "vx", "vy", "r", "maxHp", "hp", "state", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingX", "foundingY", "foundingDelayUntil", "foundingAttempts", "dead"]; + const V5_NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"]; + const V5_STATES = ["idle", "seek_food", "seek_friend", "seek_bed", "seek_water", "sleep", "eat", "panic", "fight", "birth_ritual", "intimidate", "ant_attack", "ant_intimidate", "zunchi_sick", "fight_sick", "play_ball", "follow_parent", "cursor_enemy", "cursor_friend", "sunbath"]; + const V5_SIZES = ["small", "medium", "large"]; + const V5_PIN_STATES = ["loose", "lodged"]; + const V5_ZUNCHI_STAGES = ["fresh", "aged", "dry", "dead"]; + const V5_MOODS = ["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]; + const PERSONALITY_ORDER = ["aggression", "openness", "sociability", "neuroticism"]; + const GENETIC_ORDER = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"]; + + function codeOrValue(value, list) { + if (value === undefined || value === null || value === "") return value; + const idx = list.indexOf(value); + return idx >= 0 ? idx : value; + } + + function valueOrCode(value, list) { + return typeof value === "number" ? (list[value] || "") : value; + } + + function compactMetric(value) { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value * 10) / 10; + return Object.is(rounded, -0) ? 0 : rounded; + } + + function approxSame(a, b, key = "") { + if (COORDINATE_KEYS.has(key)) return compactCoordinate(a) === compactCoordinate(b); + if (typeof a === "number" || typeof b === "number") return compactNumber(Number(a) || 0) === compactNumber(Number(b) || 0); + return JSON.stringify(compactValue(a, 0, key)) === JSON.stringify(compactValue(b, 0, key)); + } + + function packAxisObject(obj, keys, fallback = null) { + if (!obj || typeof obj !== "object") return undefined; + const arr = keys.map(key => compactNumber(obj[key] ?? fallback?.[key] ?? 0)); + while (arr.length && (arr[arr.length - 1] === 0 || arr[arr.length - 1] === null || arr[arr.length - 1] === undefined)) arr.pop(); + return arr.length ? arr : undefined; + } + + function unpackAxisObject(arr, keys, fallback = null) { + const out = fallback && typeof fallback === "object" ? clonePlain(fallback) || {} : {}; + if (!Array.isArray(arr)) return out; + for (let i = 0; i < Math.min(arr.length, keys.length); i++) out[keys[i]] = compactNumber(Number(arr[i]) || 0); + for (const key of keys) if (!Number.isFinite(out[key])) out[key] = 0; + return out; + } + + function compactItemEffectTimers(value) { + if (!value || typeof value !== "object") return undefined; + const arr = [compactNumber(value.laxative || 0), compactNumber(value.ammo || 0)]; + while (arr.length && !arr[arr.length - 1]) arr.pop(); + return arr.length ? arr : undefined; + } + + function expandItemEffectTimers(value) { + if (!Array.isArray(value)) return value; + return { laxative: Number(value[0] || 0), ammo: Number(value[1] || 0) }; + } + + function compactPersonalityDaily(value) { + if (!value || typeof value !== "object") return undefined; + const out = []; + if (Number.isFinite(value.day) && value.day !== 1) out.push(0, value.day); + if (Number.isFinite(value.total) && value.total) out.push(1, compactNumber(value.total)); + if (value.byKey && typeof value.byKey === "object" && Object.keys(value.byKey).length) out.push(2, compactValue(value.byKey)); + return out.length ? out : undefined; + } + + function expandPersonalityDaily(value) { + if (!Array.isArray(value)) return value; + const out = { day: 1, total: 0, byKey: {}, byCause: {} }; + for (let i = 0; i < value.length - 1; i += 2) { + if (value[i] === 0) out.day = value[i + 1]; + else if (value[i] === 1) out.total = value[i + 1]; + else if (value[i] === 2) out.byKey = clonePlain(value[i + 1]) || {}; + } + return out; + } + + function compactRelationshipsV5(rels) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + for (const [id, r] of Object.entries(rels)) { + if (!id || !r) continue; + const row = [id, compactMetric(r.affinity || 0), compactMetric(r.fear || 0), compactNumber(r.fightsWon || 0), compactNumber(r.fightsLost || 0)]; + while (row.length > 1 && (row[row.length - 1] === 0 || row[row.length - 1] === "" || row[row.length - 1] === null || row[row.length - 1] === undefined)) row.pop(); + if (row.length > 1) rows.push(row); + } + rows.sort((a, b) => { + const score = row => Math.abs(row[1] || 0) + Math.abs(row[2] || 0) + Math.abs(row[3] || 0) * 2 + Math.abs(row[4] || 0) * 2; + return score(b) - score(a); + }); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function expandRelationshipsV5(rows) { + if (!Array.isArray(rows)) return rows; + const out = {}; + for (const row of rows) { + if (!Array.isArray(row) || !row[0]) continue; + out[row[0]] = { affinity: Number(row[1] || 0), fear: Number(row[2] || 0), fightsWon: Number(row[3] || 0), fightsLost: Number(row[4] || 0), lastEvent: "", lastTime: 0 }; + } + return out; + } + + function v5DefaultTarinaiData(data = {}, worldRef = null) { + const key = data.familyKey || data.id || ""; + const genetics = typeof normalizeGenetics === "function" ? normalizeGenetics(null, key) : null; + const birthPersonality = typeof randomBirthPersonality === "function" ? randomBirthPersonality(key) : null; + const adultScale = typeof adultScaleFromGenetics === "function" ? adultScaleFromGenetics(key, data.genetics || genetics) : undefined; + const out = { + id: "", liveToken: 0, generation: 1, birthTime: 0, impulseVx: 0, impulseVy: 0, state: "idle", dead: false, deathReason: "", favorite: false, hasPaired: false, + parents: [], children: [], relationships: {}, loveMochiTimer: 0, fightMochiTimer: 0, zunchiDisease: false, zunchiDiseaseSeverity: 0, sleepDisease: false, + explosionDisease: false, explosionDiseaseTimer: 0, fightDisease: false, totalFightLosses: 0, totalFightWins: 0, isZunchiSlave: false, zunchiSlaveLocked: false, + formerName: null, zunchiSlaveSpriteVariant: "", zunchiStain: 0, digest: 0, poopCount: 0, oshiriByoZunchiStock: 0, stuckPushpinId: null, + itemEffectTimers: { laxative: 0, ammo: 0 }, laxativePoopTimer: 3, powerItemMode: "", sizeItemMode: "", lifeItemMode: "", mercuryLifeSpanBase: null, + mercuryLifeMultiplier: 1, mysteryDrugHistory: [], insideNestBoxId: null, sunbathTimer: 0, sunbathCooldown: 0, sunbathSpriteVariant: 1, + fallTimer: 0, hurtTimer: 0, fearTimer: 0, + }; + if (genetics) out.genetics = genetics; + if (birthPersonality) { + out.birthPersonality = birthPersonality; + out.currentPersonality = birthPersonality; + } + if (adultScale !== undefined) { + out.adultScale = adultScale; + if ((data.generation || 1) <= 1) out.scale = adultScale; + } + if (typeof stableChoice === "function") out.goodMode = stableChoice(key, "good-mode", typeof displayNormalSprites === "function" ? displayNormalSprites() : []) || "smile"; + if (typeof stableUnit === "function") { + out.facing = stableUnit(key, "facing") < 0.5 ? -1 : 1; + out.visualFacing = out.facing; + out.fallDir = stableUnit(key, "fall-dir") < 0.5 ? -1 : 1; + out.zunchiStainSeed = stableUnit(key, "zunchi-stain"); + } + return out; + } + + function encodeV5TarinaiValue(key, value) { + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "need") return codeOrValue(value, V5_NEEDS); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "birthPersonality" || key === "currentPersonality") return packAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return packAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "relationships") return compactRelationshipsV5(value); + if (key === "itemEffectTimers") return compactItemEffectTimers(value); + if (key === "personalityDaily") return compactPersonalityDaily(value); + return compactValue(value, 0, key); + } + + function decodeV5TarinaiValue(key, value) { + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "need") return valueOrCode(value, V5_NEEDS); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "birthPersonality" || key === "currentPersonality") return unpackAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return unpackAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "relationships") return expandRelationshipsV5(value); + if (key === "itemEffectTimers") return expandItemEffectTimers(value); + if (key === "personalityDaily") return expandPersonalityDaily(value); + return clonePlain(value); + } + + function encodeV5ItemValue(key, value) { + if (key === "toolSize" || key === "machineSlotSize") return codeOrValue(value, V5_SIZES); + if (key === "pinState") return codeOrValue(value, V5_PIN_STATES); + if (key === "stage") return codeOrValue(value, V5_ZUNCHI_STAGES); + if (key === "machineSlotType" || key === "machineLastSlotType" || key === "sourceMachineType") return codeOrValue(value, V4_ITEM_TYPES); + return compactValue(value, 0, key); + } + + function decodeV5ItemValue(key, value) { + if (key === "toolSize" || key === "machineSlotSize") return valueOrCode(value, V5_SIZES); + if (key === "pinState") return valueOrCode(value, V5_PIN_STATES); + if (key === "stage") return valueOrCode(value, V5_ZUNCHI_STAGES); + if (key === "machineSlotType" || key === "machineLastSlotType" || key === "sourceMachineType") return valueOrCode(value, V4_ITEM_TYPES); + return clonePlain(value); + } + + function packSparseV5(data, keys, defaults = null, encode = (key, value) => compactValue(value, 0, key)) { + const out = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const encoded = encode(key, data?.[key]); + if (isEmptyCompactValue(encoded)) continue; + if (defaults && approxSame(encoded, encode(key, defaults[key]), key)) continue; + out.push(i, encoded); + } + return out; + } + + function unpackSparseV5(pairs, keys, decode = (key, value) => clonePlain(value)) { + const out = {}; + if (!Array.isArray(pairs)) return out; + for (let i = 0; i < pairs.length - 1; i += 2) { + const key = keys[pairs[i]]; + if (!key) continue; + out[key] = decode(key, pairs[i + 1]); + } + return out; + } + + function v5ReferencedItemIds(worldRef, tarinaiRows = []) { + const ids = new Set(); + for (const t of tarinaiRows || []) { + if (t?.stuckPushpinId) ids.add(t.stuckPushpinId); + if (t?.insideNestBoxId) ids.add(t.insideNestBoxId); + } + for (const a of worldRef?.ants || []) { + if (a?.homeId) ids.add(a.homeId); + if (a?.targetId && String(a.targetId).startsWith("item")) ids.add(a.targetId); + } + for (const it of worldRef?.items || []) { + if (it?.projectileSourceId) ids.add(it.projectileSourceId); + } + return ids; + } + + function v5ItemDefaultsFor(type = "", keepId = false) { + const out = itemDefaultsFor(type); + if (!keepId) out.id = ""; + if (type !== "signboard") out.text = ""; + return out; + } + + function packV5World(worldRef) { + const world = worldData(worldRef); + const mood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + world.colonyMood = codeOrValue(mood, V5_MOODS); + delete world.frozenTarinai; + delete world.family; + const defaults = { w: 1000, h: 720, time: 0, day: 1, speed: 1, fieldType: "garden", fieldZoom: 1, cameraX: 0, cameraY: 0, weather: "sunny", deadCount: 0, liveIdNext: 1, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: 0, lastColonyMoodDay: 0 }; + return packSparseV5(world, V5_WORLD_KEYS, defaults, (key, value) => key === "colonyMood" ? value : compactValue(value, 0, key)); + } + + function unpackV5World(row) { + const world = unpackSparseV5(row, V5_WORLD_KEYS, (key, value) => key === "colonyMood" ? { id: valueOrCode(value, V5_MOODS) || "relaxed" } : clonePlain(value)); + return world; + } + + function packV5Tarinai(worldRef, tarinai) { + const data = snapshotEntity(worldRef, tarinai); + const defaults = v5DefaultTarinaiData(data, worldRef); + return packSparseV5(data, V5_TARINAI_KEYS, defaults, encodeV5TarinaiValue); + } + + function unpackV5Tarinai(row) { + return unpackSparseV5(row, V5_TARINAI_KEYS, decodeV5TarinaiValue); + } + + function packV5Item(worldRef, item, referencedIds) { + const data = snapshotEntity(worldRef, item); + const type = data.type || item?.type || ""; + const keepId = Boolean(data.id && (referencedIds?.has?.(data.id) || type === "ant_nest" || type === "nest_box" || data.pinState === "lodged")); + const defaults = v5ItemDefaultsFor(type, keepId); + const extras = packSparseV5(data, V5_ITEM_EXTRA_KEYS, defaults, encodeV5ItemValue); + const row = [codeFromList(type, V4_ITEM_TYPES), compactCoordinate(data.x || 0), compactCoordinate(data.y || 0)]; + if (extras.length) row.push(extras); + return row; + } + + function unpackV5Item(row) { + if (!Array.isArray(row)) return null; + const type = valueFromList(row[0], V4_ITEM_TYPES); + const data = unpackSparseV5(row[3] || [], V5_ITEM_EXTRA_KEYS, decodeV5ItemValue); + data.type = type; + data.x = Number(row[1] || 0); + data.y = Number(row[2] || 0); + return data; + } + + function packV5Ant(worldRef, ant) { + const data = snapshotEntity(worldRef, ant); + const defaults = { r: 6, maxHp: 8, hp: data.maxHp || 8, state: "wander", age: 0, dead: false }; + return packSparseV5(data, V5_ANT_KEYS, defaults, (key, value) => compactValue(value, 0, key)); + } + + function unpackV5Ant(row) { + return unpackSparseV5(row, V5_ANT_KEYS, (key, value) => clonePlain(value)); + } + + function compactFrozenEntryV5(entry = {}) { + const data = entry.data || entry; + const row = packSparseV5(data, V5_TARINAI_KEYS, v5DefaultTarinaiData(data), encodeV5TarinaiValue); + const out = [entry.freezeId || entry.id || "", compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const losses = compactValue((entry.pendingLosses || []).slice(-8)) || []; + if (losses.length) out.push(losses); + return out; + } + + function expandFrozenEntryV5(entry = []) { + if (!Array.isArray(entry)) return expandFrozenEntry(entry); + const data = unpackV5Tarinai(entry[3] || []); + return { + freezeId: entry[0] || "", + frozenAt: entry[1] || 0, + worldTime: entry[2] || 0, + name: entry[3] || data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: clonePlain(entry[4]) || [] + }; + } + + function createV5Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const tarinaiRows = (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiRows); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + return [ + V5_SNAPSHOT_VERSION, + packV5World(worldRef), + (worldRef.tarinai || []).filter(Boolean).map(t => packV5Tarinai(worldRef, t)), + (worldRef.items || []).filter(it => it && !it.dead && !["trace", "splat"].includes(it.type)).map(it => packV5Item(worldRef, it, referencedIds)), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(compactFrozenEntryV5), + ]; + } + + function expandV5Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V5_SNAPSHOT_VERSION) return snapshot; + const world = unpackV5World(snapshot[1] || []); + world.frozenTarinai = (snapshot[5] || []).map(expandFrozenEntryV5); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: Array.isArray(snapshot[2]) ? snapshot[2].length : 0, items: Array.isArray(snapshot[3]) ? snapshot[3].length : 0 }, + world, + tarinai: (snapshot[2] || []).map(unpackV5Tarinai), + items: (snapshot[3] || []).map(unpackV5Item).filter(Boolean), + ants: (snapshot[4] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + function compactNumber(value) { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value * 1000) / 1000; + return Object.is(rounded, -0) ? 0 : rounded; + } + + function compactCoordinate(value) { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value * 10) / 10; + return Object.is(rounded, -0) ? 0 : rounded; + } + + function compactValue(value, depth = 0, keyHint = "") { + if (value == null) return value; + const type = typeof value; + if (type === "number") return COORDINATE_KEYS.has(keyHint) ? compactCoordinate(value) : compactNumber(value); + if (type === "string" || type === "boolean") return value; + if (type === "function" || type === "symbol" || type === "undefined") return undefined; + if (depth > 6) return undefined; + if (Array.isArray(value)) return value.map(v => compactValue(v, depth + 1, keyHint)).filter(v => v !== undefined); + if (!isPlainObject(value)) return undefined; + const out = {}; + for (const [key, v] of Object.entries(value)) { + if (DEFAULT_SKIP.has(key) || key.startsWith("__")) continue; + const c = compactValue(v, depth + 1, key); + if (c !== undefined && c !== null && !(Array.isArray(c) && !c.length)) out[key] = c; + } + return out; + } + + function compactRelationships(rels) { + if (!rels || typeof rels !== "object") return undefined; + const entries = Object.entries(rels) + .map(([id, r]) => [id, compactValue(r)]) + .filter(([, r]) => r && (Math.abs(r.affinity || 0) > 0.08 || Math.abs(r.fear || 0) > 0.08 || Math.abs(r.familiarity || 0) > 0.2 || Math.abs(r.fights || 0) > 0)); + entries.sort((a, b) => { + const score = (r) => Math.abs(r.affinity || 0) + Math.abs(r.fear || 0) + Math.abs(r.familiarity || 0) * 0.25 + Math.abs(r.fights || 0) * 2; + return score(b[1]) - score(a[1]); + }); + const out = {}; + for (const [id, r] of entries.slice(0, 12)) out[id] = r; + return Object.keys(out).length ? out : undefined; + } + + function compactByKeys(data, keys) { + const arr = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + let value = data?.[key]; + if (key === "relationships") value = compactRelationships(value); + arr[i] = compactValue(value, 0, key); + } + while (arr.length && (arr[arr.length - 1] === undefined || arr[arr.length - 1] === null || (Array.isArray(arr[arr.length - 1]) && !arr[arr.length - 1].length))) arr.pop(); + return arr; + } + + function expandByKeys(arr, keys) { + const out = {}; + if (!Array.isArray(arr)) return out; + for (let i = 0; i < Math.min(arr.length, keys.length); i++) { + const value = arr[i]; + if (value === undefined || value === null) continue; + out[keys[i]] = clonePlain(value); + } + return out; + } + + function compactFrozenEntry(entry = {}) { + const data = entry.data || entry; + return { + i: entry.freezeId || entry.id || "", + a: compactNumber(entry.frozenAt || 0), + t: compactNumber(entry.worldTime || 0), + n: entry.name || data.name || "", + k: entry.familyKey || data.familyKey || "", + d: compactByKeys(data, COMPACT_TARINAI_KEYS), + l: compactValue((entry.pendingLosses || []).slice(-8)) || [] + }; + } + + function expandFrozenEntry(entry = {}) { + const data = expandByKeys(entry.d, COMPACT_TARINAI_KEYS); + return { + freezeId: entry.i || "", + frozenAt: entry.a || 0, + worldTime: entry.t || 0, + name: entry.n || data.name || "tarinai", + familyKey: entry.k || data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: clonePlain(entry.l) || [] + }; + } + + + + // v6 hash format: alpha-only, shorter than V5. Individual observation history and names are intentionally omitted. + // Gameplay state is preserved through needs, personality, genetics, relationships, family links, item states, machines, ants, and frozen entries. + const V6_SNAPSHOT_VERSION = 6; + const V6_FIELD_TYPES = ["garden", "cage", "park"]; + const V6_WEATHERS = ["sunny", "cloudy", "light_rain"]; + const V6_CORE_TARINAI_KEYS = ["familyKey", "id", "liveToken", "x", "y", "adultScale", "scale", "age", "lifeSpan", "hunger", "loneliness", "energy", "stress", "affection", "need", "state", "reproductionTimer", "digest", "currentPersonality", "genetics"]; + const V6_EXTRA_TARINAI_KEYS = ["type", "vx", "vy", "impulseVx", "impulseVy", "generation", "birthTime", "parents", "children", "relationships", "dead", "hasPaired", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]; + const V6_LOSS_RELATIONS = ["parent", "child", "mate"]; + const V6_TARINAI_KEYS = [...V6_CORE_TARINAI_KEYS, ...V6_EXTRA_TARINAI_KEYS]; + + function v6IdToCode(value) { + if (!value) return ""; + const m = /^t(\d+)$/.exec(String(value)); + return m ? Number(m[1]) : value; + } + + function v6CodeToId(value) { + if (value === undefined || value === null || value === "") return ""; + return typeof value === "number" ? `t${value}` : String(value); + } + + function v6FamilyMaps(worldRef, frozen = []) { + const map = new Map(); + const list = []; + const add = (value) => { + if (!value) return null; + const key = String(value); + if (!map.has(key)) { + map.set(key, map.size); + list.push(key); + } + return map.get(key); + }; + for (const t of worldRef?.tarinai || []) { + if (!t) continue; + add(t.familyKey || t.archiveKey || t.id); + for (const id of t.parents || []) add(id); + for (const id of t.children || []) add(id); + } + for (const entry of frozen || []) { + const data = entry?.data || entry; + if (!data) continue; + add(entry.familyKey || data.familyKey || data.id); + for (const id of data.parents || []) add(id); + for (const id of data.children || []) add(id); + for (const loss of entry.pendingLosses || []) add(loss?.familyKey); + } + return { map, list }; + } + + function v6FamilyCode(value, ctx) { + if (!value) return ""; + if (!ctx?.familyMap) return value; + const existing = ctx.familyMap.get(String(value)); + if (existing !== undefined) return existing; + const next = ctx.familyMap.size; + ctx.familyMap.set(String(value), next); + ctx.familyList?.push?.(String(value)); + return next; + } + + function v6FamilyValue(value, ctx) { + if (value === undefined || value === null || value === "") return ""; + // V6 intentionally restores compact family keys. Exact historic UUIDs are observation history, not gameplay. + return typeof value === "number" ? `f${value + 1}` : String(value); + } + + function v6RelationKey(value) { + return v6IdToCode(value); + } + + function v6RelationId(value) { + return v6CodeToId(value); + } + + function compactRelationshipsV6(rels) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + for (const [id, r] of Object.entries(rels)) { + if (!id || !r) continue; + const row = [v6RelationKey(id), compactMetric(r.affinity || 0), compactMetric(r.fear || 0), compactNumber(r.fightsWon || 0), compactNumber(r.fightsLost || 0)]; + while (row.length > 1 && (row[row.length - 1] === 0 || row[row.length - 1] === "" || row[row.length - 1] === null || row[row.length - 1] === undefined)) row.pop(); + if (row.length > 1) rows.push(row); + } + rows.sort((a, b) => { + const score = row => Math.abs(row[1] || 0) + Math.abs(row[2] || 0) + Math.abs(row[3] || 0) * 2 + Math.abs(row[4] || 0) * 2; + return score(b) - score(a); + }); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function expandRelationshipsV6(rows) { + if (!Array.isArray(rows)) return rows; + const out = {}; + for (const row of rows) { + if (!Array.isArray(row) || row[0] === undefined || row[0] === null || row[0] === "") continue; + out[v6RelationId(row[0])] = { affinity: Number(row[1] || 0), fear: Number(row[2] || 0), fightsWon: Number(row[3] || 0), fightsLost: Number(row[4] || 0), lastEvent: "", lastTime: 0 }; + } + return out; + } + + function encodeV6TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyCode(value, ctx); + if (key === "id") return v6IdToCode(value); + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "need") return codeOrValue(value, V5_NEEDS); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "currentPersonality") return packAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return packAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) && value.length ? value.map(v => v6FamilyCode(v, ctx)).filter(v => v !== "") : undefined; + if (key === "relationships") return compactRelationshipsV6(value); + if (key === "itemEffectTimers") return compactItemEffectTimers(value); + return compactValue(value, 0, key); + } + + function decodeV6TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyValue(value, ctx); + if (key === "id") return v6CodeToId(value); + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "need") return valueOrCode(value, V5_NEEDS); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "currentPersonality") return unpackAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return unpackAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) ? value.map(v => v6FamilyValue(v, ctx)).filter(Boolean) : []; + if (key === "relationships") return expandRelationshipsV6(value); + if (key === "itemEffectTimers") return expandItemEffectTimers(value); + return clonePlain(value); + } + + function v6DefaultTarinaiData(data = {}) { + return { + familyKey: "", id: "", liveToken: 0, type: "smile", x: 0, y: 0, vx: 0, vy: 0, impulseVx: 0, impulseVy: 0, + adultScale: 0, scale: 0, age: 0, lifeSpan: 0, generation: 1, birthTime: 0, hunger: 0, loneliness: 0, energy: 0, stress: 0, affection: 0, + need: "", state: "idle", currentPersonality: {}, genetics: {}, parents: [], children: [], relationships: {}, + dead: false, deathReason: "", favorite: false, hasPaired: false, reproductionTimer: 0, loveMochiTimer: 0, fightMochiTimer: 0, + zunchiDisease: false, zunchiDiseaseSeverity: 0, sleepDisease: false, explosionDisease: false, explosionDiseaseTimer: 0, fightDisease: false, + totalFightLosses: 0, totalFightWins: 0, isZunchiSlave: false, zunchiSlaveLocked: false, zunchiSlaveSpriteVariant: "", zunchiStain: 0, + digest: 0, poopCount: 0, oshiriByoZunchiStock: 0, stuckPushpinId: null, itemEffectTimers: { laxative: 0, ammo: 0 }, laxativePoopTimer: 3, + powerItemMode: "", sizeItemMode: "", lifeItemMode: "", mercuryLifeSpanBase: null, mercuryLifeMultiplier: 1, insideNestBoxId: null, + sunbathTimer: 0, sunbathCooldown: 0, sunbathSpriteVariant: 1, goodMode: "", facing: 0, visualFacing: 0, fallTimer: 0, fallDir: 0, hurtTimer: 0, fearTimer: 0, + }; + } + + function packV6Core(data, keys, defaults, encode, ctx) { + const row = keys.map(key => { + const encoded = encode(key, data?.[key], ctx); + if (isEmptyCompactValue(encoded)) return null; + const def = encode(key, defaults?.[key], ctx); + if (approxSame(encoded, def, key)) return null; + return encoded; + }); + while (row.length && (row[row.length - 1] === null || row[row.length - 1] === undefined || isEmptyCompactValue(row[row.length - 1]))) row.pop(); + return row; + } + + function unpackV6Core(row, keys, decode, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < Math.min(row.length, keys.length); i++) { + const value = row[i]; + if (value === null || value === undefined) continue; + const key = keys[i]; + out[key] = decode(key, value, ctx); + } + return out; + } + + function packV6CoreTarinai(data, ctx) { + const row = V6_CORE_TARINAI_KEYS.map(key => { + const encoded = encodeV6TarinaiValue(key, data?.[key], ctx); + return isEmptyCompactValue(encoded) ? null : encoded; + }); + while (row.length && (row[row.length - 1] === null || row[row.length - 1] === undefined || isEmptyCompactValue(row[row.length - 1]))) row.pop(); + return row; + } + + function unpackV6CoreTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < Math.min(row.length, V6_CORE_TARINAI_KEYS.length); i++) { + const value = row[i]; + if (value === null || value === undefined) continue; + const key = V6_CORE_TARINAI_KEYS[i]; + out[key] = decodeV6TarinaiValue(key, value, ctx); + } + return out; + } + + function packV6ExtrasTarinai(data, defaults, ctx) { + const out = []; + for (let i = 0; i < V6_EXTRA_TARINAI_KEYS.length; i++) { + const key = V6_EXTRA_TARINAI_KEYS[i]; + const encoded = encodeV6TarinaiValue(key, data?.[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + if (defaults && approxSame(encoded, encodeV6TarinaiValue(key, defaults[key], ctx), key)) continue; + out.push(i, encoded); + } + return out; + } + + function unpackV6ExtrasTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < row.length - 1; i += 2) { + const key = V6_EXTRA_TARINAI_KEYS[row[i]]; + if (!key) continue; + out[key] = decodeV6TarinaiValue(key, row[i + 1], ctx); + } + return out; + } + + function packV6Tarinai(worldRef, tarinai, ctx) { + const data = snapshotEntity(worldRef, tarinai); + delete data.name; + delete data.parentNames; + delete data.formerName; + delete data.birthPersonality; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.recentChangeCauses; + delete data.records; + delete data.deathReason; + delete data.favorite; + delete data.zunchiSlaveSpriteVariant; + delete data.zunchiStain; + delete data.sunbathSpriteVariant; + delete data.goodMode; + delete data.facing; + delete data.visualFacing; + delete data.fallDir; + delete data.hurtTimer; + const defaults = v6DefaultTarinaiData(data); + const core = packV6CoreTarinai(data, ctx); + const extras = packV6ExtrasTarinai(data, defaults, ctx); + return extras.length ? [core, extras] : core; + } + + function unpackV6Tarinai(row, ctx) { + const split = Array.isArray(row) && Array.isArray(row[0]) && Array.isArray(row[1]); + const core = split ? row[0] : row; + const extras = split ? row[1] : []; + const data = unpackV6CoreTarinai(core, ctx); + Object.assign(data, unpackV6ExtrasTarinai(extras, ctx)); + if (!data.generation) data.generation = 1; + if (!data.name) data.name = data.isZunchiSlave || data.zunchiSlaveLocked ? "zunchi_slave" : "tarinai"; + if (!data.birthPersonality && data.currentPersonality) data.birthPersonality = clonePlain(data.currentPersonality); + return data; + } + + function encodeV6WorldValue(key, value) { + if (key === "fieldType") return codeOrValue(value, V6_FIELD_TYPES); + if (key === "weather") return codeOrValue(value, V6_WEATHERS); + if (key === "colonyMood") return codeOrValue(value, V5_MOODS); + return compactValue(value, 0, key); + } + + function decodeV6WorldValue(key, value) { + if (key === "fieldType") return valueOrCode(value, V6_FIELD_TYPES); + if (key === "weather") return valueOrCode(value, V6_WEATHERS); + if (key === "colonyMood") return { id: valueOrCode(value, V5_MOODS) || "relaxed" }; + return clonePlain(value); + } + + function packV6World(worldRef) { + const world = worldData(worldRef); + world.colonyMood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + delete world.frozenTarinai; + delete world.family; + const defaults = { w: 1000, h: 720, time: 0, day: 1, speed: 1, fieldType: "garden", fieldZoom: 1, cameraX: 0, cameraY: 0, weather: "sunny", deadCount: 0, liveIdNext: 1, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: "relaxed", lastColonyMoodDay: 0 }; + return packSparseV5(world, V5_WORLD_KEYS, defaults, encodeV6WorldValue); + } + + function unpackV6World(row) { + return unpackSparseV5(row, V5_WORLD_KEYS, decodeV6WorldValue); + } + + function packV6Items(worldRef, referencedIds) { + const base = []; + const extras = []; + let index = 0; + for (const item of worldRef.items || []) { + if (!item || item.dead || ["trace", "splat"].includes(item.type)) continue; + const data = snapshotEntity(worldRef, item); + const type = data.type || item.type || ""; + const keepId = Boolean(data.id && (referencedIds?.has?.(data.id) || type === "ant_nest" || type === "nest_box" || data.pinState === "lodged")); + const defaults = v5ItemDefaultsFor(type, keepId); + const extra = packSparseV5(data, V5_ITEM_EXTRA_KEYS, defaults, encodeV5ItemValue); + base.push(codeFromList(type, V4_ITEM_TYPES), compactCoordinate(data.x || 0), compactCoordinate(data.y || 0)); + if (extra.length) extras.push(index, extra); + index += 1; + } + return extras.length ? [base, extras] : [base]; + } + + function unpackV6Items(payload) { + const base = Array.isArray(payload?.[0]) ? payload[0] : []; + const extraPairs = Array.isArray(payload?.[1]) ? payload[1] : []; + const extraByIndex = new Map(); + for (let i = 0; i < extraPairs.length - 1; i += 2) extraByIndex.set(Number(extraPairs[i]) || 0, extraPairs[i + 1]); + const out = []; + for (let i = 0, index = 0; i < base.length - 2; i += 3, index++) { + const type = valueFromList(base[i], V4_ITEM_TYPES); + if (!type) continue; + const data = unpackSparseV5(extraByIndex.get(index) || [], V5_ITEM_EXTRA_KEYS, decodeV5ItemValue); + data.type = type; + data.x = Number(base[i + 1] || 0); + data.y = Number(base[i + 2] || 0); + out.push(data); + } + return out; + } + + function compactLossesV6(losses, ctx) { + if (!Array.isArray(losses) || !losses.length) return []; + const out = []; + for (const loss of losses.slice(-8)) { + if (!loss) continue; + const rel = codeOrValue(loss.relation || "", V6_LOSS_RELATIONS); + const fam = v6FamilyCode(loss.familyKey || "", ctx); + const live = v6IdToCode(loss.liveId || ""); + const row = [rel]; + if (fam !== "") row.push(fam); + if (live !== "") row.push(live); + out.push(row); + } + return out; + } + + function expandLossesV6(rows, ctx) { + if (!Array.isArray(rows)) return []; + return rows.map(row => Array.isArray(row) ? { + relation: valueOrCode(row[0], V6_LOSS_RELATIONS) || "unknown", + name: "unknown", + familyKey: row.length > 1 ? v6FamilyValue(row[1], ctx) : "", + liveId: row.length > 2 ? v6CodeToId(row[2]) : "", + reason: "鬮ョ蠑ア繝サ繝サ・ス繝サ・サ鬮」雋サ・」・ー郢晢スサ繝サ・。", + time: 0, + } : null).filter(Boolean); + } + + function compactFrozenEntryV6(entry = {}, ctx) { + const data = entry.data || entry; + const row = packV6Tarinai(null, data, ctx); + const out = [compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const losses = compactLossesV6(entry.pendingLosses || [], ctx); + if (losses.length) out.push(losses); + return out; + } + + function expandFrozenEntryV6(entry = [], ctx, index = 0) { + if (!Array.isArray(entry)) return expandFrozenEntryV5(entry); + const data = unpackV6Tarinai(entry[2] || [], ctx); + const freezeId = `fz${index + 1}`; + return { + freezeId, + frozenAt: entry[0] || 0, + worldTime: entry[1] || 0, + name: entry[3] || data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: expandLossesV6(entry[3] || [], ctx) + }; + } + + function createV6Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const maps = v6FamilyMaps(worldRef, frozen); + const ctx = { familyMap: maps.map, familyList: maps.list }; + const tarinaiRows = (worldRef.tarinai || []).filter(Boolean).map(t => packV6Tarinai(worldRef, t, ctx)); + const tarinaiDataRows = (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiDataRows); + return [ + V6_SNAPSHOT_VERSION, + packV6World(worldRef), + tarinaiRows, + packV6Items(worldRef, referencedIds), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(entry => compactFrozenEntryV6(entry, ctx)), + ]; + } + + function expandV6Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V6_SNAPSHOT_VERSION) return snapshot; + const ctx = {}; + const world = unpackV6World(snapshot[1] || []); + world.frozenTarinai = (snapshot[5] || []).map((entry, index) => expandFrozenEntryV6(entry, ctx, index)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: Array.isArray(snapshot[2]) ? snapshot[2].length : 0, items: Array.isArray(snapshot[3]?.[0]) ? Math.floor(snapshot[3][0].length / 3) : 0 }, + world, + tarinai: (snapshot[2] || []).map(row => unpackV6Tarinai(row, ctx)), + items: unpackV6Items(snapshot[3] || []), + ants: (snapshot[4] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + + + + // v7 hash format: alpha-only. It keeps individual names and gameplay state, + // drops dead individuals from the family graph/save rows, and relies on the save + // layer's printable-ASCII base91 transport for a shorter hash string. + const V7_SNAPSHOT_VERSION = 7; + const V7_CORE_TARINAI_KEYS = ["id", "familyKey", "x", "y", "age", "hunger", "loneliness", "energy", "stress", "affection", "need", "currentPersonality", "genetics", "adultScale", "scale", "lifeSpan", "state", "reproductionTimer", "digest"]; + const V7_EXTRA_TARINAI_KEYS = ["type", "liveToken", "vx", "vy", "impulseVx", "impulseVy", "generation", "birthTime", "parents", "children", "relationships", "hasPaired", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]; + const V7_NUMERIC_BASE = { + adultScale: 1, + scale: 1, + age: 10, + lifeSpan: 1700, + hunger: 30, + loneliness: 40, + energy: 75, + stress: 10, + affection: 15, + reproductionTimer: 30, + digest: 0, + }; + + function v7LiveContext(worldRef, frozen = []) { + const liveIds = new Set(); + const liveFamilyKeys = new Set(); + const frozenIds = new Set(); + const frozenFamilyKeys = new Set(); + for (const t of worldRef?.tarinai || []) { + if (!t || t.dead) continue; + if (t.id) liveIds.add(t.id); + if (t.familyKey) liveFamilyKeys.add(String(t.familyKey)); + } + for (const entry of frozen || []) { + const data = entry?.data || entry; + if (!data) continue; + if (data.id || entry.liveId) frozenIds.add(data.id || entry.liveId); + if (data.familyKey || entry.familyKey) frozenFamilyKeys.add(String(data.familyKey || entry.familyKey)); + } + return { liveIds, liveFamilyKeys, frozenIds, frozenFamilyKeys }; + } + + function v7FamilyMaps(worldRef, frozen = []) { + const live = v7LiveContext(worldRef, frozen); + const map = new Map(); + const list = []; + const add = (value) => { + if (!value) return null; + const key = String(value); + if (!map.has(key)) { + map.set(key, map.size); + list.push(key); + } + return map.get(key); + }; + for (const t of worldRef?.tarinai || []) { + if (!t || t.dead) continue; + add(t.familyKey || t.archiveKey || t.id); + for (const id of t.parents || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + for (const id of t.children || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + } + for (const entry of frozen || []) { + const data = entry?.data || entry; + if (!data) continue; + add(entry.familyKey || data.familyKey || data.id); + for (const id of data.parents || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + for (const id of data.children || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + // Pending death losses are gameplay notifications for frozen individuals, not family-chart nodes. + for (const loss of entry.pendingLosses || []) add(loss?.familyKey); + } + return { map, list, live }; + } + + function v7IsKnownFamily(value, ctx) { + if (!value) return false; + const key = String(value); + return Boolean(ctx?.live?.liveFamilyKeys?.has?.(key) || ctx?.live?.frozenFamilyKeys?.has?.(key)); + } + + function v7EncodeRelativeNumber(key, value) { + if (!Number.isFinite(value)) return undefined; + if (!Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return compactValue(value, 0, key); + return compactNumber(value - V7_NUMERIC_BASE[key]); + } + + function v7DecodeRelativeNumber(key, value) { + if (!Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return clonePlain(value); + return compactNumber(Number(value || 0) + V7_NUMERIC_BASE[key]); + } + + function compactRelationshipsV7(rels, ctx) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + const allowed = ctx?.live?.liveIds || null; + for (const [id, r] of Object.entries(rels)) { + if (!id || !r) continue; + if (allowed && !allowed.has(id)) continue; + const row = [v6RelationKey(id), compactMetric(r.affinity || 0), compactMetric(r.fear || 0), compactNumber(r.fightsWon || 0), compactNumber(r.fightsLost || 0)]; + while (row.length > 1 && (row[row.length - 1] === 0 || row[row.length - 1] === "" || row[row.length - 1] === null || row[row.length - 1] === undefined)) row.pop(); + if (row.length > 1) rows.push(row); + } + rows.sort((a, b) => { + const score = row => Math.abs(row[1] || 0) + Math.abs(row[2] || 0) + Math.abs(row[3] || 0) * 2 + Math.abs(row[4] || 0) * 2; + return score(b) - score(a); + }); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function encodeV7TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyCode(value, ctx); + if (key === "id") return v6IdToCode(value); + if (key === "name") return value || "tarinai"; + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "need") return codeOrValue(value, V5_NEEDS); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "currentPersonality") return packAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return packAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) && value.length ? value.filter(v => v7IsKnownFamily(v, ctx)).map(v => v6FamilyCode(v, ctx)).filter(v => v !== "") : undefined; + if (key === "relationships") return compactRelationshipsV7(value, ctx); + if (key === "itemEffectTimers") return compactItemEffectTimers(value); + if (Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return v7EncodeRelativeNumber(key, Number(value)); + return compactValue(value, 0, key); + } + + function decodeV7TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyValue(value, ctx); + if (key === "id") return v6CodeToId(value); + if (key === "name") return value || "tarinai"; + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "need") return valueOrCode(value, V5_NEEDS); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "currentPersonality") return unpackAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return unpackAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) ? value.map(v => v6FamilyValue(v, ctx)).filter(Boolean) : []; + if (key === "relationships") return expandRelationshipsV6(value); + if (key === "itemEffectTimers") return expandItemEffectTimers(value); + if (Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return v7DecodeRelativeNumber(key, value); + return clonePlain(value); + } + + function v7DefaultTarinaiData() { + const out = v6DefaultTarinaiData({}); + out.name = "tarinai"; + return out; + } + + function packV7CoreTarinai(data, ctx) { + const row = V7_CORE_TARINAI_KEYS.map(key => { + const encoded = encodeV7TarinaiValue(key, data?.[key], ctx); + return isEmptyCompactValue(encoded) ? null : encoded; + }); + while (row.length && (row[row.length - 1] === null || row[row.length - 1] === undefined || isEmptyCompactValue(row[row.length - 1]))) row.pop(); + return row; + } + + function unpackV7CoreTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < Math.min(row.length, V7_CORE_TARINAI_KEYS.length); i++) { + const value = row[i]; + if (value === null || value === undefined) continue; + const key = V7_CORE_TARINAI_KEYS[i]; + out[key] = decodeV7TarinaiValue(key, value, ctx); + } + return out; + } + + function packV7ExtrasTarinai(data, defaults, ctx) { + const out = []; + for (let i = 0; i < V7_EXTRA_TARINAI_KEYS.length; i++) { + const key = V7_EXTRA_TARINAI_KEYS[i]; + const encoded = encodeV7TarinaiValue(key, data?.[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + if (defaults && approxSame(encoded, encodeV7TarinaiValue(key, defaults[key], ctx), key)) continue; + out.push(i, encoded); + } + return out; + } + + function unpackV7ExtrasTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < row.length - 1; i += 2) { + const key = V7_EXTRA_TARINAI_KEYS[row[i]]; + if (!key) continue; + out[key] = decodeV7TarinaiValue(key, row[i + 1], ctx); + } + return out; + } + + function packV7Tarinai(worldRef, tarinai, ctx) { + const data = snapshotEntity(worldRef, tarinai); + data.parents = Array.isArray(data.parents) ? data.parents.filter(v => v7IsKnownFamily(v, ctx)) : []; + data.children = Array.isArray(data.children) ? data.children.filter(v => v7IsKnownFamily(v, ctx)) : []; + delete data.parentNames; + delete data.formerName; + delete data.birthPersonality; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.recentChangeCauses; + delete data.records; + delete data.deathReason; + delete data.favorite; + delete data.dead; + delete data.zunchiSlaveSpriteVariant; + delete data.zunchiStain; + delete data.sunbathSpriteVariant; + delete data.goodMode; + delete data.facing; + delete data.visualFacing; + delete data.fallDir; + delete data.hurtTimer; + const defaults = v7DefaultTarinaiData(); + const core = packV7CoreTarinai(data, ctx); + const extras = packV7ExtrasTarinai(data, defaults, ctx); + return extras.length ? [core, extras] : core; + } + + function unpackV7Tarinai(row, ctx) { + const split = Array.isArray(row) && Array.isArray(row[0]) && Array.isArray(row[1]); + const core = split ? row[0] : row; + const extras = split ? row[1] : []; + const data = unpackV7CoreTarinai(core, ctx); + Object.assign(data, unpackV7ExtrasTarinai(extras, ctx)); + if (!data.generation) data.generation = 1; + if (!data.name) data.name = data.isZunchiSlave || data.zunchiSlaveLocked ? "zunchi_slave" : "tarinai"; + if (!data.birthPersonality && data.currentPersonality) data.birthPersonality = clonePlain(data.currentPersonality); + data.dead = false; + return data; + } + + function compactLossesV7(losses, ctx) { + return compactLossesV6(losses, ctx); + } + + function compactFrozenEntryV7(entry = {}, ctx) { + const data = entry.data || entry; + const row = packV7Tarinai(null, data, ctx); + const out = [compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const name = data.name || entry.name || ""; + const losses = compactLossesV7(entry.pendingLosses || [], ctx); + if (name && name !== "tarinai") out.push(name); + else if (losses.length) out.push(null); + if (losses.length) out.push(losses); + return out; + } + + function expandFrozenEntryV7(entry = [], ctx, index = 0) { + if (!Array.isArray(entry)) return expandFrozenEntryV6(entry, ctx, index); + const data = unpackV7Tarinai(entry[2] || [], ctx); + const freezeId = `fz${index + 1}`; + return { + freezeId, + frozenAt: entry[0] || 0, + worldTime: entry[1] || 0, + name: entry[3] || data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: expandLossesV6(entry[4] || [], ctx) + }; + } + + function createV7Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const maps = v7FamilyMaps(worldRef, frozen); + const ctx = { familyMap: maps.map, familyList: maps.list, live: maps.live }; + const aliveTarinai = (worldRef.tarinai || []).filter(t => t && !t.dead); + const tarinaiRows = aliveTarinai.map(t => packV7Tarinai(worldRef, t, ctx)); + const names = aliveTarinai.map(t => t?.name || "tarinai").join("\n"); + const tarinaiDataRows = aliveTarinai.map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiDataRows); + return [ + V7_SNAPSHOT_VERSION, + packV6World(worldRef), + names, + tarinaiRows, + packV6Items(worldRef, referencedIds), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(entry => compactFrozenEntryV7(entry, ctx)), + ]; + } + + function expandV7Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V7_SNAPSHOT_VERSION) return snapshot; + const ctx = {}; + const world = unpackV6World(snapshot[1] || []); + const names = typeof snapshot[2] === "string" ? snapshot[2].split("\n") : (Array.isArray(snapshot[2]) ? snapshot[2] : []); + const tarinaiRows = Array.isArray(snapshot[3]) ? snapshot[3] : []; + world.frozenTarinai = (snapshot[6] || []).map((entry, index) => expandFrozenEntryV7(entry, ctx, index)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: tarinaiRows.length, items: Array.isArray(snapshot[4]?.[0]) ? Math.floor(snapshot[4][0].length / 3) : 0 }, + world, + tarinai: tarinaiRows.map((row, index) => { + const data = unpackV7Tarinai(row, ctx); + if (names[index]) data.name = names[index]; + return data; + }), + items: unpackV6Items(snapshot[4] || []), + ants: (snapshot[5] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + + // v8 hash format: alpha-only and intentionally drops legacy import support. + // It keeps gameplay state and individual names, removes frozen memories, saves no dead family nodes, + // and uses a tiny reset-sentinel for untouched initial colonies. + const V8_SNAPSHOT_VERSION = 8; + + function v8Names(worldRef) { + return (worldRef?.tarinai || []).filter(t => t && !t.dead).map(t => t.name || "tarinai").join("\n"); + } + + function v8InitialCandidate(worldRef) { + if (!worldRef) return false; + if ((worldRef.day || 1) !== 1 || Math.abs(worldRef.time || 0) > 0.08) return false; + if ((worldRef.deadCount || 0) !== 0) return false; + if ((worldRef.ants || []).some(a => a && !a.dead)) return false; + if ((worldRef.effects || []).length) return false; + if ((worldRef.frozenTarinai || []).length) return false; + const field = (typeof FIELD_TYPES !== "undefined" ? FIELD_TYPES?.[worldRef.fieldType || "garden"] : null) || null; + const expectedPop = field?.population ?? (typeof CONFIG !== "undefined" ? CONFIG.initialPopulation : null); + const expectedGrass = field?.grass ?? 18; + const living = (worldRef.tarinai || []).filter(t => t && !t.dead); + if (Number.isFinite(expectedPop) && living.length !== expectedPop) return false; + for (const t of living) { + if ((t.parents || []).length || (t.children || []).length || Object.keys(t.relationships || {}).length) return false; + if (t.state && t.state !== "idle") return false; + if (t.isZunchiSlave || t.zunchiSlaveLocked || t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) return false; + if (t.stuckPushpinId || t.insideNestBoxId || (t.oshiriByoZunchiStock || 0) > 0) return false; + } + const counts = {}; + for (const it of worldRef.items || []) { + if (!it || it.dead || ["trace", "splat"].includes(it.type)) continue; + counts[it.type] = (counts[it.type] || 0) + 1; + if (!["grass", "stone", "bed"].includes(it.type)) return false; + } + return (counts.grass || 0) === expectedGrass && (counts.stone || 0) === 1 && (counts.bed || 0) === 2; + } + + function compactFrozenEntryV8(entry = {}, ctx) { + const data = clonePlain(entry.data || entry) || {}; + delete data.relationships; + delete data.records; + delete data.recentChangeCauses; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.parentNames; + const row = packV7Tarinai(null, data, ctx); + const out = [compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const name = data.name || entry.name || ""; + if (name && name !== "tarinai") out.push(name); + return out; + } + + function expandFrozenEntryV8(entry = [], ctx, index = 0) { + if (!Array.isArray(entry)) return expandFrozenEntryV7(entry, ctx, index); + const data = unpackV7Tarinai(entry[2] || [], ctx); + if (entry[3]) data.name = entry[3]; + const freezeId = `fz${index + 1}`; + return { + freezeId, + frozenAt: entry[0] || 0, + worldTime: entry[1] || 0, + name: data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: [] + }; + } + + function createV8Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const fieldCode = codeOrValue(worldRef.fieldType || "garden", V6_FIELD_TYPES); + if (v8InitialCandidate(worldRef)) { + const names = v8Names(worldRef); + return [V8_SNAPSHOT_VERSION, 0, fieldCode, names || ""]; + } + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const maps = v7FamilyMaps(worldRef, frozen); + const ctx = { familyMap: maps.map, familyList: maps.list, live: maps.live }; + const aliveTarinai = (worldRef.tarinai || []).filter(t => t && !t.dead); + const tarinaiRows = aliveTarinai.map(t => packV7Tarinai(worldRef, t, ctx)); + const names = aliveTarinai.map(t => t?.name || "tarinai").join("\n"); + const tarinaiDataRows = aliveTarinai.map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiDataRows); + return [ + V8_SNAPSHOT_VERSION, + packV6World(worldRef), + names, + tarinaiRows, + packV6Items(worldRef, referencedIds), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(entry => compactFrozenEntryV8(entry, ctx)), + ]; + } + + function expandV8Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V8_SNAPSHOT_VERSION || snapshot[1] === 0) return snapshot; + const ctx = {}; + const world = unpackV6World(snapshot[1] || []); + const names = typeof snapshot[2] === "string" ? snapshot[2].split("\n") : (Array.isArray(snapshot[2]) ? snapshot[2] : []); + const tarinaiRows = Array.isArray(snapshot[3]) ? snapshot[3] : []; + world.frozenTarinai = (snapshot[6] || []).map((entry, index) => expandFrozenEntryV8(entry, ctx, index)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: tarinaiRows.length, items: Array.isArray(snapshot[4]?.[0]) ? Math.floor(snapshot[4][0].length / 3) : 0 }, + world, + tarinai: tarinaiRows.map((row, index) => { + const data = unpackV7Tarinai(row, ctx); + if (names[index]) data.name = names[index]; + return data; + }), + items: unpackV6Items(snapshot[4] || []), + ants: (snapshot[5] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + function restoreV8InitialSnapshot(snapshot, worldRef = global.world) { + if (!Array.isArray(snapshot) || snapshot[0] !== V8_SNAPSHOT_VERSION || snapshot[1] !== 0) return false; + const fieldType = valueOrCode(snapshot[2], V6_FIELD_TYPES) || "garden"; + if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); + if (typeof worldRef.reset === "function") worldRef.reset(null, fieldType); + const names = typeof snapshot[3] === "string" ? snapshot[3].split("\n") : []; + const living = (worldRef.tarinai || []).filter(t => t && !t.dead); + for (let i = 0; i < Math.min(names.length, living.length); i++) if (names[i]) living[i].name = names[i]; + worldRef.logs = []; + worldRef.selected = null; + worldRef.familyTreeDirty = true; + worldRef.drawListDirty = true; + worldRef.terrainDirty = true; + if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts(); + if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts(); + if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); + if (typeof renderLog === "function") renderLog([]); + if (typeof renderArchive === "function") renderArchive(); + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + if (typeof syncTopButtons === "function") syncTopButtons(); + global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef); + return true; + } + + + // v9 hash format: alpha-only and current-only. + // This removes the V8 initial-state sentinel and uses one binary packet for every state. + // Game-play state is kept; observation history, dead family nodes, and frozen memories are omitted. + const V9_SNAPSHOT_VERSION = 9; + const V9_NAME_CHARS = "\u306f\u3046\u3045\u3063"; + const V9_BOOLEAN_FLAGS = ["hasPaired", "zunchiDisease", "sleepDisease", "explosionDisease", "fightDisease", "isZunchiSlave", "zunchiSlaveLocked"]; + const V9_CORE_NUMERIC_KEYS = ["age", "hunger", "loneliness", "energy", "stress", "affection", "adultScale", "scale", "lifeSpan", "reproductionTimer", "digest"]; + const V9_EXTRA_TARINAI_KEYS = ["type", "state", "vx", "vy", "impulseVx", "impulseVy", "generation", "birthTime", "parents", "children", "relationships", "hasPaired", "loveMochiTimer", "fightMochiTimer", "zunchiDiseaseSeverity", "explosionDiseaseTimer", "totalFightLosses", "totalFightWins", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]; + const V9_TIMER_KEYS = new Set(["loveMochiTimer", "fightMochiTimer", "explosionDiseaseTimer", "laxativePoopTimer", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]); + const V9_ITEM_EXTRA_KEYS = ["r", "amount", "age", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "vx", "vy", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinFlipX", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + const V9_ANT_KEYS = ["kind", "homeId", "targetId", "targetToken", "x", "y", "vx", "vy", "r", "maxHp", "hp", "state", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingX", "foundingY", "foundingDelayUntil", "foundingAttempts"]; + const V9_WORLD_KEYS = ["w", "h", "time", "fieldType", "weather", "nextWeatherChange", "deadCount", "liveIdSerial", "lastBirthAt", "maxGeneration", "colonyMood", "lastColonyMoodDay"]; + + function v9Coord(value) { return Math.round((Number(value) || 0) * 10); } + function v9Uncoord(value) { return compactCoordinate((Number(value) || 0) / 10); } + function v9Num1(value) { return Math.round((Number(value) || 0) * 10); } + function v9Unnum1(value) { return compactNumber((Number(value) || 0) / 10); } + function v9Diff1(key, value) { return v9Num1((Number(value) || 0) - (V7_NUMERIC_BASE[key] || 0)); } + function v9Undiff1(key, value) { return compactNumber((V7_NUMERIC_BASE[key] || 0) + ((Number(value) || 0) / 10)); } + + function v9PackName(name) { + const s = String(name || "tarinai"); + if (s === "zunchi_slave") return -1; + if (s === "tarinai") return -2; + if (s.length > 0 && s.length <= 15 && Array.from(s).every(ch => V9_NAME_CHARS.includes(ch))) { + let code = s.length & 15; + let shift = 4; + for (const ch of s) { + code |= (V9_NAME_CHARS.indexOf(ch) & 3) << shift; + shift += 2; + } + return code; + } + return s; + } + + function v9UnpackName(value) { + if (value === -1) return "zunchi_slave"; + if (value === -2 || value === undefined || value === null || value === "") return "tarinai"; + if (typeof value !== "number") return String(value || "tarinai"); + const len = value & 15; + let out = ""; + for (let i = 0, shift = 4; i < len; i++, shift += 2) out += V9_NAME_CHARS[(value >> shift) & 3] || "\\u306f"; + return out || "tarinai"; + } + + function v9LiveLists(worldRef, frozen = []) { + const alive = (worldRef?.tarinai || []).filter(t => t && !t.dead); + const frozenRows = (frozen || []).map(entry => entry?.data || entry).filter(Boolean); + const all = [...alive, ...frozenRows]; + const idMap = new Map(); + const familyMap = new Map(); + all.forEach((t, index) => { + if (t?.id) idMap.set(String(t.id), index); + if (t?.familyKey) familyMap.set(String(t.familyKey), index); + if (t?.archiveKey) familyMap.set(String(t.archiveKey), index); + }); + return { alive, frozenRows, all, idMap, familyMap, liveCount: alive.length }; + } + + function v9ItemList(worldRef) { + const items = (worldRef?.items || []).filter(it => it && !it.dead && !["trace", "splat"].includes(it.type)); + const itemMap = new Map(); + items.forEach((it, index) => { if (it?.id) itemMap.set(String(it.id), index); }); + return { items, itemMap }; + } + + function v9PackTarinaiRef(value, ctx) { + if (!value) return undefined; + const idx = ctx?.idMap?.get?.(String(value)); + return idx === undefined ? undefined : idx + 1; + } + + function v9PackFamilyRef(value, ctx) { + if (!value) return undefined; + const idx = ctx?.familyMap?.get?.(String(value)); + return idx === undefined ? undefined : idx + 1; + } + + function v9PackItemRef(value, ctx) { + if (!value) return undefined; + const idx = ctx?.itemMap?.get?.(String(value)); + return idx === undefined ? undefined : idx + 1; + } + + function v9TarinaiId(index) { return `t${index + 1}`; } + function v9FamilyKey(index) { return `f${index + 1}`; } + function v9ItemId(index) { return `i${index + 1}`; } + function v9AntId(index) { return `a${index + 1}`; } + + function v9PackAxisDelta(obj, keys, defaults = null, scale = 100) { + if (!obj || typeof obj !== "object") return undefined; + let mask = 0; + const vals = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const base = defaults?.[key] ?? 0; + const diff = Math.round(((Number(obj[key]) || 0) - base) * scale); + if (!diff) continue; + mask |= (1 << i); + vals.push(diff); + } + return mask ? [mask, ...vals] : undefined; + } + + function v9UnpackAxisDelta(row, keys, defaults = null, scale = 100) { + const out = defaults && typeof defaults === "object" ? clonePlain(defaults) || {} : {}; + if (!Array.isArray(row) || !row.length) return out; + const mask = Number(row[0]) || 0; + let p = 1; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const base = defaults?.[key] ?? 0; + out[key] = mask & (1 << i) ? compactNumber(base + ((Number(row[p++]) || 0) / scale)) : base; + } + return out; + } + + function v9PackRelationships(rels, ctx) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + for (const [id, r] of Object.entries(rels)) { + const idx = v9PackTarinaiRef(id, ctx); + if (idx === undefined || !r) continue; + const aff = v9Num1(r.affinity || 0); + const fear = v9Num1(r.fear || 0); + const wins = Math.round(Number(r.fightsWon || 0)); + const losses = Math.round(Number(r.fightsLost || 0)); + if (!aff && !fear && !wins && !losses) continue; + const row = [idx, aff, fear, wins, losses]; + while (row.length > 1 && !row[row.length - 1]) row.pop(); + rows.push(row); + } + rows.sort((a, b) => (Math.abs(b[1] || 0) + Math.abs(b[2] || 0) + (b[3] || 0) * 20 + (b[4] || 0) * 20) - (Math.abs(a[1] || 0) + Math.abs(a[2] || 0) + (a[3] || 0) * 20 + (a[4] || 0) * 20)); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function v9UnpackRelationships(rows) { + if (!Array.isArray(rows)) return {}; + const out = {}; + for (const row of rows) { + if (!Array.isArray(row) || !Number.isFinite(row[0])) continue; + out[v9TarinaiId(row[0] - 1)] = { affinity: v9Unnum1(row[1] || 0), fear: v9Unnum1(row[2] || 0), fightsWon: Number(row[3] || 0), fightsLost: Number(row[4] || 0), lastEvent: "", lastTime: 0 }; + } + return out; + } + + function v9PackItemEffectTimers(value) { + if (!value || typeof value !== "object") return undefined; + const row = [v9Num1(value.laxative || 0), v9Num1(value.ammo || 0)]; + while (row.length && !row[row.length - 1]) row.pop(); + return row.length ? row : undefined; + } + + function v9UnpackItemEffectTimers(row) { + return { laxative: Array.isArray(row) ? v9Unnum1(row[0] || 0) : 0, ammo: Array.isArray(row) ? v9Unnum1(row[1] || 0) : 0 }; + } + + function v9EncodeTarinaiExtra(key, value, ctx) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "parents" || key === "children") { + const arr = Array.isArray(value) ? value.map(v => v9PackFamilyRef(v, ctx)).filter(v => v !== undefined) : []; + return arr.length ? arr : undefined; + } + if (key === "relationships") return v9PackRelationships(value, ctx); + if (key === "stuckPushpinId" || key === "insideNestBoxId") return v9PackItemRef(value, ctx); + if (key === "itemEffectTimers") return v9PackItemEffectTimers(value); + if (key === "powerItemMode") return codeOrValue(value, ["", "protein", "niteropu"]); + if (key === "sizeItemMode") return codeOrValue(value, ["", "giant_drug", "dwarf_drug"]); + if (key === "lifeItemMode") return codeOrValue(value, ["", "mercury"]); + if (V9_TIMER_KEYS.has(key)) return v9Num1(value || 0); + if (["vx", "vy", "impulseVx", "impulseVy"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeTarinaiExtra(key, value) { + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "parents" || key === "children") return Array.isArray(value) ? value.map(v => v9FamilyKey(v - 1)).filter(Boolean) : []; + if (key === "relationships") return v9UnpackRelationships(value); + if (key === "stuckPushpinId" || key === "insideNestBoxId") return Number.isFinite(value) ? v9ItemId(value - 1) : null; + if (key === "itemEffectTimers") return v9UnpackItemEffectTimers(value); + if (key === "powerItemMode") return valueOrCode(value, ["", "protein", "niteropu"]); + if (key === "sizeItemMode") return valueOrCode(value, ["", "giant_drug", "dwarf_drug"]); + if (key === "lifeItemMode") return valueOrCode(value, ["", "mercury"]); + if (V9_TIMER_KEYS.has(key)) return v9Unnum1(value || 0); + if (["vx", "vy", "impulseVx", "impulseVy"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackFlags(data) { + let flags = 0; + for (let i = 0; i < V9_BOOLEAN_FLAGS.length; i++) if (data?.[V9_BOOLEAN_FLAGS[i]]) flags |= (1 << i); + return flags; + } + + function v9ApplyFlags(data, flags) { + for (let i = 0; i < V9_BOOLEAN_FLAGS.length; i++) data[V9_BOOLEAN_FLAGS[i]] = Boolean(flags & (1 << i)); + } + + function v9PackTarinai(worldRef, t, ctx) { + const data = snapshotEntity(worldRef, t); + data.parents = Array.isArray(data.parents) ? data.parents.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + data.children = Array.isArray(data.children) ? data.children.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + const row = [v9PackName(data.name), v9Coord(data.x), v9Coord(data.y), codeOrValue(data.need || "", V5_NEEDS)]; + const numeric = []; + for (let i = 0; i < V9_CORE_NUMERIC_KEYS.length; i++) { + const key = V9_CORE_NUMERIC_KEYS[i]; + const diff = v9Diff1(key, Number(data[key] || 0)); + if (diff) numeric.push(i, diff); + } + if (numeric.length) row.push(numeric); else row.push(null); + const personality = v9PackAxisDelta(data.currentPersonality, PERSONALITY_ORDER, null, 100); + const genetics = v9PackAxisDelta(data.genetics, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + const axes = []; + if (personality) axes.push(0, personality); + if (genetics) axes.push(1, genetics); + row.push(axes.length ? axes : null); + const flags = v9PackFlags(data); + row.push(flags || null); + const defaults = v7DefaultTarinaiData(); + const extras = []; + for (let i = 0; i < V9_EXTRA_TARINAI_KEYS.length; i++) { + const key = V9_EXTRA_TARINAI_KEYS[i]; + if (V9_BOOLEAN_FLAGS.includes(key)) continue; + const encoded = v9EncodeTarinaiExtra(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeTarinaiExtra(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + extras.push(i, encoded); + } + row.push(extras.length ? extras : null); + while (row.length > 4 && isEmptyCompactValue(row[row.length - 1])) row.pop(); + return row; + } + + function v9UnpackTarinai(row, index = 0) { + const data = { id: v9TarinaiId(index), familyKey: v9FamilyKey(index), liveToken: index + 1, generation: 1, state: "idle", type: "smile", parents: [], children: [], relationships: {}, itemEffectTimers: { laxative: 0, ammo: 0 } }; + if (!Array.isArray(row)) return data; + data.name = v9UnpackName(row[0]); + data.x = v9Uncoord(row[1] || 0); + data.y = v9Uncoord(row[2] || 0); + data.need = valueOrCode(row[3], V5_NEEDS) || ""; + for (const key of V9_CORE_NUMERIC_KEYS) data[key] = V7_NUMERIC_BASE[key] ?? 0; + const numeric = Array.isArray(row[4]) ? row[4] : []; + for (let i = 0; i < numeric.length - 1; i += 2) { + const key = V9_CORE_NUMERIC_KEYS[numeric[i]]; + if (key) data[key] = v9Undiff1(key, numeric[i + 1]); + } + const axes = Array.isArray(row[5]) ? row[5] : []; + for (let i = 0; i < axes.length - 1; i += 2) { + if (axes[i] === 0) data.currentPersonality = v9UnpackAxisDelta(axes[i + 1], PERSONALITY_ORDER, null, 100); + if (axes[i] === 1) data.genetics = v9UnpackAxisDelta(axes[i + 1], GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + } + if (!data.currentPersonality) data.currentPersonality = {}; + if (!data.birthPersonality) data.birthPersonality = clonePlain(data.currentPersonality); + if (!data.genetics) data.genetics = { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }; + v9ApplyFlags(data, Number(row[6] || 0)); + const extras = Array.isArray(row[7]) ? row[7] : []; + for (let i = 0; i < extras.length - 1; i += 2) { + const key = V9_EXTRA_TARINAI_KEYS[extras[i]]; + if (!key) continue; + data[key] = v9DecodeTarinaiExtra(key, extras[i + 1]); + } + if (data.isZunchiSlave || data.zunchiSlaveLocked) { + data.isZunchiSlave = true; + data.zunchiSlaveLocked = true; + data.name = "zunchi_slave"; + } + data.dead = false; + return data; + } + + function v9EncodeItemValue(key, value, ctx) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "toolSize" || key === "machineSlotSize") return codeOrValue(value, V5_SIZES); + if (key === "pinState") return codeOrValue(value, V5_PIN_STATES); + if (key === "stage") return codeOrValue(value, V5_ZUNCHI_STAGES); + if (["machineSlotType", "machineLastSlotType", "sourceMachineType"].includes(key)) return codeOrValue(value, V4_ITEM_TYPES); + if (key === "pinTargetId") return v9PackTarinaiRef(value, ctx); + if (["machineProducerId", "projectileSourceId"].includes(key)) return v9PackItemRef(value, ctx); + if (["vx", "vy", "spinVelocity", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "cannonAngle"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeItemValue(key, value) { + if (key === "toolSize" || key === "machineSlotSize") return valueOrCode(value, V5_SIZES); + if (key === "pinState") return valueOrCode(value, V5_PIN_STATES); + if (key === "stage") return valueOrCode(value, V5_ZUNCHI_STAGES); + if (["machineSlotType", "machineLastSlotType", "sourceMachineType"].includes(key)) return valueOrCode(value, V4_ITEM_TYPES); + if (key === "pinTargetId") return Number.isFinite(value) ? v9TarinaiId(value - 1) : ""; + if (["machineProducerId", "projectileSourceId"].includes(key)) return Number.isFinite(value) ? v9ItemId(value - 1) : ""; + if (["vx", "vy", "spinVelocity", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "cannonAngle"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackItems(worldRef, ctx) { + const base = []; + const extras = []; + for (let index = 0; index < ctx.items.length; index++) { + const item = ctx.items[index]; + const data = snapshotEntity(worldRef, item); + const type = data.type || item.type || ""; + base.push(codeFromList(type, V4_ITEM_TYPES), v9Coord(data.x), v9Coord(data.y)); + const defaults = itemDefaultsFor(type); + const pairs = []; + for (let i = 0; i < V9_ITEM_EXTRA_KEYS.length; i++) { + const key = V9_ITEM_EXTRA_KEYS[i]; + const encoded = v9EncodeItemValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeItemValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + pairs.push(i, encoded); + } + if (pairs.length) extras.push(index, pairs); + } + return extras.length ? [base, extras] : [base]; + } + + function v9UnpackItems(payload) { + const base = Array.isArray(payload?.[0]) ? payload[0] : []; + const extraPairs = Array.isArray(payload?.[1]) ? payload[1] : []; + const extraByIndex = new Map(); + for (let i = 0; i < extraPairs.length - 1; i += 2) extraByIndex.set(Number(extraPairs[i]) || 0, extraPairs[i + 1]); + const out = []; + for (let i = 0, index = 0; i < base.length - 2; i += 3, index++) { + const type = valueFromList(base[i], V4_ITEM_TYPES); + if (!type) continue; + const data = { id: v9ItemId(index), type, x: v9Uncoord(base[i + 1] || 0), y: v9Uncoord(base[i + 2] || 0) }; + const extras = extraByIndex.get(index) || []; + for (let p = 0; p < extras.length - 1; p += 2) { + const key = V9_ITEM_EXTRA_KEYS[extras[p]]; + if (!key) continue; + data[key] = v9DecodeItemValue(key, extras[p + 1]); + } + out.push(data); + } + return out; + } + + function v9EncodeAntValue(key, value, ctx) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "homeId") return v9PackItemRef(value, ctx); + if (key === "targetId") { + const item = v9PackItemRef(value, ctx); + if (item !== undefined) return item; + const tarinai = v9PackTarinaiRef(value, ctx); + return tarinai !== undefined ? -tarinai : undefined; + } + if (["x", "y", "foundingX", "foundingY"].includes(key)) return v9Coord(value || 0); + if (["vx", "vy", "r", "hp", "maxHp", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingDelayUntil"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeAntValue(key, value) { + if (key === "homeId") return Number.isFinite(value) ? v9ItemId(value - 1) : ""; + if (key === "targetId") { + if (!Number.isFinite(value)) return ""; + return value < 0 ? v9TarinaiId((-value) - 1) : v9ItemId(value - 1); + } + if (["x", "y", "foundingX", "foundingY"].includes(key)) return v9Uncoord(value || 0); + if (["vx", "vy", "r", "hp", "maxHp", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingDelayUntil"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackAnts(worldRef, ctx) { + const rows = []; + for (const ant of worldRef.ants || []) { + if (!ant || ant.dead) continue; + const data = snapshotEntity(worldRef, ant); + const pairs = []; + const defaults = { kind: "worker", x: 0, y: 0, vx: 0, vy: 0, r: 4, maxHp: 1, hp: 1, state: "wander", age: 0, seed: 0, wanderAngle: 0, nextTurn: 0, returnTimer: 0, foundingAttempts: 0 }; + for (let i = 0; i < V9_ANT_KEYS.length; i++) { + const key = V9_ANT_KEYS[i]; + const encoded = v9EncodeAntValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeAntValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + pairs.push(i, encoded); + } + rows.push(pairs); + } + return rows; + } + + function v9UnpackAnts(rows) { + if (!Array.isArray(rows)) return []; + return rows.map((row, index) => { + const data = { id: v9AntId(index) }; + for (let i = 0; i < row.length - 1; i += 2) { + const key = V9_ANT_KEYS[row[i]]; + if (!key) continue; + data[key] = v9DecodeAntValue(key, row[i + 1]); + } + return data; + }); + } + + function v9EncodeWorldValue(key, value) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "fieldType") return codeOrValue(value, V6_FIELD_TYPES); + if (key === "weather") return codeOrValue(value, V6_WEATHERS); + if (key === "colonyMood") return codeOrValue(value, V5_MOODS); + if (["w", "h", "time", "nextWeatherChange", "lastBirthAt", "lastColonyMoodDay"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeWorldValue(key, value) { + if (key === "fieldType") return valueOrCode(value, V6_FIELD_TYPES); + if (key === "weather") return valueOrCode(value, V6_WEATHERS); + if (key === "colonyMood") return { id: valueOrCode(value, V5_MOODS) || "relaxed" }; + if (["w", "h", "time", "nextWeatherChange", "lastBirthAt", "lastColonyMoodDay"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackWorld(worldRef) { + const world = worldData(worldRef); + world.colonyMood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + delete world.day; + const defaults = { w: 1000, h: 720, time: 0, fieldType: "garden", weather: "sunny", deadCount: 0, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: "relaxed", lastColonyMoodDay: 0 }; + return packSparseV5(world, V9_WORLD_KEYS, defaults, v9EncodeWorldValue); + } + + function v9UnpackWorld(row) { + const world = unpackSparseV5(row, V9_WORLD_KEYS, v9DecodeWorldValue); + world.day = 1; + if (!world.fieldType) world.fieldType = "garden"; + if (!world.colonyMood) world.colonyMood = { id: "relaxed" }; + return world; + } + + function v9PackFrozen(entry = {}, ctx) { + const data = clonePlain(entry.data || entry) || {}; + delete data.relationships; + delete data.records; + delete data.recentChangeCauses; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.parentNames; + const row = v9PackTarinai(null, data, ctx); + const out = [v9Num1(entry.frozenAt || 0), v9Num1(entry.worldTime || 0), row]; + return out; + } + + function v9UnpackFrozen(entry = [], index = 0, liveCount = 0) { + const data = v9UnpackTarinai(entry[2] || [], liveCount + index); + data.id = `ft${index + 1}`; + data.familyKey = `ff${index + 1}`; + const freezeId = `fz${index + 1}`; + return { freezeId, frozenAt: v9Unnum1(entry[0] || 0), worldTime: v9Unnum1(entry[1] || 0), name: data.name || "tarinai", familyKey: data.familyKey || "", liveId: data.id || "", liveToken: data.liveToken || 0, type: data.type || "smile", data, pendingLosses: [] }; + } + + function createV9SnapshotArray(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const live = v9LiveLists(worldRef, frozen); + const itemInfo = v9ItemList(worldRef); + const ctx = { ...live, ...itemInfo }; + return [ + V9_SNAPSHOT_VERSION, + v9PackWorld(worldRef), + live.alive.map(t => v9PackTarinai(worldRef, t, ctx)), + v9PackItems(worldRef, ctx), + v9PackAnts(worldRef, ctx), + frozen.map(entry => v9PackFrozen(entry, ctx)), + ]; + } + + function expandV9SnapshotArray(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V9_SNAPSHOT_VERSION) return snapshot; + const world = v9UnpackWorld(snapshot[1] || []); + const tarinaiRows = Array.isArray(snapshot[2]) ? snapshot[2] : []; + world.frozenTarinai = (snapshot[5] || []).map((entry, index) => v9UnpackFrozen(entry, index, tarinaiRows.length)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: 1, time: "", fieldType: world.fieldType || "garden", population: tarinaiRows.length, items: Array.isArray(snapshot[3]?.[0]) ? Math.floor(snapshot[3][0].length / 3) : 0 }, + world, + tarinai: tarinaiRows.map((row, index) => v9UnpackTarinai(row, index)), + items: v9UnpackItems(snapshot[3] || []), + ants: v9UnpackAnts(snapshot[4] || []), + effects: [], + logs: [], + selectedRef: null, + }; + } + + class V9BinaryWriter { + constructor() { this.bytes = []; } + byte(v) { this.bytes.push(v & 255); } + varUint(v) { + let n = Math.max(0, Math.floor(Number(v) || 0)); + while (n >= 128) { this.byte((n & 127) | 128); n = Math.floor(n / 128); } + this.byte(n); + } + sint(v) { + const n = Math.trunc(Number(v) || 0); + this.varUint(n < 0 ? ((-n) * 2 - 1) : n * 2); + } + bytesRaw(bytes) { for (const b of bytes) this.byte(b); } + value(value) { + if (value === undefined || value === null) { this.byte(0); return; } + if (value === false) { this.byte(1); return; } + if (value === true) { this.byte(2); return; } + if (typeof value === "number") { + if (Number.isInteger(value)) { this.byte(value >= 0 ? 3 : 4); value >= 0 ? this.varUint(value) : this.sint(value); return; } + this.byte(5); this.sint(Math.round(value * 1000)); return; + } + if (typeof value === "string") { + const bytes = new TextEncoder().encode(value); + this.byte(6); this.varUint(bytes.length); this.bytesRaw(bytes); return; + } + if (Array.isArray(value)) { + this.byte(7); this.varUint(value.length); for (const v of value) this.value(v); return; + } + this.value(JSON.stringify(value)); + } + finish() { return new Uint8Array(this.bytes); } + } + + class V9BinaryReader { + constructor(bytes) { this.bytes = bytes || new Uint8Array(); this.i = 0; } + byte() { return this.i < this.bytes.length ? this.bytes[this.i++] : 0; } + varUint() { + let shift = 0, out = 0, mul = 1; + while (this.i < this.bytes.length) { + const b = this.byte(); + out += (b & 127) * mul; + if (!(b & 128)) break; + mul *= 128; + shift += 7; + if (shift > 42) break; + } + return out; + } + sint() { const u = this.varUint(); return u & 1 ? -((u + 1) / 2) : u / 2; } + value() { + const tag = this.byte(); + if (tag === 0) return null; + if (tag === 1) return false; + if (tag === 2) return true; + if (tag === 3) return this.varUint(); + if (tag === 4) return this.sint(); + if (tag === 5) return this.sint() / 1000; + if (tag === 6) { + const len = this.varUint(); + const slice = this.bytes.slice(this.i, this.i + len); + this.i += len; + return new TextDecoder().decode(slice); + } + if (tag === 7) { + const len = this.varUint(); + const arr = []; + for (let i = 0; i < len; i++) arr.push(this.value()); + return arr; + } + return null; + } + } + + + // v10 hash format: current-only compact packet. + // It removes the V8 initial-state special case and does not use generic JSON/value arrays for common live data. + // Game-play state is kept; observation history, elapsed day count, dead family nodes, and frozen memories are omitted. + const V10_SNAPSHOT_VERSION = 10; + const V10_NAME_CHARS = V9_NAME_CHARS; + const V10_WORLD_KEYS = ["w", "h", "time", "fieldType", "weather", "nextWeatherChange", "deadCount", "lastBirthAt", "maxGeneration", "colonyMood"]; + const V10_WORLD_DEFAULTS = { w: 1000, h: 720, time: 0, fieldType: "garden", weather: "sunny", nextWeatherChange: 0, deadCount: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: "relaxed" }; + + class V10Writer { + constructor() { this.bytes = []; } + byte(v) { this.bytes.push((Number(v) || 0) & 255); } + u(v) { + let n = Math.max(0, Math.floor(Number(v) || 0)); + while (n >= 128) { this.byte((n % 128) | 128); n = Math.floor(n / 128); } + this.byte(n); + } + z(v) { + const n = Math.trunc(Number(v) || 0); + this.u(n < 0 ? ((-n) * 2 - 1) : n * 2); + } + raw(bytes) { for (const b of bytes || []) this.byte(b); } + str(value) { + const bytes = new TextEncoder().encode(String(value ?? "")); + this.u(bytes.length); + this.raw(bytes); + } + val(value) { + if (value === undefined || value === null) { this.byte(0); return; } + if (value === false) { this.byte(1); return; } + if (value === true) { this.byte(2); return; } + if (typeof value === "number") { this.byte(3); this.z(Math.round(value)); return; } + if (typeof value === "string") { this.byte(4); this.str(value); return; } + if (Array.isArray(value)) { this.byte(5); this.u(value.length); for (const v of value) this.val(v); return; } + this.byte(6); this.str(JSON.stringify(value)); + } + finish() { return new Uint8Array(this.bytes); } + } + + class V10Reader { + constructor(bytes) { this.bytes = bytes || new Uint8Array(); this.i = 0; } + byte() { return this.i < this.bytes.length ? this.bytes[this.i++] : 0; } + u() { + let out = 0, mul = 1, guard = 0; + while (this.i < this.bytes.length && guard++ < 8) { + const b = this.byte(); + out += (b & 127) * mul; + if (!(b & 128)) break; + mul *= 128; + } + return out; + } + z() { const u = this.u(); return u & 1 ? -((u + 1) / 2) : u / 2; } + str() { + const len = this.u(); + const slice = this.bytes.slice(this.i, this.i + len); + this.i += len; + return new TextDecoder().decode(slice); + } + val() { + const tag = this.byte(); + if (tag === 0) return null; + if (tag === 1) return false; + if (tag === 2) return true; + if (tag === 3) return this.z(); + if (tag === 4) return this.str(); + if (tag === 5) { const len = this.u(); const out = []; for (let i = 0; i < len; i++) out.push(this.val()); return out; } + if (tag === 6) { try { return JSON.parse(this.str()); } catch (_) { return {}; } } + return null; + } + } + + function v10PackNameInfo(name) { + const s = String(name || "tarinai"); + if (s === "tarinai") return [0]; + if (s === "zunchi_slave") return [1]; + const chars = Array.from(s); + if (chars.length > 0 && chars.length <= 24 && chars.every(ch => V10_NAME_CHARS.includes(ch))) { + let code = 0; + let mul = 1; + for (const ch of chars) { + code += (V10_NAME_CHARS.indexOf(ch) & 3) * mul; + mul *= 4; + } + return [2, chars.length, code]; + } + return [3, s]; + } + + function v10WriteName(w, name) { + const info = v10PackNameInfo(name); + w.u(info[0]); + if (info[0] === 2) { w.u(info[1]); w.u(info[2]); } + else if (info[0] === 3) w.str(info[1]); + } + + function v10ReadName(r) { + const mode = r.u(); + if (mode === 0) return "tarinai"; + if (mode === 1) return "zunchi_slave"; + if (mode === 2) { + const len = r.u(); + let code = r.u(); + let out = ""; + for (let i = 0; i < len; i++) { + out += V10_NAME_CHARS[code % 4] || "\\u306f"; + code = Math.floor(code / 4); + } + return out || "tarinai"; + } + if (mode === 3) return r.str() || "tarinai"; + return "tarinai"; + } + + function v10WriteAxis(w, obj, keys, defaults = null, scale = 100) { + const packed = v9PackAxisDelta(obj, keys, defaults, scale); + if (!packed) { w.u(0); return; } + const mask = packed[0] || 0; + w.u(mask); + for (let i = 1; i < packed.length; i++) w.z(packed[i]); + } + + function v10ReadAxis(r, keys, defaults = null, scale = 100) { + const mask = r.u(); + if (!mask) return defaults && typeof defaults === "object" ? clonePlain(defaults) || {} : {}; + const row = [mask]; + for (let i = 0; i < keys.length; i++) if (mask & (1 << i)) row.push(r.z()); + return v9UnpackAxisDelta(row, keys, defaults, scale); + } + + function v10WriteTarinai(w, t, worldRef, ctx) { + const data = snapshotEntity(worldRef, t); + data.parents = Array.isArray(data.parents) ? data.parents.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + data.children = Array.isArray(data.children) ? data.children.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + v10WriteName(w, data.name); + w.u(v9Coord(data.x)); + w.u(v9Coord(data.y)); + w.u((codeFromList(data.need || "", V5_NEEDS) + 1) || 0); + let numericMask = 0; + const numericValues = []; + for (let i = 0; i < V9_CORE_NUMERIC_KEYS.length; i++) { + const key = V9_CORE_NUMERIC_KEYS[i]; + const diff = v9Diff1(key, Number(data[key] || 0)); + if (!diff) continue; + numericMask |= (1 << i); + numericValues.push(diff); + } + w.u(numericMask); + for (const v of numericValues) w.z(v); + v10WriteAxis(w, data.currentPersonality, PERSONALITY_ORDER, null, 100); + v10WriteAxis(w, data.genetics, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + w.u(v9PackFlags(data)); + const defaults = v7DefaultTarinaiData(); + for (let i = 0; i < V9_EXTRA_TARINAI_KEYS.length; i++) { + const key = V9_EXTRA_TARINAI_KEYS[i]; + if (V9_BOOLEAN_FLAGS.includes(key)) continue; + const encoded = v9EncodeTarinaiExtra(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeTarinaiExtra(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + w.u(i + 1); + w.val(encoded); + } + w.u(0); + } + + function v10ReadTarinai(r, index = 0) { + const data = { id: v9TarinaiId(index), familyKey: v9FamilyKey(index), liveToken: index + 1, generation: 1, state: "idle", type: "smile", parents: [], children: [], relationships: {}, itemEffectTimers: { laxative: 0, ammo: 0 } }; + data.name = v10ReadName(r); + data.x = v9Uncoord(r.u()); + data.y = v9Uncoord(r.u()); + const need = r.u(); + data.need = need ? (valueFromList(need - 1, V5_NEEDS) || "") : ""; + for (const key of V9_CORE_NUMERIC_KEYS) data[key] = V7_NUMERIC_BASE[key] ?? 0; + const numericMask = r.u(); + for (let i = 0; i < V9_CORE_NUMERIC_KEYS.length; i++) { + if (!(numericMask & (1 << i))) continue; + const key = V9_CORE_NUMERIC_KEYS[i]; + data[key] = v9Undiff1(key, r.z()); + } + data.currentPersonality = v10ReadAxis(r, PERSONALITY_ORDER, null, 100); + data.birthPersonality = clonePlain(data.currentPersonality); + data.genetics = v10ReadAxis(r, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + v9ApplyFlags(data, r.u()); + while (true) { + const tag = r.u(); + if (!tag) break; + const key = V9_EXTRA_TARINAI_KEYS[tag - 1]; + const value = r.val(); + if (!key) continue; + data[key] = v9DecodeTarinaiExtra(key, value); + } + if (data.isZunchiSlave || data.zunchiSlaveLocked) { + data.isZunchiSlave = true; + data.zunchiSlaveLocked = true; + data.name = "zunchi_slave"; + } + data.dead = false; + return data; + } + + function v10WriteWorld(w, worldRef) { + const world = worldData(worldRef); + world.colonyMood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + delete world.day; + let mask = 0; + const values = []; + for (let i = 0; i < V10_WORLD_KEYS.length; i++) { + const key = V10_WORLD_KEYS[i]; + const raw = key === "colonyMood" ? world.colonyMood : world[key]; + const def = V10_WORLD_DEFAULTS[key]; + let encoded; + if (key === "fieldType") encoded = codeOrValue(raw, V6_FIELD_TYPES); + else if (key === "weather") encoded = codeOrValue(raw, V6_WEATHERS); + else if (key === "colonyMood") encoded = codeOrValue(raw, V5_MOODS); + else if (["w", "h", "time", "nextWeatherChange", "lastBirthAt"].includes(key)) encoded = v9Num1((Number(raw) || 0) - (Number(def) || 0)); + else encoded = Math.round(Number(raw ?? def) || 0) - Math.round(Number(def) || 0); + if (!encoded) continue; + mask |= (1 << i); + values.push(encoded); + } + w.u(mask); + for (const v of values) w.z(v); + } + + function v10ReadWorld(r) { + const out = { day: 1, fieldType: "garden", colonyMood: { id: "relaxed" } }; + const mask = r.u(); + for (let i = 0; i < V10_WORLD_KEYS.length; i++) { + const key = V10_WORLD_KEYS[i]; + const def = V10_WORLD_DEFAULTS[key]; + if (!(mask & (1 << i))) { if (key !== "colonyMood") out[key] = def; continue; } + const v = r.z(); + if (key === "fieldType") out[key] = valueOrCode(v, V6_FIELD_TYPES) || "garden"; + else if (key === "weather") out[key] = valueOrCode(v, V6_WEATHERS) || "sunny"; + else if (key === "colonyMood") out[key] = { id: valueOrCode(v, V5_MOODS) || "relaxed" }; + else if (["w", "h", "time", "nextWeatherChange", "lastBirthAt"].includes(key)) out[key] = v9Unnum1(v9Num1(def) + v); + else out[key] = Math.round(Number(def) || 0) + v; + } + if (!out.colonyMood) out.colonyMood = { id: "relaxed" }; + return out; + } + + function v10WriteItems(w, worldRef, ctx) { + w.u(ctx.items.length); + for (let index = 0; index < ctx.items.length; index++) { + const item = ctx.items[index]; + const data = snapshotEntity(worldRef, item); + const type = data.type || item.type || ""; + w.u((codeFromList(type, V4_ITEM_TYPES) + 1) || 0); + w.u(v9Coord(data.x)); + w.u(v9Coord(data.y)); + const defaults = itemDefaultsFor(type); + for (let i = 0; i < V9_ITEM_EXTRA_KEYS.length; i++) { + const key = V9_ITEM_EXTRA_KEYS[i]; + if (key === "antWorkers" || key === "workerSprite") continue; + const encoded = v9EncodeItemValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeItemValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + w.u(i + 1); + w.val(encoded); + } + w.u(0); + } + } + + function v10ReadItems(r) { + const count = r.u(); + const out = []; + for (let index = 0; index < count; index++) { + const type = valueFromList(r.u() - 1, V4_ITEM_TYPES); + const data = { id: v9ItemId(index), type, x: v9Uncoord(r.u()), y: v9Uncoord(r.u()) }; + while (true) { + const tag = r.u(); + if (!tag) break; + const key = V9_ITEM_EXTRA_KEYS[tag - 1]; + const value = r.val(); + if (!key) continue; + data[key] = v9DecodeItemValue(key, value); + } + if (type) out.push(data); + } + return out; + } + + function v10WriteAnts(w, worldRef, ctx) { + const ants = (worldRef.ants || []).filter(a => a && !a.dead); + w.u(ants.length); + for (const ant of ants) { + const data = snapshotEntity(worldRef, ant); + const defaults = { kind: "worker", x: 0, y: 0, vx: 0, vy: 0, r: 4, maxHp: 1, hp: 1, state: "wander", age: 0, seed: 0, wanderAngle: 0, nextTurn: 0, returnTimer: 0, foundingAttempts: 0 }; + for (let i = 0; i < V9_ANT_KEYS.length; i++) { + const key = V9_ANT_KEYS[i]; + const encoded = v9EncodeAntValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeAntValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + w.u(i + 1); + w.val(encoded); + } + w.u(0); + } + } + + function v10ReadAnts(r) { + const count = r.u(); + const out = []; + for (let index = 0; index < count; index++) { + const data = { id: v9AntId(index) }; + while (true) { + const tag = r.u(); + if (!tag) break; + const key = V9_ANT_KEYS[tag - 1]; + const value = r.val(); + if (!key) continue; + data[key] = v9DecodeAntValue(key, value); + } + out.push(data); + } + return out; + } + + function v10WriteFrozen(w, frozen, ctx) { + w.u((frozen || []).length); + for (const entry of frozen || []) { + w.z(v9Num1(entry?.frozenAt || 0)); + w.z(v9Num1(entry?.worldTime || 0)); + const data = clonePlain(entry?.data || entry) || {}; + delete data.relationships; + delete data.records; + delete data.recentChangeCauses; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.parentNames; + v10WriteTarinai(w, data, null, ctx); + } + } + + function v10ReadFrozen(r, liveCount = 0) { + const count = r.u(); + const out = []; + for (let index = 0; index < count; index++) { + const frozenAt = v9Unnum1(r.z()); + const worldTime = v9Unnum1(r.z()); + const data = v10ReadTarinai(r, liveCount + index); + data.id = `ft${index + 1}`; + data.familyKey = `ff${index + 1}`; + out.push({ freezeId: `fz${index + 1}`, frozenAt, worldTime, name: data.name || "tarinai", familyKey: data.familyKey || "", liveId: data.id || "", liveToken: data.liveToken || 0, type: data.type || "smile", data, pendingLosses: [] }); + } + return out; + } + + function createV10SnapshotBytes(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const live = v9LiveLists(worldRef, frozen); + const itemInfo = v9ItemList(worldRef); + const ctx = { ...live, ...itemInfo }; + const w = new V10Writer(); + w.byte(0x54); + w.byte(0x0a); + v10WriteWorld(w, worldRef); + w.u(live.alive.length); + for (const t of live.alive) v10WriteTarinai(w, t, worldRef, ctx); + v10WriteItems(w, worldRef, ctx); + v10WriteAnts(w, worldRef, ctx); + v10WriteFrozen(w, frozen, ctx); + return w.finish(); + } + + function decodeV10SnapshotBytes(bytes) { + const r = new V10Reader(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || [])); + if (r.byte() !== 0x54 || r.byte() !== 0x0a) throw new Error("invalid V10 save packet"); + const world = v10ReadWorld(r); + const liveCount = r.u(); + const tarinai = []; + for (let i = 0; i < liveCount; i++) tarinai.push(v10ReadTarinai(r, i)); + const items = v10ReadItems(r); + const ants = v10ReadAnts(r); + world.frozenTarinai = v10ReadFrozen(r, liveCount); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: 1, time: "", fieldType: world.fieldType || "garden", population: tarinai.length, items: items.length }, + world, + tarinai, + items, + ants, + effects: [], + logs: [], + selectedRef: null, + }; + } + + // v11 hash format: current-only wrapper. Mode 0 is a semantic reset/delta + // packet for initial and modest early-game saves; mode 1 embeds a V10 packet + // so complex worlds still preserve the full compact simulation state. + const V11_SNAPSHOT_VERSION = 11; + const V11_SEMANTIC_ITEM_TYPES = [ + "sweet", "love_mochi", "fight_mochi", "water_bowl", "sleep_drug", "laxative", "protein", "niteropu", "ammo", + "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "zunchi", "genkotsu", "nest_box", + "ant_nest", "ball", "signboard", "pushpin", "oshibyo", "duplicator", "cannon", "fence_v", "fence_h", "water" + ]; + const V11_BASELINE_ITEM_TYPES = new Set(["grass", "stone", "bed", "trace", "splat", "ant_corpse"]); + const V11_HAZARD_ITEM_TYPES = new Set(["firecracker"]); + const V11_TIMER_TARINAI_KEYS = ["loveMochiTimer", "fightMochiTimer", "explosionDiseaseTimer", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer", "birthRitualTimer", "intimidateTimer", "intimidatedTimer"]; + const V11_COMPLEX_STATES = new Set(["fight", "panic", "birth_ritual", "intimidate", "hurt", "defeated", "stuck"]); + const V11_MAX_SEMANTIC_OBJECTS = 16; + const V11_MAX_SIGN_TEXT = 40; + + function v11ExpectedPopulation(fieldType) { + const field = (typeof FIELD_TYPES !== "undefined" ? FIELD_TYPES?.[fieldType || "garden"] : null) || null; + return field?.population ?? (typeof CONFIG !== "undefined" ? CONFIG.initialPopulation : 18); + } + + function v11SemanticTarinaiBlocked(t) { + if (!t || t.dead) return false; + if (V11_COMPLEX_STATES.has(t.state)) return true; + if (t.isZunchiSlave || t.zunchiSlaveLocked || t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) return true; + if (t.stuckPushpinId || t.insideNestBoxId || (t.oshiriByoZunchiStock || 0) > 0) return true; + if (Object.keys(t.relationships || {}).length) return true; + if (t.itemEffectTimers && Object.values(t.itemEffectTimers).some(v => (Number(v) || 0) > 0.2)) return true; + return V11_TIMER_TARINAI_KEYS.some(key => (Number(t[key]) || 0) > 0.2); + } + + function v11SemanticItemData(worldRef) { + const out = []; + for (const it of worldRef?.items || []) { + if (!it || it.dead || V11_BASELINE_ITEM_TYPES.has(it.type)) continue; + if (V11_HAZARD_ITEM_TYPES.has(it.type)) return null; + if (!V11_SEMANTIC_ITEM_TYPES.includes(it.type)) return null; + if ((it.fuseTimer || 0) > 0 || (it.dropTimer || 0) > 0 || it.cannonProjectile || it.projectileSourceId) return null; + const text = it.type === "signboard" ? String(it.text || "") : ""; + if (text.length > V11_MAX_SIGN_TEXT) return null; + out.push({ type: it.type, x: Number(it.x) || 0, y: Number(it.y) || 0, text }); + if (out.length > V11_MAX_SEMANTIC_OBJECTS) return null; + } + return out; + } + + function v11SemanticData(worldRef) { + if (!worldRef) return null; + const alive = (worldRef.tarinai || []).filter(t => t && !t.dead); + if ((worldRef.effects || []).some(ef => ef && !ef.dead)) return null; + if ((worldRef.ants || []).some(a => a && !a.dead)) return null; + if (alive.some(v11SemanticTarinaiBlocked)) return null; + const objects = v11SemanticItemData(worldRef); + if (!objects) return null; + const indexByFamily = new Map(); + alive.forEach((t, index) => indexByFamily.set(t.familyKey || t.id || String(index), index)); + const people = []; + for (const t of alive) { + const parents = []; + for (const ref of t.parents || []) { + if (!indexByFamily.has(ref)) return null; + parents.push(indexByFamily.get(ref)); + } + const name = t.name || "tarinai"; + people.push({ name, type: t.type || "smile", generation: Math.max(1, Math.round(t.generation || 1)), parents }); + } + const frozen = []; + for (const entry of worldRef.frozenTarinai || []) { + const data = entry?.data || entry || {}; + if (data.isZunchiSlave || data.zunchiSlaveLocked || data.zunchiDisease || data.sleepDisease || data.explosionDisease || data.fightDisease) return null; + const name = data.name || entry?.name || "tarinai"; + frozen.push({ name, type: data.type || entry?.type || "smile", generation: Math.max(1, Math.round(data.generation || 1)) }); + } + return { + fieldType: worldRef.fieldType || "garden", + deadCount: Math.max(0, Math.round(worldRef.deadCount || 0)), + people, + objects, + frozen, + }; + } + + function v11WriteSemantic(w, data) { + w.u((codeFromList(data.fieldType || "garden", V6_FIELD_TYPES) + 1) || 0); + w.u(data.deadCount || 0); + w.u(data.people.length); + for (const p of data.people) { + v10WriteName(w, p.name); + w.u((codeFromList(p.type || "smile", V4_SPRITE_TYPES) + 1) || 0); + w.u(Math.max(1, p.generation || 1)); + w.u(p.parents.length); + for (const parent of p.parents) w.u(parent + 1); + } + w.u(data.objects.length); + for (const obj of data.objects) { + w.u((codeFromList(obj.type, V4_ITEM_TYPES) + 1) || 0); + w.u(v9Coord(obj.x)); + w.u(v9Coord(obj.y)); + if (obj.type === "signboard") w.str(obj.text || ""); + } + w.u(data.frozen.length); + for (const f of data.frozen) { + v10WriteName(w, f.name); + w.u((codeFromList(f.type || "smile", V4_SPRITE_TYPES) + 1) || 0); + w.u(Math.max(1, f.generation || 1)); + } + } + + function v11ReadSemantic(r) { + const fieldType = valueFromList(r.u() - 1, V6_FIELD_TYPES) || "garden"; + const deadCount = r.u(); + const people = []; + const peopleCount = r.u(); + for (let i = 0; i < peopleCount; i++) { + const name = v10ReadName(r); + const type = valueFromList(r.u() - 1, V4_SPRITE_TYPES) || "smile"; + const generation = Math.max(1, r.u() || 1); + const parents = []; + const parentCount = r.u(); + for (let p = 0; p < parentCount; p++) parents.push(r.u() - 1); + people.push({ name, type, generation, parents }); + } + const objects = []; + const objectCount = r.u(); + for (let i = 0; i < objectCount; i++) { + const type = valueFromList(r.u() - 1, V4_ITEM_TYPES) || ""; + const x = v9Uncoord(r.u()); + const y = v9Uncoord(r.u()); + const text = type === "signboard" ? r.str() : ""; + if (type) objects.push({ type, x, y, text }); + } + const frozen = []; + const frozenCount = r.u(); + for (let i = 0; i < frozenCount; i++) { + frozen.push({ + name: v10ReadName(r), + type: valueFromList(r.u() - 1, V4_SPRITE_TYPES) || "smile", + generation: Math.max(1, r.u() || 1), + }); + } + return { __tarinaiV11Semantic: true, version: V11_SNAPSHOT_VERSION, fieldType, deadCount, people, objects, frozen }; + } + + function createV11SnapshotBytes(worldRef = global.world) { + const full = createV10SnapshotBytes(worldRef); + const semantic = v11SemanticData(worldRef); + if (semantic) { + const w = new V10Writer(); + w.byte(0x54); + w.byte(0x0b); + w.u(0); + v11WriteSemantic(w, semantic); + const semanticBytes = w.finish(); + if (semanticBytes.length < full.length) return semanticBytes; + } + const w = new V10Writer(); + w.byte(0x54); + w.byte(0x0b); + w.u(1); + w.u(full.length); + w.raw(full); + return w.finish(); + } + + function decodeV11SnapshotBytes(bytes) { + const r = new V10Reader(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || [])); + if (r.byte() !== 0x54 || r.byte() !== 0x0b) throw new Error("invalid V11 save packet"); + const mode = r.u(); + if (mode === 0) return v11ReadSemantic(r); + if (mode === 1) { + const len = r.u(); + return decodeV10SnapshotBytes(r.bytes.slice(r.i, r.i + len)); + } + throw new Error("unsupported V11 save mode"); + } + + function restoreV11SemanticSnapshot(snapshot, worldRef = global.world) { + if (!snapshot?.__tarinaiV11Semantic) return false; + const fieldType = snapshot.fieldType || "garden"; + if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); + if (typeof worldRef.reset === "function") worldRef.reset(null, fieldType); + const people = Array.isArray(snapshot.people) ? snapshot.people : []; + const baseline = (worldRef.tarinai || []).filter(t => t && !t.dead); + while (baseline.length > people.length) { + const t = baseline.pop(); + if (typeof worldRef.releaseTarinaiLiveId === "function") worldRef.releaseTarinaiLiveId(t); + const index = worldRef.tarinai.indexOf(t); + if (index >= 0) worldRef.tarinai.splice(index, 1); + } + while (baseline.length < people.length) { + const p = people[baseline.length] || {}; + baseline.push(worldRef.addTarinai({ + name: p.name || "tarinai", + type: p.type || "smile", + generation: Math.max(1, p.generation || 1), + x: rand(80, worldRef.w - 80), + y: rand(80, worldRef.h - 80), + })); + } + for (let i = 0; i < Math.min(people.length, baseline.length); i++) { + const p = people[i] || {}; + const t = baseline[i]; + t.name = p.name || t.name; + t.type = p.type || t.type || "smile"; + t.generation = Math.max(1, p.generation || 1); + t.parents = []; + t.children = []; + t.relationships = {}; + t.hasPaired = false; + t.dead = false; + } + for (let i = 0; i < Math.min(people.length, baseline.length); i++) { + const t = baseline[i]; + const refs = []; + for (const parentIndex of people[i]?.parents || []) { + const parent = baseline[parentIndex]; + if (!parent) continue; + refs.push(parent.familyKey); + parent.children = Array.from(new Set([...(parent.children || []), t.familyKey])); + } + t.parents = refs; + if (refs.length) t.hasPaired = true; + } + worldRef.deadCount = Math.max(0, Math.round(snapshot.deadCount || 0)); + worldRef.items = (worldRef.items || []).filter(it => it && !it.dead); + for (const obj of snapshot.objects || []) { + if (!obj?.type) continue; + const item = new Item(obj.type, Number(obj.x) || 0, Number(obj.y) || 0); + if (obj.type === "signboard") item.text = String(obj.text || ""); + worldRef.items.push(item); + } + worldRef.frozenTarinai = (snapshot.frozen || []).map((f, index) => { + const data = { + id: `ft${index + 1}`, + familyKey: `ff${index + 1}`, + liveToken: baseline.length + index + 1, + name: f.name || "tarinai", + type: f.type || "smile", + generation: Math.max(1, f.generation || 1), + parents: [], + children: [], + relationships: {}, + itemEffectTimers: { laxative: 0, ammo: 0 }, + }; + return { freezeId: `fz${index + 1}`, frozenAt: 0, worldTime: 0, name: data.name, familyKey: data.familyKey, liveId: data.id, liveToken: data.liveToken, type: data.type, data, pendingLosses: [] }; + }); + worldRef.logs = []; + worldRef.selected = null; + worldRef.family = {}; + for (const t of baseline) if ((t.parents || []).length || (t.children || []).length) worldRef.recordFamily?.(t); + worldRef.familyTreeDirty = true; + worldRef.drawListDirty = true; + worldRef.terrainDirty = true; + if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts(); + if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts(); + if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); + if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load"); + if (typeof renderLog === "function") renderLog([]); + if (typeof resetArchiveRenderState === "function") resetArchiveRenderState(); + if (typeof renderArchive === "function") renderArchive(); + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + if (typeof syncTopButtons === "function") syncTopButtons(); + global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef); + return true; + } + + function createV9SnapshotBytes(worldRef = global.world) { + const writer = new V9BinaryWriter(); + writer.byte(0x54); // T + writer.byte(0x09); + writer.value(createV9SnapshotArray(worldRef)); + return writer.finish(); + } + + function decodeV9SnapshotBytes(bytes) { + const reader = new V9BinaryReader(bytes); + if (reader.byte() !== 0x54 || reader.byte() !== 0x09) throw new Error("invalid V9 save packet"); + const snapshot = reader.value(); + if (!Array.isArray(snapshot) || snapshot[0] !== V9_SNAPSHOT_VERSION) throw new Error("invalid V9 save packet"); + return expandV9SnapshotArray(snapshot); + } + + function createCompactSnapshot(worldRef = global.world) { + return { __tarinaiCompactBytes: true, version: V11_SNAPSHOT_VERSION, bytes: createV11SnapshotBytes(worldRef) }; + } + + function expandCompactSnapshot(snapshot) { + if (snapshot && snapshot.__tarinaiCompactBytes && snapshot.bytes) return decodeV11SnapshotBytes(snapshot.bytes); + if (snapshot instanceof Uint8Array) return decodeV11SnapshotBytes(snapshot); + return snapshot; + } + function createSnapshot(worldRef = global.world) { if (!worldRef) throw new Error("world is not ready"); const selectedRef = refFor(worldRef, worldRef.selected); @@ -144,6 +2900,10 @@ function restoreSnapshot(snapshot, worldRef = global.world) { if (!snapshot || typeof snapshot !== "object") throw new Error("invalid save data"); if (!worldRef) throw new Error("world is not ready"); + if (snapshot && snapshot.__tarinaiCompactBytes && snapshot.bytes) return restoreSnapshot(expandCompactSnapshot(snapshot), worldRef); + if (snapshot instanceof Uint8Array) return restoreSnapshot(expandCompactSnapshot(snapshot), worldRef); + if (snapshot.__tarinaiV11Semantic) return restoreV11SemanticSnapshot(snapshot, worldRef); + const data = snapshot.version ? snapshot : JSON.parse(JSON.stringify(snapshot)); const fieldType = data.world?.fieldType || "garden"; if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); @@ -173,6 +2933,7 @@ for (const saved of tarinaiList) { if (!saved) continue; const t = restoreTarinaiData(worldRef, saved, { clearTransient: false }); + if (!t.id && typeof worldRef.assignTarinaiLiveId === "function") worldRef.assignTarinaiLiveId(t); worldRef.tarinai.push(t); if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 0, target: t }); } @@ -221,7 +2982,8 @@ worldRef.relationNotices = clonePlain(data.world?.relationNotices) || {}; worldRef.resolvedFightIds = clonePlain(data.world?.resolvedFightIds) || {}; worldRef.eventCounters = clonePlain(data.world?.eventCounters) || {}; - worldRef.colonyMood = clonePlain(data.world?.colonyMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "のんびり", effects: { personality: {} } }; + const savedMood = data.world?.colonyMood; + worldRef.colonyMood = savedMood?.id && typeof worldRef.colonyMoodDefinition === "function" ? worldRef.colonyMoodDefinition(savedMood.id) : (clonePlain(savedMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "relaxed", effects: { personality: {} } }); worldRef.familyTreeDirty = true; worldRef.drawListDirty = true; worldRef.terrainDirty = true; @@ -251,6 +3013,26 @@ worldData, snapshotEntity, createSnapshot, + createCompactSnapshot, + expandCompactSnapshot, + createV4Snapshot, + expandV4Snapshot, + createV5Snapshot, + expandV5Snapshot, + createV6Snapshot, + expandV6Snapshot, + createV7Snapshot, + expandV7Snapshot, + createV8Snapshot, + expandV8Snapshot, + createV10SnapshotBytes, + decodeV10SnapshotBytes, + createV11SnapshotBytes, + decodeV11SnapshotBytes, + createV9SnapshotBytes, + decodeV9SnapshotBytes, + createV9SnapshotArray, + expandV9SnapshotArray, restoreSnapshot, restoreTarinaiData, }; diff --git a/js/sound_pack.js b/js/sound_pack.js index 40682ea..b4ae83a 100644 --- a/js/sound_pack.js +++ b/js/sound_pack.js @@ -32,12 +32,12 @@ sfx_log_push: 0.10, }, samplePaths: { - voice_hau: "assets/sounds/voice_001_hau.wav", - voice_flee: "assets/sounds/voice_002_flee.wav", - voice_poop: "assets/sounds/voice_003_po.wav", - sfx_eat: "assets/sounds/voice_004_eat.wav", - voice_stress: "assets/sounds/voice_005_stress.wav", - sfx_sleep: "assets/sounds/voice_006_sleep.wav", + voice_hau: "assets/sounds/voice_001_hau.mp3", + voice_flee: "assets/sounds/voice_002_flee.mp3", + voice_poop: "assets/sounds/voice_003_po.mp3", + sfx_eat: "assets/sounds/voice_004_eat.mp3", + voice_stress: "assets/sounds/voice_005_stress.mp3", + sfx_sleep: "assets/sounds/voice_006_sleep.mp3", sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3" }, categories: { diff --git a/js/tarinai.js b/js/tarinai.js index d3cd670..c2241ab 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -57,7 +57,23 @@ class Tarinai { constructor(world, opts = {}) { this.hpBarTimer = opts.hpBarTimer ?? 0; this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); - this.need = opts.need || pick(NEEDS); + const defaultNeeds = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + this.needs = opts.needs && typeof opts.needs === "object" ? { ...defaultNeeds, ...opts.needs } : { ...defaultNeeds }; + this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...this.needs, ...opts.previousNeeds } : { ...this.needs }; + this.needsInitialized = Boolean(opts.needsInitialized); + this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...defaultNeeds, ...opts.needShock } : { ...defaultNeeds }; + this.need = opts.need || opts.intent?.need || "fulfill"; + this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : { + need: this.need, + tiedNeeds: [this.need], + actionId: "idle", + actionLabel: "\u5f85\u3063\u3066\u3044\u308b", + reasonText: "\u5f85\u3063\u3066\u3044\u308b", + }; + this.panicTimer = opts.panicTimer ?? 0; + this.buildTimer = opts.buildTimer ?? 0; + this.buildType = opts.buildType || ""; + this.buildMaterialId = opts.buildMaterialId || ""; this.state = opts.state || "idle"; this.dead = !!opts.dead; this.deathReason = opts.deathReason || ""; diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index c5665cb..f432e9d 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -73,6 +73,7 @@ if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer); if (Number.isFinite(opts.hurtTimer)) this.hurtTimer = Math.max(this.hurtTimer || 0, opts.hurtTimer); if (Number.isFinite(opts.awakeLockTimer)) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, opts.awakeLockTimer); + this.panicTimer = Math.max(this.panicTimer || 0, Number.isFinite(opts.duration) ? opts.duration : 3.0); if (Number.isFinite(opts.fallTimer)) { this.fallTimer = Math.max(this.fallTimer || 0, opts.fallTimer); this.fallMax = Math.max(this.fallMax || 0, this.fallTimer); diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index fc9170d..ddd1dd7 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -5,7 +5,19 @@ if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js"); Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ canEatItemType(type) { - return this.isZunchiSlave ? type === "zunchi" : true; + const t = String(type || ""); + if (!this.isZunchiSlave) return true; + if (!t || t === "sweet" || t === "grass" || t === "water" || t === "water_bowl") return false; + if (t === "zunchi" || t === "food" || t === "ant_corpse") return true; + const foodDef = global.TarinaiFoodRegistry?.get?.(t); + return Boolean(foodDef && foodDef.interest !== "water"); + }, + + zunchiSlaveFoodTypes() { + const defs = global.TARINAI_FOOD_DEFINITIONS || {}; + const types = Object.keys(defs).filter(type => this.canEatItemType(type)); + if (!types.includes("zunchi")) types.push("zunchi"); + return types; }, hasLoveMochiEffect() { @@ -146,8 +158,8 @@ startSunbath() { if (!this.canStartSunbath()) return false; this.setActionState?.("sunbath", { target: null, reason: "日なたぼっこ", sleeping: false }); - this.sunbathTimer = rand(6, 14); - this.sunbathCooldown = rand(16, 34); + this.sunbathTimer = rand(12, 24); + this.sunbathCooldown = rand(8, 18); this.sunbathFrameTimer = 0; this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); this.sunbathRecoveryChecked = false; @@ -166,7 +178,7 @@ } this.stress = clamp((this.stress || 0) - dt * 1.15, 0, 130); this.energy = clamp((this.energy || 0) + dt * 0.08, 0, 100); - if (!this.isSunbathTime() || this.hunger > 74 || this.energy < 10 || this.state === "panic") this.sunbathTimer = 0; + if (!this.isSunbathTime() || this.hunger > 86 || this.energy < 8 || this.state === "panic") this.sunbathTimer = 0; if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { this.goIdle(""); } @@ -211,6 +223,7 @@ this.fearTimer = Math.max(0, this.fearTimer - dt); this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); + this.panicTimer = Math.max(0, (this.panicTimer || 0) - dt); this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); @@ -453,8 +466,13 @@ this.ensureFoodServings(it); if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.type, 0) ?? 0); - it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); - it.amount = it.foodServingsRemaining; + if (!it.infiniteServing) { + it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); + it.amount = it.foodServingsRemaining; + } else { + it.foodServingsRemaining = Math.max(it.foodServingsRemaining || 0, it.foodServingsMax || 1); + it.amount = it.foodServingsRemaining; + } this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value }); return value; }, @@ -578,6 +596,20 @@ } this.stuckPushpinId = null; } + + if ((this.panicTimer || 0) > 0 && this.state === "panic") { + this.setActionState?.("panic", { + target: previousTarget || this.panicDestination?.(null, true) || null, + reason: this.intent?.reasonText || "\u304a\u3069\u308d\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", + wake: true, + }); + return; + } + + if (window.TarinaiNeedSystem?.runNeedDecision) { + window.TarinaiNeedSystem.runNeedDecision(this, this.world, dt); + return; + } const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null; const panicSource = (this.world?.nearbyTarinai?.(this.x, this.y, 128, true) || []) @@ -623,6 +655,16 @@ return; } + if (this.canStartSunbath?.()) { + const moodBonus = (this.world?.colonyMood?.id === "sun_party") ? 0.72 : 0; + const sickBonus = (this.zunchiDisease || this.explosionDisease || this.fightDisease) ? 0.48 : 0; + const stressBonus = this.stress > 28 ? 0.22 : 0; + const idleBonus = this.state === "idle" ? 0.18 : 0; + if (Math.random() < dt * (0.34 + moodBonus + sickBonus + stressBonus + idleBonus)) { + if (this.startSunbath()) return; + } + } + const dayP = this.world.dayProgress(); const personalSleep = this.sleepStart < this.sleepEnd ? dayP >= this.sleepStart && dayP < this.sleepEnd @@ -793,14 +835,14 @@ } } - if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "grass", "ant_corpse"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { + if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? this.canEatItemType(friendNearby.target.type) : ["sweet", "grass", "ant_corpse"].includes(friendNearby.target.type)) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { this.setActionState?.("seek_food", { target: friendNearby.target, reason: "友達と一緒に食べに行く" }); this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); return; } if (this.hunger > 62) { - const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])]; + const itemTypes = this.isZunchiSlave ? this.zunchiSlaveFoodTypes() : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])]; const item = this.world.nearest(this, itemTypes, 420); if (item) { this.setActionState?.("seek_food", { target: item, reason: "食べ物を探している" }); @@ -856,12 +898,13 @@ interactWithItems(dt) { const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 18; const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 7; + const slaveCanFoodBite = this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 16; const canZunchiBite = this.eatCooldown <= 0 && this.hunger > (this.isZunchiSlave ? 16 : 104); for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) { const d = dist(this, it); if (d > this.radius + it.r + 12) continue; - if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { + if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && (canBite || (slaveCanFoodBite && this.canEatItemType(it.type))))) && this.foodItemHasServingLeft(it)) { const eating = this.consumeFoodServing(it); if (eating > 0) { this.beginEating(it, `${toolLabel(it.type)}を食べている`); @@ -896,7 +939,7 @@ } } - if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) { + if ((canSweetBite || (slaveCanFoodBite && this.canEatItemType(it.type))) && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) { const eating = this.consumeFoodServing(it); if (eating <= 0) continue; this.beginEating(it, `${toolLabel(it.type)}を食べている`); @@ -946,9 +989,14 @@ this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); this.vx *= Math.pow(0.20, dt * 60); this.vy *= Math.pow(0.20, dt * 60); - it.amount -= bite; - it.eatenAmount = (it.eatenAmount || 0) + bite; - it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); + if (!it.infiniteServing) { + it.amount -= bite; + it.eatenAmount = (it.eatenAmount || 0) + bite; + it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); + } else { + it.amount = Math.max(it.amount || 0, 120); + it.growth = Math.max(it.growth || 0, 0.82); + } this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46); this.recoverHealth(bite * 0.18); this.stress -= dt * 0.5; @@ -965,13 +1013,14 @@ break; } - if (canBite && it.type === "ant_corpse") { + if ((canBite || (slaveCanFoodBite && this.canEatItemType(it.type))) && it.type === "ant_corpse") { const bite = Math.min(it.amount, rand(3.2, 5.4)); if (bite > 0) { this.beginEating(it, "アリの死骸を食べている"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); - it.amount -= bite; + if (!it.infiniteServing) it.amount -= bite; + else it.amount = Math.max(it.amount || 0, 24); this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62); this.recoverHealth(bite * 0.12); this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130); @@ -1000,7 +1049,8 @@ this.beginEating(it, this.isZunchiSlave ? "ずんちどれいなのでずんちを食べている" : "ずんちを食べている"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); - it.amount -= bite; + if (!it.infiniteServing) it.amount -= bite; + else it.amount = Math.max(it.amount || 0, 240); this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * (this.isZunchiSlave ? 0.66 : 0.18)) : (this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18)); this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130); @@ -1025,7 +1075,8 @@ audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); this.nextDrinkSound = (this.world?.time || 0) + 0.72; } - if (it.type === "water") it.amount -= dt * 2.4; + if (it.type === "water" && !it.infiniteServing) it.amount -= dt * 2.4; + if (it.type === "water" && it.infiniteServing) it.amount = Math.max(it.amount || 0, 120); if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18; } diff --git a/js/ui_selected.js b/js/ui_selected.js index 1faf3f5..a245833 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -166,6 +166,25 @@ function selectedInfoRowHtml(label, valueHtml, metric = "") { return `
${escapeHtml(label)}${valueHtml}
`; } +function selectedNeedRowsHtml(t) { + const system = window.TarinaiNeedSystem; + const keys = system?.NEED_KEYS || ["food", "sleep", "health", "safety", "social", "fulfill"]; + const labels = system?.NEED_LABELS || { + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", + }; + const active = t?.intent?.need || t?.need || ""; + return keys.map(key => { + const value = system?.getNeedDisplayValue ? system.getNeedDisplayValue(t?.needs?.[key] || 0) : Math.round((t?.needs?.[key] || 0) / 10); + const mark = key === active ? ` \u25b2` : ""; + return `
${escapeHtml(labels[key] || key)}${escapeHtml(value)}${mark}
`; + }).join(""); +} + function renderSelected() { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) { @@ -201,6 +220,7 @@ function renderSelected() { t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0), t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0), t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0, + JSON.stringify(t.needs || {}), t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", personalitySnapshot, personalityDailySnapshot(t), ["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100), itemEffectSummary.map(e => `${e.id}:${e.detail}`).join(","), @@ -239,7 +259,8 @@ function renderSelected() { const basicHtml = `
\u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
\u72b6\u614b${escapeHtml(stateLabel(t.state))}
-
\u7406\u7531${escapeHtml(reasonLabel(t))}
+
\u3044\u307e${escapeHtml(t.intent?.actionLabel || stateLabel(t.state))}
+
\u7406\u7531${escapeHtml(t.intent?.reasonText || reasonLabel(t))}
\u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
\u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
\u6700\u5927\u5bff\u547d${geneticPercentText(t, "life")}
@@ -247,14 +268,7 @@ function renderSelected() {
\u79fb\u52d5\u901f\u5ea6${geneticPercentText(t, "speed")}
\u30b5\u30a4\u30ba${geneticSizeText(t)}
`; - const healthHtml = [ - selectedInfoRowHtml("\u7dcf\u5408\u7684\u6c17\u5206", fmt(t.mood), "mood"), - selectedInfoRowHtml("\u7a7a\u8179", fmt(t.hunger), "hunger"), - selectedInfoRowHtml("\u5bc2\u3057\u3055", fmt(t.loneliness), "loneliness"), - selectedInfoRowHtml("\u4f53\u529b", fmt(t.energy), "energy"), - selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"), - selectedInfoRowHtml("\u305a\u3093\u3061\u6c5a\u308c", fmt(t.zunchiStain || 0), "zunchi"), - ].join(""); + const healthHtml = selectedNeedRowsHtml(t) + selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"); const itemEffectHtml = itemEffectSummary.length ? itemEffectSummary.map(e => `
${escapeHtml(e.label)}${escapeHtml(e.detail)}
`).join("") : `
\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u306a\u3057
`; diff --git a/js/version.js b/js/version.js index ec564a1..73fbb90 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "15.24.0"; - const APP_BUILD = "safe-refactor-ui-fix"; + const APP_VERSION = "15.35.0"; + const APP_BUILD = "hash-v10-shorter-current-only"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 843e9b0..3fd8e60 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -38,6 +38,31 @@ return hit; }, + damageStructuresInRadius(x, y, radius, damageFn, breaker = null) { + let hit = 0; + for (const structure of this.items || []) { + if (!structure || structure.dead || !structure.isStructure) continue; + const d = Math.max(1, distXY(structure.x, structure.y, x, y)); + if (d > radius + (structure.r || 18)) continue; + const p = clamp(1 - d / Math.max(1, radius), 0, 1); + if (p <= 0 && d > radius) continue; + const damage = typeof damageFn === "function" ? damageFn(p, structure, d) : Number(damageFn || 0); + if (damage <= 0) continue; + if (typeof structure.damage === "function") structure.damage(damage, breaker, this); + else if (window.TarinaiNeedSystem?.damageStructure) window.TarinaiNeedSystem.damageStructure.call(structure, damage, breaker, this); + else { + structure.hp = Math.max(0, Number(structure.hp || 0) - damage); + if (structure.hp <= 0) structure.dead = true; + } + hit += 1; + } + if (hit > 0) { + this.drawListDirty = true; + this.markTerrainDirty?.("structure-damage"); + } + return hit; + }, + explodeFirecracker(it) { if (!it || it.dead || it.type !== "firecracker") return; it.amount = 0; @@ -109,6 +134,7 @@ this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), }); + this.damageStructuresInRadius(x, y, blastRadius, p => 35 + p * 90, it); let blastedBalls = 0; for (const ball of this.items) { if (!ball || ball.dead || ball.type !== "ball") continue; @@ -199,6 +225,7 @@ this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale, }); + this.damageStructuresInRadius(x, y, blastRadius, q => 28 + q * 76, t); for (const ball of this.items) { if (!ball || ball.dead || ball.type !== "ball") continue; let dx = ball.x - x; @@ -589,6 +616,7 @@ const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { push: p => push * (0.62 + p * 1.25), }); + const structureHit = this.damageStructuresInRadius(it.x, it.y, radius + 54, p => damage * (0.45 + p * 0.85), it); if (isGenkotsu) { this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75); for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); @@ -596,7 +624,7 @@ } else { this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); } - if (hit > 0 || antHit > 0 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45)); + if (hit > 0 || antHit > 0 || structureHit > 0 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45)); if (isGenkotsu) audio.genkotsuImpact?.(); else if (isStone) audio.stoneImpact?.(); else if (hit > 0 || antHit > 0) audio.poke?.(); diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 45aaf38..301ddf3 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -211,6 +211,73 @@ return item; }, + signboardAt(x, y) { + let sign = null; + let bestD = Infinity; + for (let i = (this.items || []).length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || it.type !== "signboard") continue; + const d = distXY(x, y, it.x, it.y); + if (d <= Math.max(72, (it.r || 42) * 2.2) && d < bestD) { sign = it; bestD = d; } + } + return sign; + }, + + machineAt(x, y) { + let best = null; + let bestD = Infinity; + for (let i = (this.items || []).length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || !(typeof isMachineType === "function" ? isMachineType(it.type) : (it.type === "duplicator" || it.type === "cannon"))) continue; + const hit = Math.max(46, (it.r || 42) * (it.type === "cannon" ? 1.35 : 1.55)); + const d = distXY(x, y, it.x, it.y); + if (d <= hit && d < bestD) { best = it; bestD = d; } + } + return best; + }, + + setMachineSlot(machine, itemType) { + if (!machine || machine.dead || !itemType || !(typeof isMachineConsumableType === "function" ? isMachineConsumableType(itemType) : false)) return false; + if (machine.machineSlotType && machine.machineSlotType !== itemType) { + for (const it of this.items || []) { + if (it && !it.dead && it.machineProducerId === machine.id) it.amount = 0; + } + } + machine.machineSlotType = itemType; + machine.machineSlotSize = this.toolSizeFor ? this.toolSizeFor(itemType) : "medium"; + machine.machineSpawnTimer = 0; + machine.cannonFireTimer = machine.type === "cannon" ? 0.35 : machine.cannonFireTimer; + const label = toolLabel(itemType); + this.log?.(`${toolLabel(machine.type)}に${label}をセットした。`, "observe"); + showToast(`${toolLabel(machine.type)}: ${label}をセット`); + this.rebuildSpatial?.(true); + return true; + }, + + rotateCannon(cannon, x, y) { + if (!cannon || cannon.dead || cannon.type !== "cannon") return false; + const dx = x - cannon.x; + const dy = y - cannon.y; + if (Math.hypot(dx, dy) > 3) cannon.cannonAngle = Math.atan2(dy, dx); + else cannon.cannonAngle = ((Number.isFinite(cannon.cannonAngle) ? cannon.cannonAngle : 0) + Math.PI / 4) % (Math.PI * 2); + const deg = Math.round((((cannon.cannonAngle * 180 / Math.PI) % 360) + 360) % 360); + showToast(`大砲の向き: ${deg}°`); + this.log?.(`大砲の向きを${deg}度に変えた。`, "observe"); + return true; + }, + + editSignboard(sign) { + if (!sign || sign.dead || sign.type !== "signboard") return false; + const current = String(sign.text || ""); + const next = typeof prompt === "function" ? prompt("看板のテキスト", current) : current; + if (next === null) return true; + sign.text = String(next || "").replace(/[\r\n]+/g, " ").slice(0, 36).trim(); + sign.textEditedAt = this.time || 0; + this.markTerrainDirty?.("signboard:text"); + this.log(sign.text ? `看板を書き換えた。` : `看板の文字を消した。`, "observe"); + return true; + }, + handleClick(x, y) { const tool = this.tool; if (tool === "observe") { @@ -219,22 +286,16 @@ if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } } if (!found) { - let sign = null; - let bestD = Infinity; - for (let i = this.items.length - 1; i >= 0; i--) { - const it = this.items[i]; - if (!it || it.dead || it.type !== "signboard") continue; - const d = distXY(x, y, it.x, it.y); - if (d <= Math.max(54, (it.r || 24) * 2.4) && d < bestD) { sign = it; bestD = d; } + const machine = this.machineAt?.(x, y) || null; + if (machine) { + if (machine.type === "cannon") this.rotateCannon?.(machine, x, y); + else showToast(machine.machineSlotType ? `複製機: ${toolLabel(machine.machineSlotType)}を複製中` : "複製機: 消費アイテムを選んでクリックするとセット"); + this.selected = null; + return; } + const sign = this.signboardAt?.(x, y) || null; if (sign) { - const current = String(sign.text || ""); - const next = typeof prompt === "function" ? prompt("看板のテキスト", current) : current; - if (next !== null) { - sign.text = String(next || "").replace(/[\r\n]+/g, " ").slice(0, 24).trim(); - sign.textEditedAt = this.time || 0; - this.log(sign.text ? `看板を書き換えた。` : `看板の文字を消した。`, "observe"); - } + this.editSignboard?.(sign); this.selected = null; return; } @@ -284,7 +345,8 @@ } if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } if (isPinType(best.type) && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; } - best.amount = 0; + if (best.isStructure && typeof best.damage === "function") best.damage(9999, null, this); + else best.amount = 0; audio.delete?.(); this.compactItems(); this.updateItemCounts(); @@ -295,7 +357,20 @@ } const itemType = toolItemType(tool); + if (itemType === "signboard") { + const sign = this.signboardAt?.(x, y) || null; + if (sign) { this.editSignboard?.(sign); return; } + } if (itemType) { + const machine = this.machineAt?.(x, y) || null; + if (machine && (typeof isMachineConsumableType === "function" ? isMachineConsumableType(itemType) : false)) { + this.setMachineSlot?.(machine, itemType); + return; + } + if (machine && machine.type === "cannon" && itemType !== "cannon" && itemType !== "duplicator") { + this.rotateCannon?.(machine, x, y); + return; + } let px = x, py = y; const rawItem = this.applyToolSize(new Item(itemType, px, py)); // Placement uses the same footprint that the preview validates. @@ -303,6 +378,7 @@ if (!placed) return; const label = toolLabel(itemType); this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); + if (itemType === "signboard") this.editSignboard?.(placed); } }, diff --git a/service-worker.js b/service-worker.js index a062aa1..e095abe 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,11 +1,11 @@ "use strict"; -const APP_VERSION = "15.24.0"; +const APP_VERSION = "15.35.0"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. const coreScriptNames = [ - "version", "event_bus", "performance_manager", "registry_base", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "render", "sim_core", "items", "ants", "health", "tarinai", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "save_system", "patch_helpers", "freeze_system", "relation_event_system", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" + "version", "event_bus", "performance_manager", "registry_base", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "need_behavior_system", "physics_helpers", "assets", "audio", "render", "sim_core", "items", "ants", "health", "tarinai", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "save_system", "patch_helpers", "freeze_system", "relation_event_system", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" ]; const CORE_ASSETS = [ "./",