diff --git a/README.md b/README.md
index ed43de7..a874c63 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ Chrome / Edge / Firefox / Safari の近年版を想定しています。
## ゲーム内容
「たりない」たちが観察皿の中で勝手に暮らします。
-基本は眺めるだけですが、餌・水・草・石・寝床・鏡などを配置して軽く介入できます。
+基本は眺めるだけですが、餌・水・草・石・寝床・などを配置して軽く介入できます。
## 主な要素
@@ -29,7 +29,7 @@ Chrome / Edge / Firefox / Safari の近年版を想定しています。
## 操作
- 観察:個体クリックで詳細表示
-- ふつうの餌 / あまい餌 / 水滴 / 草 / ぬくい石 / ふわふわ / 鏡:キャンバスクリックで配置
+- ふつうの餌 / あまい餌 / 水滴 / 草 / ぬくい石 / ふわふわ:キャンバスクリックで配置
- つつく:個体をクリックして一時的に不安を上げる
- 迷い込み:指定位置に新しいたりないを追加
@@ -165,3 +165,46 @@ tarinai_colony_game/
- 食事パーティクル頻度を抑制
- 死亡後の緑のしぶきが時間経過でゆっくり薄く消えるように変更
- 保存キーは `tarinai-colony-save-v9`
+
+## 更新内容(v10)
+
+- 新規スプライト5枚を追加
+ - `10_hurt`: 痛い時・喧嘩中に打たれた時
+ - `11_weak`: 低体力時
+ - `12_back`: 奥方向へ進む時
+ - `13_flee`: 喧嘩に負けて敗走する時
+ - `14_zunda_eat`: ずんだ餅を食べる時
+- 追加画像はすべて透明PNG化し、`512x468` のキャンバスへ正規化
+- 追加スプライトは行動専用扱いにし、通常個体の基本タイプとしてはランダム出現しないように調整
+
+
+## 更新内容(v11)
+
+- 個体ごとの性格を追加
+ - 臆病、食いしん坊、眠たがり、寂しがり、怒りっぽい、マイペース
+ - 性格は睡眠、喧嘩、ずんだ餅反応、恐怖、対個体行動へ影響
+- 選択パネルに観察理由を追加
+ - 現在の行動理由
+ - 対象
+ - 仲の良い個体
+ - 避けている個体
+ - 喧嘩記憶
+- 関係性と喧嘩記憶を追加
+ - 近くで過ごす個体同士は親和度が上がる
+ - 喧嘩に負けた相手を記憶し、しばらく避けやすくなる
+ - 勝敗はログと選択パネルに反映
+- hay bed に快適度・混雑ペナルティを追加
+ - 空いているベッドを選びやすくなる
+ - 混雑時は回復効率が下がり、ストレスが上がる
+- 草の生態系を強化
+ - 雨、水、ずんち由来の肥沃地で育ちやすくなる
+ - 強い晴天で水分がないと弱りやすくなる
+ - 草の周辺ではストレス・恐怖が少し下がる
+- 表情スプライトの優先順位を整理
+ - ずんだ餅、被ダメージ、敗走、恐怖、低体力、睡眠、喧嘩、奥移動、通常の順に競合解決
+- ログを種類別に色分け
+ - 誕生、死亡、喧嘩、食事、草、環境、関係、観察
+- 喧嘩で転ぶ演出を追加
+ - 打撃時に低確率で転ぶ
+ - 敗北時に転倒し、土煙が出る
+- 保存キーは `tarinai-colony-save-v11`
diff --git a/assets/objects/zunchi.png b/assets/objects/zunchi.png
new file mode 100644
index 0000000..70a90d7
Binary files /dev/null and b/assets/objects/zunchi.png differ
diff --git a/assets/objects/zunchi_02.png b/assets/objects/zunchi_02.png
new file mode 100644
index 0000000..a2c270d
Binary files /dev/null and b/assets/objects/zunchi_02.png differ
diff --git a/assets/sounds/firecracker_explosion.mp3 b/assets/sounds/firecracker_explosion.mp3
new file mode 100644
index 0000000..9e23327
Binary files /dev/null and b/assets/sounds/firecracker_explosion.mp3 differ
diff --git a/assets/sounds/voice_001_hau.wav b/assets/sounds/voice_001_hau.wav
new file mode 100644
index 0000000..9ab5281
Binary files /dev/null and b/assets/sounds/voice_001_hau.wav differ
diff --git a/assets/sounds/voice_002_flee.wav b/assets/sounds/voice_002_flee.wav
new file mode 100644
index 0000000..3c86ae7
Binary files /dev/null and b/assets/sounds/voice_002_flee.wav differ
diff --git a/assets/sounds/voice_003_po.wav b/assets/sounds/voice_003_po.wav
new file mode 100644
index 0000000..d06aa0c
Binary files /dev/null and b/assets/sounds/voice_003_po.wav differ
diff --git a/assets/sounds/voice_004_eat.wav b/assets/sounds/voice_004_eat.wav
new file mode 100644
index 0000000..36d0d25
Binary files /dev/null and b/assets/sounds/voice_004_eat.wav differ
diff --git a/assets/sounds/voice_005_stress.wav b/assets/sounds/voice_005_stress.wav
new file mode 100644
index 0000000..cb969df
Binary files /dev/null and b/assets/sounds/voice_005_stress.wav differ
diff --git a/assets/sounds/voice_006_sleep.wav b/assets/sounds/voice_006_sleep.wav
new file mode 100644
index 0000000..a1f6e81
Binary files /dev/null and b/assets/sounds/voice_006_sleep.wav differ
diff --git a/assets/sprites/_contact_sheet.png b/assets/sprites/_contact_sheet.png
new file mode 100644
index 0000000..8d2ed9e
Binary files /dev/null and b/assets/sprites/_contact_sheet.png differ
diff --git a/assets/sprites/manifest.json b/assets/sprites/manifest.json
new file mode 100644
index 0000000..a136922
--- /dev/null
+++ b/assets/sprites/manifest.json
@@ -0,0 +1,175 @@
+[
+ {
+ "id": "smile",
+ "file": "assets/sprites/tarinai_01_smile.png",
+ "width": 512,
+ "height": 468
+ },
+ {
+ "id": "angry",
+ "file": "assets/sprites/tarinai_02_angry.png",
+ "width": 512,
+ "height": 464
+ },
+ {
+ "id": "teary",
+ "file": "assets/sprites/tarinai_03_teary.png",
+ "width": 512,
+ "height": 472
+ },
+ {
+ "id": "jito",
+ "file": "assets/sprites/tarinai_04_jito.png",
+ "width": 512,
+ "height": 487
+ },
+ {
+ "id": "drool",
+ "file": "assets/sprites/tarinai_05_drool.png",
+ "width": 512,
+ "height": 461
+ },
+ {
+ "id": "cry",
+ "file": "assets/sprites/tarinai_06_cry.png",
+ "width": 512,
+ "height": 478
+ },
+ {
+ "id": "sleep",
+ "file": "assets/sprites/tarinai_07_sleep.png",
+ "width": 511,
+ "height": 475
+ },
+ {
+ "id": "pokan",
+ "file": "assets/sprites/tarinai_08_pokan.png",
+ "width": 512,
+ "height": 469
+ },
+ {
+ "id": "stretch",
+ "file": "assets/sprites/tarinai_09_stretch.png",
+ "width": 512,
+ "height": 384
+ },
+ {
+ "id": "hurt",
+ "file": "assets/sprites/tarinai_10_hurt.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "weak",
+ "file": "assets/sprites/tarinai_11_weak.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "back",
+ "file": "assets/sprites/tarinai_12_back.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "flee",
+ "file": "assets/sprites/tarinai_13_flee.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "zunda_eat",
+ "file": "assets/sprites/tarinai_14_zunda_eat.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "hurt2",
+ "file": "assets/sprites/tarinai_15_hurt2.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "normal_smirk",
+ "file": "assets/sprites/tarinai_16_normal_smirk.png",
+ "width": 512,
+ "height": 468,
+ "displayOnly": true
+ },
+ {
+ "id": "normal_tongue",
+ "file": "assets/sprites/tarinai_17_normal_tongue.png",
+ "width": 512,
+ "height": 468,
+ "displayOnly": true
+ },
+ {
+ "id": "fear",
+ "file": "assets/sprites/tarinai_18_fear.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "flee_fear2",
+ "file": "assets/sprites/tarinai_19_flee_fear2.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "sleep2",
+ "file": "assets/sprites/tarinai_20_sleep2.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "normal_happy",
+ "file": "assets/sprites/tarinai_21_normal_happy.png",
+ "width": 512,
+ "height": 468,
+ "displayOnly": true
+ },
+ {
+ "id": "fear_blue",
+ "file": "assets/sprites/tarinai_22_fear_blue.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "fear_cry",
+ "file": "assets/sprites/tarinai_23_fear_cry.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "stress_dizzy",
+ "file": "assets/sprites/tarinai_24_stress_dizzy.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "stress_sweat",
+ "file": "assets/sprites/tarinai_25_stress_sweat.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ },
+ {
+ "id": "intimidate",
+ "file": "assets/sprites/tarinai_26_intimidate.png",
+ "width": 512,
+ "height": 468,
+ "actionOnly": true
+ }
+]
\ No newline at end of file
diff --git a/assets/sprites/tarinai_01_smile.png b/assets/sprites/tarinai_01_smile.png
new file mode 100644
index 0000000..2c64d7c
Binary files /dev/null and b/assets/sprites/tarinai_01_smile.png differ
diff --git a/assets/sprites/tarinai_02_angry.png b/assets/sprites/tarinai_02_angry.png
new file mode 100644
index 0000000..557b003
Binary files /dev/null and b/assets/sprites/tarinai_02_angry.png differ
diff --git a/assets/sprites/tarinai_03_teary.png b/assets/sprites/tarinai_03_teary.png
new file mode 100644
index 0000000..bdd7bc5
Binary files /dev/null and b/assets/sprites/tarinai_03_teary.png differ
diff --git a/assets/sprites/tarinai_04_jito.png b/assets/sprites/tarinai_04_jito.png
new file mode 100644
index 0000000..3cd4c57
Binary files /dev/null and b/assets/sprites/tarinai_04_jito.png differ
diff --git a/assets/sprites/tarinai_05_drool.png b/assets/sprites/tarinai_05_drool.png
new file mode 100644
index 0000000..bbd9f12
Binary files /dev/null and b/assets/sprites/tarinai_05_drool.png differ
diff --git a/assets/sprites/tarinai_06_cry.png b/assets/sprites/tarinai_06_cry.png
new file mode 100644
index 0000000..3f25d00
Binary files /dev/null and b/assets/sprites/tarinai_06_cry.png differ
diff --git a/assets/sprites/tarinai_07_sleep.png b/assets/sprites/tarinai_07_sleep.png
new file mode 100644
index 0000000..48c44b2
Binary files /dev/null and b/assets/sprites/tarinai_07_sleep.png differ
diff --git a/assets/sprites/tarinai_08_pokan.png b/assets/sprites/tarinai_08_pokan.png
new file mode 100644
index 0000000..8c8a24b
Binary files /dev/null and b/assets/sprites/tarinai_08_pokan.png differ
diff --git a/assets/sprites/tarinai_09_stretch.png b/assets/sprites/tarinai_09_stretch.png
new file mode 100644
index 0000000..f6fe904
Binary files /dev/null and b/assets/sprites/tarinai_09_stretch.png differ
diff --git a/assets/sprites/tarinai_10_hurt.png b/assets/sprites/tarinai_10_hurt.png
new file mode 100644
index 0000000..0598a61
Binary files /dev/null and b/assets/sprites/tarinai_10_hurt.png differ
diff --git a/assets/sprites/tarinai_11_weak.png b/assets/sprites/tarinai_11_weak.png
new file mode 100644
index 0000000..9b27132
Binary files /dev/null and b/assets/sprites/tarinai_11_weak.png differ
diff --git a/assets/sprites/tarinai_12_back.png b/assets/sprites/tarinai_12_back.png
new file mode 100644
index 0000000..4418fb0
Binary files /dev/null and b/assets/sprites/tarinai_12_back.png differ
diff --git a/assets/sprites/tarinai_13_flee.png b/assets/sprites/tarinai_13_flee.png
new file mode 100644
index 0000000..d05bcb7
Binary files /dev/null and b/assets/sprites/tarinai_13_flee.png differ
diff --git a/assets/sprites/tarinai_14_zunda_eat.png b/assets/sprites/tarinai_14_zunda_eat.png
new file mode 100644
index 0000000..8e71ba0
Binary files /dev/null and b/assets/sprites/tarinai_14_zunda_eat.png differ
diff --git a/assets/sprites/tarinai_15_hurt2.png b/assets/sprites/tarinai_15_hurt2.png
new file mode 100644
index 0000000..b92d10e
Binary files /dev/null and b/assets/sprites/tarinai_15_hurt2.png differ
diff --git a/assets/sprites/tarinai_16_normal_smirk.png b/assets/sprites/tarinai_16_normal_smirk.png
new file mode 100644
index 0000000..73e343a
Binary files /dev/null and b/assets/sprites/tarinai_16_normal_smirk.png differ
diff --git a/assets/sprites/tarinai_17_normal_tongue.png b/assets/sprites/tarinai_17_normal_tongue.png
new file mode 100644
index 0000000..5edf621
Binary files /dev/null and b/assets/sprites/tarinai_17_normal_tongue.png differ
diff --git a/assets/sprites/tarinai_18_fear.png b/assets/sprites/tarinai_18_fear.png
new file mode 100644
index 0000000..aa04096
Binary files /dev/null and b/assets/sprites/tarinai_18_fear.png differ
diff --git a/assets/sprites/tarinai_19_flee_fear2.png b/assets/sprites/tarinai_19_flee_fear2.png
new file mode 100644
index 0000000..dddfb4b
Binary files /dev/null and b/assets/sprites/tarinai_19_flee_fear2.png differ
diff --git a/assets/sprites/tarinai_20_sleep2.png b/assets/sprites/tarinai_20_sleep2.png
new file mode 100644
index 0000000..4d6bee7
Binary files /dev/null and b/assets/sprites/tarinai_20_sleep2.png differ
diff --git a/assets/sprites/tarinai_21_normal_happy.png b/assets/sprites/tarinai_21_normal_happy.png
new file mode 100644
index 0000000..b2730c5
Binary files /dev/null and b/assets/sprites/tarinai_21_normal_happy.png differ
diff --git a/assets/sprites/tarinai_22_fear_blue.png b/assets/sprites/tarinai_22_fear_blue.png
new file mode 100644
index 0000000..0d5d167
Binary files /dev/null and b/assets/sprites/tarinai_22_fear_blue.png differ
diff --git a/assets/sprites/tarinai_22_stress_blue.png b/assets/sprites/tarinai_22_stress_blue.png
new file mode 100644
index 0000000..b1f3b40
Binary files /dev/null and b/assets/sprites/tarinai_22_stress_blue.png differ
diff --git a/assets/sprites/tarinai_23_fear_cry.png b/assets/sprites/tarinai_23_fear_cry.png
new file mode 100644
index 0000000..869217b
Binary files /dev/null and b/assets/sprites/tarinai_23_fear_cry.png differ
diff --git a/assets/sprites/tarinai_23_stress_cry.png b/assets/sprites/tarinai_23_stress_cry.png
new file mode 100644
index 0000000..320a2b1
Binary files /dev/null and b/assets/sprites/tarinai_23_stress_cry.png differ
diff --git a/assets/sprites/tarinai_24_stress_dizzy.png b/assets/sprites/tarinai_24_stress_dizzy.png
new file mode 100644
index 0000000..f9474ca
Binary files /dev/null and b/assets/sprites/tarinai_24_stress_dizzy.png differ
diff --git a/assets/sprites/tarinai_25_stress_sweat.png b/assets/sprites/tarinai_25_stress_sweat.png
new file mode 100644
index 0000000..1385ebf
Binary files /dev/null and b/assets/sprites/tarinai_25_stress_sweat.png differ
diff --git a/assets/sprites/tarinai_26_intimidate.png b/assets/sprites/tarinai_26_intimidate.png
new file mode 100644
index 0000000..1813759
Binary files /dev/null and b/assets/sprites/tarinai_26_intimidate.png differ
diff --git a/assets/sprites/tarinai_27_birth_ritual.png b/assets/sprites/tarinai_27_birth_ritual.png
new file mode 100644
index 0000000..7d13ae8
Binary files /dev/null and b/assets/sprites/tarinai_27_birth_ritual.png differ
diff --git a/assets/ui/tool_bed.png b/assets/ui/tool_bed.png
new file mode 100644
index 0000000..c9243ec
Binary files /dev/null and b/assets/ui/tool_bed.png differ
diff --git a/assets/ui/tool_delete.png b/assets/ui/tool_delete.png
new file mode 100644
index 0000000..e518a51
Binary files /dev/null and b/assets/ui/tool_delete.png differ
diff --git a/assets/ui/tool_fence_h.png b/assets/ui/tool_fence_h.png
new file mode 100644
index 0000000..74872d7
Binary files /dev/null and b/assets/ui/tool_fence_h.png differ
diff --git a/assets/ui/tool_fence_v.png b/assets/ui/tool_fence_v.png
new file mode 100644
index 0000000..d5bcf85
Binary files /dev/null and b/assets/ui/tool_fence_v.png differ
diff --git a/assets/ui/tool_firecracker.png b/assets/ui/tool_firecracker.png
new file mode 100644
index 0000000..c530161
Binary files /dev/null and b/assets/ui/tool_firecracker.png differ
diff --git a/assets/ui/tool_grass.png b/assets/ui/tool_grass.png
new file mode 100644
index 0000000..38020e3
Binary files /dev/null and b/assets/ui/tool_grass.png differ
diff --git a/assets/ui/tool_new.png b/assets/ui/tool_new.png
new file mode 100644
index 0000000..3b1e13f
Binary files /dev/null and b/assets/ui/tool_new.png differ
diff --git a/assets/ui/tool_observe.png b/assets/ui/tool_observe.png
new file mode 100644
index 0000000..64f6964
Binary files /dev/null and b/assets/ui/tool_observe.png differ
diff --git a/assets/ui/tool_poke.png b/assets/ui/tool_poke.png
new file mode 100644
index 0000000..a63c6b0
Binary files /dev/null and b/assets/ui/tool_poke.png differ
diff --git a/assets/ui/tool_stone.png b/assets/ui/tool_stone.png
new file mode 100644
index 0000000..8bf6596
Binary files /dev/null and b/assets/ui/tool_stone.png differ
diff --git a/assets/ui/tool_sweet.png b/assets/ui/tool_sweet.png
new file mode 100644
index 0000000..5560116
Binary files /dev/null and b/assets/ui/tool_sweet.png differ
diff --git a/assets/ui/tool_water.png b/assets/ui/tool_water.png
new file mode 100644
index 0000000..8270a2a
Binary files /dev/null and b/assets/ui/tool_water.png differ
diff --git a/assets/ui/tool_zunchi.png b/assets/ui/tool_zunchi.png
new file mode 100644
index 0000000..d7e5168
Binary files /dev/null and b/assets/ui/tool_zunchi.png differ
diff --git a/css/styles.css b/css/styles.css
new file mode 100644
index 0000000..0b413e8
--- /dev/null
+++ b/css/styles.css
@@ -0,0 +1,870 @@
+@import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=M+PLUS+Rounded+1c:wght@400;500;700&family=Yomogi&display=swap");
+
+:root {
+ --bg: #f7f5ec;
+ --panel: rgba(255,251,245,0.88);
+ --ink: #2a241d;
+ --muted: #756a5e;
+ --line: rgba(84, 68, 48, 0.18);
+ --accent: #9ce84b;
+ --accent-2: #f5a8b7;
+ --danger: #b64545;
+ --shadow: 0 18px 50px rgba(64, 48, 32, 0.14);
+ --radius: 18px;
+ --bubble-font: "Yomogi", "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", system-ui, sans-serif;
+ background:
+ radial-gradient(circle at 12% 0%, rgba(168, 228, 113, 0.28), transparent 33%),
+ radial-gradient(circle at 88% 8%, rgba(255, 222, 147, 0.24), transparent 28%),
+ radial-gradient(circle at 82% 90%, rgba(126, 181, 120, 0.16), transparent 34%),
+ linear-gradient(180deg, #dff0ff 0%, #edf8ff 12%, #f9f6eb 46%, var(--bg) 100%);
+ color: var(--ink);
+ min-height: 100vh;
+ overflow-x: hidden;
+ position: relative;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+}
+
+#app {
+ width: min(1480px, 100vw);
+ margin: 0 auto;
+ padding: 18px;
+ position: relative;
+}
+
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+h1 {
+ margin: 0 0 3px;
+ font-size: clamp(24px, 3.4vw, 42px);
+ letter-spacing: 0.03em;
+}
+
+.subtitle {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.top-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.btn, .tool {
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.76);
+ border-radius: 999px;
+ padding: 9px 13px;
+ cursor: pointer;
+ box-shadow: 0 4px 14px rgba(80,60,40,0.06);
+ transition: transform 130ms ease, background 130ms ease, border-color 130ms ease;
+ user-select: none;
+}
+
+.btn:hover, .tool:hover { transform: translateY(-1px); background: #fff; }
+.btn:active, .tool:active { transform: translateY(1px); }
+
+.btn.primary {
+ background: linear-gradient(180deg, #a9ef5d, #8bdc3f);
+ border-color: rgba(89, 128, 47, 0.28);
+}
+
+.btn.danger {
+ color: #7a2828;
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: minmax(520px, 1fr) 380px;
+ gap: 16px;
+ align-items: stretch;
+}
+
+.stage-wrap {
+ position: relative;
+ background: linear-gradient(180deg, rgba(255,255,255,0.46), rgba(246, 240, 228, 0.56));
+ border: 1px solid rgba(84, 68, 48, 0.12);
+ border-radius: calc(var(--radius) + 8px);
+ overflow: hidden;
+ box-shadow: 0 16px 42px rgba(42, 45, 80, 0.12), inset 0 0 0 1px rgba(255,255,255,0.24);
+ min-height: var(--field-min-height, 620px);
+}
+
+#gameCanvas {
+ width: 100%;
+ height: var(--field-height, calc(100vh - 140px));
+ min-height: var(--field-min-height, 620px);
+ display: block;
+ cursor: crosshair;
+}
+
+.field-dialog {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ place-items: center;
+ padding: 18px;
+ background: rgba(34, 29, 22, 0.38);
+ backdrop-filter: blur(3px);
+}
+.field-dialog.hidden { display: none; }
+.field-dialog-panel {
+ width: min(440px, 100%);
+ border-radius: 16px;
+ padding: 16px;
+ background: rgba(255, 252, 245, 0.96);
+ border: 1px solid rgba(84,68,48,0.16);
+ box-shadow: 0 18px 46px rgba(40, 32, 24, 0.22);
+}
+.field-dialog-panel h2 {
+ margin: 0 0 12px;
+ font-size: 17px;
+}
+.field-choice-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+.field-choice {
+ min-height: 74px;
+ border: 1px solid rgba(84,68,48,0.16);
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(255,255,255,0.86), rgba(241,235,221,0.72));
+ cursor: pointer;
+ color: var(--ink);
+}
+.field-choice:hover { border-color: rgba(112,178,66,0.45); }
+.field-choice strong, .field-choice span { display: block; }
+.field-choice strong { font-size: 15px; }
+.field-choice span { margin-top: 4px; color: var(--muted); font-size: 12px; }
+.field-dialog-actions {
+ margin-top: 12px;
+ display: flex;
+ justify-content: flex-end;
+}
+
+.toast {
+ position: absolute;
+ left: 50%;
+ bottom: 22px;
+ transform: translateX(-50%);
+ background: rgba(42,36,29,0.86);
+ color: white;
+ padding: 10px 16px;
+ border-radius: 999px;
+ font-size: 14px;
+ pointer-events: none;
+ opacity: 1;
+ transition: opacity 200ms ease, transform 200ms ease;
+}
+.toast.hidden { opacity: 0; transform: translateX(-50%) translateY(8px); }
+
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ height: calc(100vh - 120px);
+ max-height: calc(100vh - 120px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding-right: 3px;
+}
+
+.card {
+ position: relative;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 14px;
+ box-shadow: 0 10px 30px rgba(64, 48, 32, 0.08);
+ backdrop-filter: blur(10px);
+ position: relative;
+}
+
+.card h2 {
+ margin: 0 0 12px;
+ font-size: 17px;
+ letter-spacing: 0.03em;
+}
+
+.tool-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0,1fr));
+ gap: 8px;
+}
+
+.tool {
+ border-radius: 14px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ justify-content: flex-start;
+ padding: 8px 8px 8px 42px;
+ min-height: 46px;
+ position: relative;
+ overflow: hidden;
+ background-repeat: no-repeat;
+ background-position: 7px center, center;
+ background-size: 30px 30px, cover;
+}
+.tool::before {
+ content: "";
+ position: absolute;
+ left: 7px;
+ top: 50%;
+ width: 30px;
+ height: 30px;
+ transform: translateY(-50%);
+ background: var(--tool-icon) center / contain no-repeat;
+ opacity: 0.92;
+ filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
+}
+.tool::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: var(--tool-icon) right -8px center / 54px 54px no-repeat;
+ opacity: 0.10;
+ pointer-events: none;
+}
+.tool span { min-width: 16px; text-align: center; color: rgba(72,58,43,0.64); font-size: 10px; }
+.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.png"); }
+.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.png"); }
+.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.png"); }
+.tool[data-tool="water"] { --tool-icon: url("../assets/ui/tool_water.png"); }
+.tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.png"); }
+.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.png"); }
+.tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.png"); }
+.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.png"); }
+.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.png"); }
+.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); }
+.tool[data-tool="poke"] { --tool-icon: url("../assets/ui/tool_poke.png"); }
+.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.png"); }
+.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.png"); }
+.tool.selected {
+ background: linear-gradient(180deg, #ffffff, #efffde);
+ border-color: rgba(103, 178, 44, 0.52);
+ box-shadow: inset 0 0 0 1px rgba(156,232,75,0.38), 0 8px 20px rgba(117, 166, 64, 0.13);
+}
+
+.hint {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.55;
+ margin: 10px 0 0;
+}
+
+.tool-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.tool-head h2 { margin-bottom: 0; }
+.btn.mini { padding: 6px 10px; font-size: 12px; }
+.tool-body { margin-top: 12px; }
+.tool-palette { display: flex; flex-direction: column; gap: 9px; }
+.tool-category {
+ border: 1px solid rgba(84,68,48,0.10);
+ border-radius: 14px;
+ background: rgba(255,255,255,0.34);
+ overflow: hidden;
+}
+.tool-category-toggle {
+ width: 100%;
+ border: 0;
+ border-bottom: 1px solid rgba(84,68,48,0.08);
+ background: rgba(255,252,244,0.66);
+ padding: 8px 10px;
+ text-align: left;
+ cursor: pointer;
+ font-weight: 700;
+ font-size: 12px;
+}
+.tool-category-toggle::before { content: "▾"; display: inline-block; width: 16px; color: var(--muted); }
+.tool-category.collapsed .tool-category-toggle::before { content: "▸"; }
+.tool-category-body { padding: 8px; }
+.tool-category.collapsed .tool-category-body { display: none; }
+.tool-size-badge {
+ position: absolute;
+ right: 7px;
+ bottom: 6px;
+ min-width: 20px;
+ padding: 2px 5px;
+ border-radius: 999px;
+ background: rgba(42,36,29,0.68);
+ color: #fff;
+ font-size: 10px;
+ line-height: 1;
+ text-align: center;
+ box-shadow: 0 1px 4px rgba(42,36,29,0.16);
+ pointer-events: none;
+}
+.chart-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin: 3px 0 8px; }
+.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; }
+.chart-tabs button.active { background: #efffde; border-color: rgba(103,178,44,0.46); font-weight: 700; }
+.log-accident { border-left-color: rgba(226, 136, 54, 0.76); }
+
+
+.stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.stat {
+ background: linear-gradient(180deg, rgba(255,255,255,0.58), rgba(246, 240, 228, 0.72));
+ border: 1px solid var(--line);
+ border-radius: 13px;
+ padding: 9px 8px;
+ text-align: center;
+}
+.stat span { display: block; font-size: 11px; color: var(--muted); }
+.stat strong { font-size: 18px; }
+
+.meter-row {
+ display: grid;
+ grid-template-columns: 72px 1fr;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ margin: 6px 0;
+}
+meter {
+ width: 100%;
+ height: 12px;
+ accent-color: var(--accent);
+}
+
+.selected-info {
+ font-size: 13px;
+ line-height: 1.55;
+}
+.selected-info.empty {
+ color: var(--muted);
+}
+.badge {
+ display: inline-block;
+ font-size: 11px;
+ line-height: 1;
+ border-radius: 999px;
+ padding: 5px 8px;
+ background: #f1f8e9;
+ border: 1px solid rgba(103, 178, 44, 0.28);
+ margin: 0 4px 4px 0;
+}
+.info-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ border-bottom: 1px dashed rgba(84,68,48,0.16);
+ padding: 5px 0;
+}
+.info-row span:first-child { color: var(--muted); }
+
+.log-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; }
+.log {
+ max-height: 360px;
+ min-height: 170px;
+ flex: 1 1 auto;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ font-size: 12px;
+}
+.log-entry {
+ padding: 8px 9px;
+ border-radius: 12px;
+ background: rgba(255,252,246,0.82);
+ border: 1px solid rgba(84,68,48,0.12);
+ border-left: 4px solid rgba(118, 101, 74, 0.24);
+}
+.log-tag {
+ display: inline-block;
+ min-width: 34px;
+ margin-right: 6px;
+ padding: 2px 5px;
+ border-radius: 999px;
+ font-size: 9px;
+ line-height: 1.1;
+ text-align: center;
+ background: rgba(84,68,48,0.08);
+ color: rgba(72,58,43,0.72);
+ text-transform: uppercase;
+}
+.log-entry .time { color: var(--muted); margin-right: 6px; }
+.log-birth { border-left-color: rgba(115, 185, 75, 0.66); }
+.log-death { border-left-color: rgba(91, 83, 75, 0.60); }
+.log-fight { border-left-color: rgba(214, 94, 70, 0.68); }
+.log-food { border-left-color: rgba(118, 185, 77, 0.68); }
+.log-grass { border-left-color: rgba(72, 148, 67, 0.68); }
+.log-weather { border-left-color: rgba(92, 142, 210, 0.62); }
+.log-relation { border-left-color: rgba(204, 126, 176, 0.62); }
+.log-observe { border-left-color: rgba(175, 146, 86, 0.62); }
+
+@media (max-width: 980px) {
+ #app { padding: 10px; }
+ .topbar { align-items: flex-start; flex-direction: column; }
+ .layout { grid-template-columns: 1fr; }
+ .panel { max-height: none; overflow: visible; }
+ #gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); }
+ .stage-wrap { min-height: var(--field-mobile-min-height, 460px); }
+ .field-choice-grid { grid-template-columns: 1fr; }
+}
+
+
+
+
+.tree-note {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+ margin: 0 0 10px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: rgba(255,255,255,0.54);
+ border: 1px solid rgba(84,68,48,0.10);
+}
+
+.lineage-tree {
+ overflow: auto;
+ padding: 8px 10px 18px;
+ contain: content;
+}
+
+.lineage-main-card {
+ margin-top: 16px;
+ min-height: 0;
+}
+
+.lineage-main-card h2 {
+ margin-bottom: 6px;
+}
+
+.lineage-main-content {
+ max-height: 560px;
+ min-height: 320px;
+ overflow: auto;
+ overscroll-behavior: auto;
+ padding: 8px 4px 16px;
+ background: rgba(255,250,244,0.48);
+ border-radius: 14px;
+ border: 1px solid rgba(84,68,48,0.10);
+}
+
+.lineage-main-content .lineage-tree {
+ min-width: 360px;
+ overflow: visible;
+ padding-right: 2px;
+}
+
+
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(circle at 8% 20%, rgba(255,255,255,0.62), transparent 16%),
+ radial-gradient(circle at 92% 18%, rgba(255,255,255,0.45), transparent 18%),
+ linear-gradient(180deg, transparent 0%, rgba(114, 152, 78, 0.05) 100%);
+}
+
+
+.stage-wrap::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(circle at 18% 14%, rgba(255,255,255,0.12), transparent 18%),
+ radial-gradient(circle at 84% 10%, rgba(255,255,255,0.09), transparent 16%),
+ linear-gradient(180deg, rgba(255,255,255,0.07), transparent 24%);
+ z-index: 1;
+}
+#gameCanvas {
+ position: relative;
+ z-index: 0;
+}
+
+.card::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border-radius: inherit;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
+}
+
+/* Compact family board: parent ┳ parent / child, without unrelated founders. */
+.family-board { padding: 0; overflow: visible; }
+.family-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(246px, 1fr));
+ gap: 10px 12px;
+ align-items: start;
+ padding: 2px 2px 10px;
+}
+.family-cluster {
+ position: relative;
+ min-height: 106px;
+ padding: 7px 7px 8px;
+ border-radius: 14px;
+ background: rgba(255,253,248,0.56);
+ border: 1px solid rgba(84,68,48,0.09);
+ contain: layout paint;
+}
+.family-parents {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ align-items: center;
+}
+.family-child-slot {
+ display: flex;
+ justify-content: center;
+ margin-top: 14px;
+}
+.family-node {
+ display: grid;
+ grid-template-columns: 22px minmax(0, 1fr);
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+ min-height: 34px;
+ padding: 4px 6px;
+ border-radius: 10px;
+ background: linear-gradient(180deg, rgba(255,253,248,0.96), rgba(248,241,229,0.90));
+ border: 1px solid rgba(84,68,48,0.14);
+ box-shadow: 0 3px 8px rgba(64,48,32,0.045);
+}
+.family-child-slot .family-node { width: min(150px, 68%); }
+.family-connector {
+ position: relative;
+ height: 12px;
+ margin: 0 42px;
+}
+.family-connector::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 7px;
+ height: 2px;
+ background: rgba(107, 88, 61, 0.55);
+}
+.family-connector span {
+ position: absolute;
+ left: calc(50% - 1px);
+ top: 7px;
+ width: 2px;
+ height: 16px;
+ background: rgba(107, 88, 61, 0.55);
+}
+.family-node.child {
+ border-color: rgba(103, 178, 44, 0.38);
+ box-shadow: inset 0 0 0 1px rgba(156,232,75,0.17), 0 3px 8px rgba(64,48,32,0.045);
+}
+.family-node[data-family-tarinai-id] { cursor: pointer; transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease; }
+.family-node[data-family-tarinai-id]:hover { transform: translateY(-1px); border-color: rgba(112,178,66,0.42); box-shadow: 0 5px 12px rgba(64,48,32,0.08); }
+.family-node.dead { opacity: 0.68; filter: grayscale(0.35); }
+.family-node.missing { opacity: 0.48; border-style: dashed; }
+.family-node img { width: 22px; height: 22px; object-fit: contain; }
+.family-copy { min-width: 0; line-height: 1.12; }
+.family-copy strong, .family-copy span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.family-copy .death-reason {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ line-height: 1.12;
+ margin-top: 1px;
+ color: rgba(126, 80, 64, 0.92);
+}
+.family-copy strong { font-size: 11px; }
+.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; }
+@media (max-width: 760px) {
+ .family-grid { grid-template-columns: 1fr; }
+ .family-cluster { min-height: 102px; }
+}
+
+
+
+
+.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; }
+.colony-stats {
+ grid-template-columns: repeat(2, 1fr);
+ margin-bottom: 8px;
+}
+.colony-chart {
+ display: block;
+ width: 100%;
+ height: 188px;
+ min-height: 188px;
+ border: 1px solid rgba(84, 68, 48, 0.14);
+ border-radius: 14px;
+ background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54));
+ overflow: hidden;
+}
+.colony-card h2 { line-height: 1.45; margin-bottom: 10px; }
+.colony-chart svg { display: block; width: 100%; height: 100%; }
+.colony-chart-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 8px;
+ align-items: center;
+ margin: 4px 0 8px;
+ font-size: 11px;
+ color: var(--muted);
+ line-height: 1.25;
+}
+.colony-chart-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ white-space: nowrap;
+}
+.colony-chart-legend i {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ display: inline-block;
+ box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10);
+}
+
+.relation-row strong { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
+.relation-face {
+ width: 24px;
+ height: 24px;
+ object-fit: contain;
+ border-radius: 999px;
+ background: rgba(255,255,255,0.66);
+ border: 1px solid rgba(84,68,48,0.12);
+ vertical-align: middle;
+ flex: 0 0 auto;
+}
+
+
+.lineage-family {
+ padding: 10px;
+ margin: 0 0 12px;
+ border-radius: 15px;
+ background: rgba(255,253,248,0.62);
+ border: 1px solid rgba(84,68,48,0.12);
+}
+.lineage-family h3 {
+ margin: 0 0 8px;
+ font-size: 13px;
+ color: rgba(64,52,38,0.86);
+}
+.lineage-generation {
+ display: grid;
+ grid-template-columns: 62px 1fr;
+ gap: 8px;
+ align-items: start;
+ padding: 8px 0;
+ border-top: 1px dashed rgba(84,68,48,0.13);
+}
+.lineage-generation:first-of-type { border-top: 0; }
+.lineage-generation-label {
+ position: sticky;
+ left: 0;
+ font-size: 11px;
+ color: var(--muted);
+ padding-top: 8px;
+}
+.lineage-generation-nodes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: stretch;
+}
+.family-node.lineage-node {
+ width: 132px;
+ min-height: 54px;
+ align-items: flex-start;
+}
+.lineage-children {
+ display: block;
+ margin-top: 3px;
+ font-size: 9px;
+ color: rgba(117,106,94,0.84);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.lineage-empty-family {
+ color: var(--muted);
+ font-size: 12px;
+ padding: 10px;
+}
+
+
+.lineage-tree-panel {
+ position: relative;
+ overflow: visible;
+ min-width: 360px;
+ margin-top: 6px;
+}
+.lineage-links {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+}
+.lineage-links path {
+ fill: none;
+ stroke: rgba(107, 88, 61, 0.58);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.lineage-links .partner-link { stroke-width: 2.4; stroke: rgba(98, 80, 56, 0.66); }
+.lineage-links .child-link { stroke-width: 1.9; stroke: rgba(107, 88, 61, 0.54); }
+.tree-node {
+ position: absolute;
+ width: 132px;
+ min-height: 54px;
+ align-items: flex-start;
+ z-index: 2;
+}
+.tree-generation-label {
+ position: absolute;
+ left: 0;
+ width: 54px;
+ font-size: 10px;
+ color: var(--muted);
+ z-index: 1;
+}
+.lineage-family { overflow-x: auto; }
+
+.lineage-family-readable {
+ overflow: visible;
+}
+.lineage-readable-grid {
+ display: grid;
+ grid-template-columns: minmax(300px, 1.05fr) minmax(280px, 0.95fr);
+ gap: 12px;
+ align-items: start;
+}
+.lineage-member-column,
+.lineage-reproduction-column {
+ min-width: 0;
+}
+.lineage-generation-block {
+ padding: 8px 0;
+ border-top: 1px dashed rgba(84,68,48,0.12);
+}
+.lineage-generation-block:first-child { border-top: 0; padding-top: 0; }
+.lineage-generation-block > b,
+.lineage-column-title {
+ display: block;
+ margin: 0 0 7px;
+ color: rgba(84,68,48,0.72);
+ font-size: 11px;
+}
+.lineage-person-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
+ gap: 7px;
+}
+.family-node.lineage-person {
+ width: auto;
+ min-height: 46px;
+}
+.lineage-pair-list {
+ display: grid;
+ gap: 8px;
+}
+.lineage-pair-card {
+ padding: 8px;
+ border-radius: 12px;
+ border: 1px solid rgba(84,68,48,0.12);
+ background: rgba(255,253,248,0.70);
+}
+.lineage-pair-parents,
+.lineage-pair-children {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ align-items: center;
+}
+.lineage-pair-parents {
+ padding-bottom: 5px;
+}
+.lineage-pair-children {
+ padding-top: 5px;
+ border-top: 1px solid rgba(84,68,48,0.08);
+}
+.lineage-plus {
+ color: rgba(84,68,48,0.44);
+ font-weight: 700;
+ line-height: 28px;
+}
+.lineage-birth-arrow {
+ color: rgba(107, 88, 61, 0.62);
+ font-size: 14px;
+ line-height: 1;
+ padding: 1px 0 3px 13px;
+}
+.lineage-mini {
+ display: inline-grid;
+ grid-template-columns: 20px minmax(0, 1fr);
+ grid-template-rows: auto auto;
+ gap: 0 4px;
+ align-items: center;
+ max-width: 132px;
+ min-width: 88px;
+ padding: 4px 6px;
+ border-radius: 9px;
+ border: 1px solid rgba(84,68,48,0.12);
+ background: rgba(255,255,255,0.64);
+}
+.lineage-mini.child {
+ border-color: rgba(103,178,44,0.28);
+ background: rgba(246,255,235,0.62);
+}
+.lineage-mini.dead { opacity: 0.64; filter: grayscale(0.25); }
+.lineage-mini img {
+ grid-row: 1 / span 2;
+ width: 20px;
+ height: 20px;
+ object-fit: contain;
+}
+.lineage-mini b,
+.lineage-mini small {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.lineage-mini b { font-size: 10px; line-height: 1.1; }
+.lineage-mini small { color: var(--muted); font-size: 8px; line-height: 1.1; }
+
+@media (max-width: 760px) {
+ .lineage-readable-grid { grid-template-columns: 1fr; }
+}
diff --git a/docs/code_map.md b/docs/code_map.md
new file mode 100644
index 0000000..2ba87a2
--- /dev/null
+++ b/docs/code_map.md
@@ -0,0 +1,29 @@
+# Code Map
+
+Use this map to open the smallest relevant file first.
+
+## Simulation
+- `js/sim_core.js`: shared simulation helpers, personality definitions, `Effect`, `SpatialGrid`, and small drawing helpers used by simulation classes.
+- `js/items.js`: `Item` lifecycle, item save/load restore, grass and zunchi behavior, and item drawing.
+- `js/tarinai.js`: `Tarinai` state, AI, movement, eating, fighting, panic, reproduction-adjacent behavior, and drawing.
+- `js/world.js`: `World` state, save/load, spawning, family registry normalization, spatial queries, tools, weather, and update loop coordination.
+- `js/sim.js`: compatibility export for `window.TarinaiSim`.
+
+## UI And Rendering
+- `js/render.js`: canvas scene rendering, lighting, weather visuals, HUD, and selected-card overlay.
+- `js/ui.js`: DOM references, selected/log panels, labels, save/load buttons, tool controls, canvas input, and UI event binding.
+- `js/ui_charts.js`: colony history aggregation and chart rendering.
+- `js/ui_family.js`: family tree components, layout, archive rendering, and validation helper.
+- `js/ui_exports.js`: compatibility export for `window.TarinaiUI`.
+
+## Common Tasks
+- Change hunger, sleep, fighting, panic, eating, or movement: start with `js/tarinai.js`.
+- Change item growth, zunchi lifecycle, grass spread, or item visuals: start with `js/items.js`.
+- Change save/load, population, spawning, weather, tools, or family data correctness: start with `js/world.js`.
+- Change family tree display only: start with `js/ui_family.js`.
+- Change colony charts: start with `js/ui_charts.js`.
+- Change buttons, selected info, logs, or input handling: start with `js/ui.js`.
+- Change the garden canvas visuals: start with `js/render.js`.
+
+## Loading Order
+`index.html` loads plain browser scripts in dependency order. Do not convert to ES modules or add a build step unless the project intentionally adopts one later.
diff --git a/docs/design_notes.md b/docs/design_notes.md
new file mode 100644
index 0000000..2b1c1e8
--- /dev/null
+++ b/docs/design_notes.md
@@ -0,0 +1,163 @@
+# たりないコロニー観察所:仕様メモ
+
+## コンセプト
+
+「不足を解決するゲーム」ではなく、
+「不足を抱えた生き物が、それでも勝手に暮らす様子を眺めるゲーム」。
+
+プレイヤーは神ではなく、観察者兼飼育係程度の立場。
+すべてを最適化するより、少し足りない状態の揺らぎを観察することを主目的にする。
+
+## 現在実装しているループ
+
+1. 個体が空腹・孤独・眠気・不安を内部状態として持つ
+2. 状態に応じて餌・仲間・寝床を探す
+3. プレイヤーは環境にアイテムを置いて介入する
+4. 介入と個体行動により、繁殖・死亡・観察ログが生まれる
+5. 死亡個体は「たりない跡」を残し、環境に記録として残る
+
+## 個体状態
+
+| 状態 | 内容 |
+|---|---|
+| 空腹 | 高いほど餌・草を探す。上がりすぎると死亡 |
+| 孤独 | 高いほど仲間に寄る。近くに仲間がいると下がる |
+| 眠気残量 | 低いほど寝る。寝床があると回復が速い |
+| 不安 | 高いほど慌てる。水・石・寝床などで下がる |
+| たりない度 | 上記の総合的な不安定さ |
+
+## 個体タイプ
+
+9枚のスプライトに対応。
+
+- にこたりない
+- いかりたりない
+- うるうるたりない
+- じとたりない
+- よだれたりない
+- ぐしゃたりない
+- すやたりない
+- ぽかんたりない
+- のびたりない
+
+各タイプは、空腹速度・社交性・睡眠傾向・不安耐性・移動速度に差がある。
+
+## 介入アイテム
+
+| アイテム | 役割 |
+|---|---|
+| ふつうの餌 | 空腹を直接下げる |
+| あまい餌 | 空腹と不安を下げるが依存的な挙動を生みやすい |
+| 水滴 | 不安を下げる。時間で蒸発する |
+| 草 | 軽い餌・落ち着き場・死後の循環要素 |
+| ぬくい石 | 周辺温度を上げ、不安を下げる |
+| ふわふわ | 寝床。睡眠回復を強める |
+| つつく | 個体に一時的な不安と速度を与える |
+| 迷い込み | 新規個体を追加する |
+
+## 生死
+
+死亡条件は現在、主に以下。
+
+- 空腹過多
+- 寿命
+- 不安過多
+- 空腹と眠気の複合
+
+死亡時には「たりない跡」が生成され、一定確率で草も発生する。
+これは観察ゲームとして「失敗」ではなく、生態系の記録として扱う。
+
+## 繁殖
+
+近距離にいる2個体が、以下を満たすと低確率で新個体が発生する。
+
+- 空腹が高すぎない
+- 眠気残量が十分ある
+- 繁殖クールダウンが終わっている
+- コロニーが過密でない
+
+子は親のタイプ・足りないものを一部継承し、低確率で変異する。
+
+## 設計上の注意
+
+- 完全な最適化ゲームに寄せすぎない
+- 数値を見せつつ、ログ文で生態観察感を出す
+- 介入は強いが万能ではない
+- 死亡や衰退も観察価値として扱う
+- 「むらさき」は画像作成者名のため、ゲーム内の異常現象名には使用していない
+
+
+## v3設計メモ
+
+- 観察時間を伸ばすため、空腹・孤独の上昇をやや抑制し、寿命を長めにした。
+- 図鑑は「達成」ではなく観察記録として扱う。スプライト、イベント、死因をゆるく解放する。
+- 系譜は全個体を複雑なツリーで描画せず、出生ログとして読みやすさを優先した。
+- 音は外部素材を使わず、Web Audioの短い合成音で実装した。ブラウザ仕様上、音 ON を押すまで鳴らない。
+
+
+## v4 仕様追記
+
+### 子の追従
+繁殖で生まれた子は `childGrowTime` の間、親のどちらか近い方を追従する。成長すると通常AIへ移行する。
+
+### 系譜
+系譜は `family` レジストリで保持し、親子関係を樹状図で表示する。表示時は同じ個体を重複させず、共有された親子関係は複数の接続線で表す。
+
+### 食事エフェクト
+食事パーティクルは表示頻度を抑え、対象アイテムの色を反映する。
+
+### 喧嘩
+近距離、低気分、怒り個体、過密などを条件に小さな喧嘩が発生する。喧嘩中は 02_angry を優先表示し、互いにノックバックする。
+
+### 昼夜
+夜間・夕方の色を背景だけでなく、観察皿・アイテム・たりない本体にも重ねる。
+
+
+## v5 仕様追記
+
+### 草
+草はコロニー内に自動で発生する。草アイテムは `amount` を草量として持ち、時間でゆっくり伸びる。食べられると `amount` が減り、描画本数も減る。
+
+### 昼夜の描画修正
+スプライト本体への色補正は `source-atop` の矩形塗りを廃止し、`ctx.filter` に変更した。これにより透過部分へ矩形状の色が乗る問題を避ける。
+
+### 系譜
+図鑑UIは削除し、系譜のみをメイン画面下部に配置する。
+
+
+## v6 仕様追記
+
+### 頭突き喧嘩
+喧嘩中は一定周期で相手へ突進し、近距離でノックバックと喧嘩エフェクトを発生させる。
+
+### 睡眠
+睡眠状態では速度をゼロにし、移動処理を行わない。寝床が近くても寝ながら移動しない。
+
+### カーソル反応
+カーソルが近い場合、気分の良い個体は笑顔で寄り、不安・怒り・つつき記憶のある個体は逃げる。
+
+
+## v7 ビジュアル改修
+
+- 背景をシャーレ風から庭の観察区画風へ変更。
+- 地面は木枠に囲まれた明るい土床とし、細かな土粒、苔色のムラ、縁の草、野花を加えた。
+- たりないとアイテムの接地影を追加し、庭の上に実在している感覚を強めた。
+- 右上HUDと下部ラベルを観察札のような見た目へ調整。
+- 親子の追従を視認しやすくするため、近距離では薄い点線ガイドを描く。
+
+
+## v8 ビジュアル / 実装改修
+
+- 直射日光・月光の方向をもとに、たりないとアイテムへ投影影を付与。
+- 背景では天体本体は描かず、空色と影方向のみで昼夜を表現する。
+- 食事の蓄積で「ずんち」を排泄する消化処理を追加。
+- 09_stretch は通常描画でも横にやや広く扱い、視認性を上げた。
+- 系譜ツリーの接続線は擬似要素を再設計し、途切れにくい連結表現へ変更。
+- 旧図鑑用の状態管理(codex)と未使用スタイルを整理。
+
+
+## v9 修正
+
+- `ctx.filter = blur(...)` を使った影を廃止し、低負荷な楕円描画へ変更。
+- 背景装飾のループ数を削減し、毎フレームの描画負荷を下げた。
+- 死亡時の `splat` は amount を連続的なアルファ値へ反映し、数分かけてフェードアウトする。
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..7cef262
--- /dev/null
+++ b/index.html
@@ -0,0 +1,135 @@
+
+
+
+
+
+ たりない観察箱
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 家系図
+ 子どもは成長するまで親についていきます。この領域をスクロールして家系を確認できます。
+
+
+
+
+
+
+
フィールドを選択
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/assets.js b/js/assets.js
new file mode 100644
index 0000000..2e64ba1
--- /dev/null
+++ b/js/assets.js
@@ -0,0 +1,133 @@
+"use strict";
+const images = new Map();
+const imageBoundsCache = new WeakMap();
+const imageMetrics = new Map();
+
+const scaledSpriteCache = new Map();
+const stainOverlayCache = new Map();
+
+function trimCanvasCache(cache, limit = 420) {
+ while (cache.size > limit) {
+ const first = cache.keys().next().value;
+ if (first === undefined) break;
+ cache.delete(first);
+ }
+}
+
+function quantizedCanvasSize(v, step = 16) {
+ return Math.max(1, Math.round(v / step) * step);
+}
+
+const SPRITE_CACHE_SCALE = 2.0;
+
+function getCachedSpriteCanvas(id, img, w, h) {
+ if (!img || !w || !h) return img;
+ const qw = quantizedCanvasSize(w, 16);
+ const qh = quantizedCanvasSize(h, 16);
+ const key = `${id}:${qw}x${qh}`;
+ let canvas = scaledSpriteCache.get(key);
+ if (canvas) return canvas;
+ canvas = document.createElement("canvas");
+ canvas.width = qw;
+ canvas.height = qh;
+ const c = canvas.getContext("2d");
+ c.imageSmoothingEnabled = true;
+ if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high";
+ c.clearRect(0, 0, qw, qh);
+ c.drawImage(img, 0, 0, qw, qh);
+ scaledSpriteCache.set(key, canvas);
+ trimCanvasCache(scaledSpriteCache, 520);
+ return canvas;
+}
+
+function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) {
+ if (!img || stain <= 0.01 || !w || !h) return null;
+ const qw = quantizedCanvasSize(w, 16);
+ const qh = quantizedCanvasSize(h, 16);
+ const bucket = Math.max(1, Math.min(8, Math.ceil(stain * 8)));
+ const seedBucket = Math.round((seed || 0) * 16);
+ const key = `${id}:${qw}x${qh}:s${bucket}:r${seedBucket}`;
+ let canvas = stainOverlayCache.get(key);
+ if (canvas) return canvas;
+ canvas = document.createElement("canvas");
+ canvas.width = qw;
+ canvas.height = qh;
+ const c = canvas.getContext("2d");
+ c.imageSmoothingEnabled = true;
+ if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high";
+ c.clearRect(0, 0, qw, qh);
+ c.drawImage(img, 0, 0, qw, qh);
+ c.globalCompositeOperation = "source-in";
+ c.globalAlpha = 0.34 + bucket * 0.075;
+ c.fillStyle = "rgba(28, 92, 22, 0.96)";
+ const s = (seedBucket % 16) / 16;
+ const spots = [
+ [0.40 + s * 0.035, 0.82, 0.14, 0.040, -0.10],
+ [0.59 - s * 0.030, 0.87, 0.15, 0.044, 0.12],
+ [0.50, 0.91, 0.20, 0.036, 0.02],
+ [0.48 + s * 0.020, 0.78, 0.10, 0.030, -0.18],
+ ];
+ for (const [sx, sy, rw, rh, rot] of spots) {
+ c.beginPath();
+ c.ellipse(sx * qw, sy * qh, rw * qw, rh * qh, rot, 0, Math.PI * 2);
+ c.fill();
+ }
+ c.globalAlpha = 1;
+ c.globalCompositeOperation = "source-over";
+ stainOverlayCache.set(key, canvas);
+ trimCanvasCache(stainOverlayCache, 360);
+ return canvas;
+}
+
+function loadImages() {
+ const assets = [...SPRITES, ...DECOR_ASSETS];
+ let loaded = 0;
+ return new Promise((resolve) => {
+ for (const s of assets) {
+ const img = new Image();
+ img.onload = img.onerror = () => {
+ const w = img.naturalWidth || img.width || ALPHA_BOUNDS[s.id]?.imageW || 512;
+ const h = img.naturalHeight || img.height || ALPHA_BOUNDS[s.id]?.imageH || 512;
+ const bounds = ALPHA_BOUNDS[s.id] || { x: 0, y: 0, w, h, imageW: w, imageH: h };
+ imageMetrics.set(s.id, {
+ w,
+ h,
+ ratio: h / Math.max(w, 1),
+ visibleBottomRatio: (bounds.y + bounds.h) / Math.max(bounds.imageH, 1),
+ });
+ loaded += 1;
+ if (loaded === assets.length) resolve();
+ };
+ img.alphaBounds = ALPHA_BOUNDS[s.id] || null;
+ img.src = s.path;
+ images.set(s.id, img);
+ }
+ });
+}
+
+function imageAlphaBounds(img) {
+ if (!img) return null;
+ if (img.alphaBounds) return img.alphaBounds;
+ if (imageBoundsCache.has(img)) return imageBoundsCache.get(img);
+
+ const w = img.naturalWidth || img.width || 0;
+ const h = img.naturalHeight || img.height || 0;
+ if (!w || !h) return null;
+ const bounds = { x: 0, y: 0, w, h, imageW: w, imageH: h };
+ imageBoundsCache.set(img, bounds);
+ return bounds;
+}
+
+function getImageMetrics(idOrImg) {
+ if (typeof idOrImg === "string") return imageMetrics.get(idOrImg) || null;
+ if (!idOrImg) return null;
+ for (const [id, img] of images) {
+ if (img === idOrImg) return imageMetrics.get(id) || null;
+ }
+ const w = idOrImg.naturalWidth || idOrImg.width || 0;
+ const h = idOrImg.naturalHeight || idOrImg.height || 0;
+ return w && h ? { w, h, ratio: h / w, visibleBottomRatio: 1 } : null;
+}
+
+
+window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, scaledSpriteCache, stainOverlayCache, loadImages, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas };
diff --git a/js/audio.js b/js/audio.js
new file mode 100644
index 0000000..24b20cc
--- /dev/null
+++ b/js/audio.js
@@ -0,0 +1,88 @@
+"use strict";
+const audio = {
+ enabled: false,
+ ctx: null,
+ samples: {},
+ sampleLast: {},
+ samplePaths: {
+ hau: "assets/sounds/voice_001_hau.wav",
+ flee: "assets/sounds/voice_002_flee.wav",
+ poop: "assets/sounds/voice_003_po.wav",
+ eat: "assets/sounds/voice_004_eat.wav",
+ stress: "assets/sounds/voice_005_stress.wav",
+ sleep: "assets/sounds/voice_006_sleep.wav",
+ explosion: "assets/sounds/firecracker_explosion.mp3",
+ },
+ ensure() {
+ const AudioClass = window.AudioContext || window.webkitAudioContext;
+ if (!AudioClass) return null;
+ if (!this.ctx) this.ctx = new AudioClass();
+ if (this.ctx.state === "suspended") this.ctx.resume();
+ this.initSamples();
+ return this.ctx;
+ },
+ initSamples() {
+ if (this._samplesReady) return;
+ this._samplesReady = true;
+ for (const [key, path] of Object.entries(this.samplePaths)) {
+ const a = new Audio(path);
+ a.preload = "auto";
+ a.volume = 0.72;
+ this.samples[key] = a;
+ }
+ },
+ toggle() {
+ this.enabled = !this.enabled;
+ if (this.enabled) this.ensure();
+ return this.enabled;
+ },
+ playSample(key, minGap = 0.16) {
+ if (!this.enabled) return false;
+ this.ensure();
+ const base = this.samples[key];
+ if (!base) return false;
+ const t = performance.now() / 1000;
+ if (t - (this.sampleLast[key] || 0) < minGap) return true;
+ this.sampleLast[key] = t;
+ const node = base.cloneNode(true);
+ node.volume = base.volume;
+ node.play().catch(() => {});
+ return true;
+ },
+ tone(freq, dur = 0.08, type = "sine", gain = 0.035, slide = 0) {
+ if (!this.enabled) return;
+ const ctx = this.ensure();
+ if (!ctx) return;
+ const t0 = ctx.currentTime;
+ const osc = ctx.createOscillator();
+ const g = ctx.createGain();
+ osc.type = type;
+ osc.frequency.setValueAtTime(freq, t0);
+ if (slide) osc.frequency.linearRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);
+ g.gain.setValueAtTime(0.0001, t0);
+ g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
+ osc.connect(g).connect(ctx.destination);
+ osc.start(t0);
+ osc.stop(t0 + dur + 0.02);
+ },
+ bubble(text) {
+ const s = String(text || "");
+ if (s.includes("\u3082\u3050")) return this.playSample("eat", 0.20);
+ if (s.includes("\u3076\u308a")) return this.playSample("poop", 0.28);
+ if (s.includes("\u306f\u3063")) return this.playSample("stress", 0.80);
+ if (s.includes("Zzz")) return this.playSample("sleep", 1.25);
+ if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("flee", 0.70);
+ if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("hau", 0.45);
+ return false;
+ },
+ eat() { if (!this.playSample("eat", 0.20)) this.tone(660 + Math.random() * 140, 0.055, "triangle", 0.026, 120); },
+ poke() { this.tone(180, 0.09, "sawtooth", 0.022, -70); },
+ birth() { this.tone(520, 0.08, "sine", 0.028, 100); setTimeout(() => this.tone(760, 0.10, "triangle", 0.024, 80), 70); },
+ death() { this.tone(150, 0.22, "sine", 0.028, -70); },
+ fight() { this.tone(110, 0.055, "square", 0.018, 70); setTimeout(() => this.tone(90, 0.045, "sawtooth", 0.014, -40), 35); },
+ explode() { if (!this.playSample("explosion", 0.30)) { this.tone(90, 0.12, "sawtooth", 0.06, -30); setTimeout(() => this.tone(55, 0.20, "square", 0.04, -20), 40); } },
+ phase() { this.tone(440, 0.06, "sine", 0.018, 60); },
+};
+
+window.TarinaiAudio = audio;
diff --git a/js/data.js b/js/data.js
new file mode 100644
index 0000000..804a27f
--- /dev/null
+++ b/js/data.js
@@ -0,0 +1,107 @@
+"use strict";
+const SPRITES = [
+ { id: "smile", label: "\u901a\u5e38\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_01_smile.png" },
+ { id: "angry", label: "\u6012\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_02_angry.png" },
+ { id: "teary", label: "\u6d99\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_03_teary.png" },
+ { id: "jito", label: "\u3058\u3068\u76ee\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_04_jito.png" },
+ { id: "drool", label: "\u3088\u3060\u308c\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_05_drool.png" },
+ { id: "cry", label: "\u6ce3\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_06_cry.png" },
+ { id: "sleep", label: "\u7720\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_07_sleep.png" },
+ { id: "pokan", label: "\u307d\u304b\u3093\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_08_pokan.png" },
+ { id: "stretch", label: "\u4f38\u3073\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_09_stretch.png" },
+ { id: "hurt", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_10_hurt.png", actionOnly: true },
+ { id: "weak", label: "\u5f31\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_11_weak.png", actionOnly: true },
+ { id: "back", label: "\u5965\u5411\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_12_back.png", actionOnly: true },
+ { id: "flee", label: "\u6557\u8d70\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_13_flee.png", actionOnly: true },
+ { id: "zunda_eat", label: "\u305a\u3093\u3060\u9905\u98df\u3079\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_14_zunda_eat.png", actionOnly: true },
+ { id: "hurt2", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_15_hurt2.png", actionOnly: true },
+ { id: "normal_smirk", label: "\u901a\u5e38\u305f\u308a\u306a\u3044\u30fb\u306b\u3084\u308a", path: "assets/sprites/tarinai_16_normal_smirk.png", displayOnly: true },
+ { id: "normal_tongue", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u820c", path: "assets/sprites/tarinai_17_normal_tongue.png", displayOnly: true },
+ { id: "fear", label: "\u6050\u6016\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_18_fear.png", actionOnly: true },
+ { id: "flee_fear2", label: "\u6557\u8d70\u6050\u6016\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_19_flee_fear2.png", actionOnly: true },
+ { id: "sleep2", label: "\u7720\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_20_sleep2.png", actionOnly: true },
+ { id: "normal_happy", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3054\u304d\u3052\u3093", path: "assets/sprites/tarinai_21_normal_happy.png", displayOnly: true },
+ { id: "fear_blue", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u9752\u3056\u3081", path: "assets/sprites/tarinai_22_fear_blue.png", actionOnly: true },
+ { id: "fear_cry", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u6d99", path: "assets/sprites/tarinai_23_fear_cry.png", actionOnly: true },
+ { id: "stress_dizzy", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3050\u308b\u3050\u308b", path: "assets/sprites/tarinai_24_stress_dizzy.png", actionOnly: true },
+ { id: "stress_sweat", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u6c57", path: "assets/sprites/tarinai_25_stress_sweat.png", actionOnly: true },
+ { id: "intimidate", label: "\u5a01\u5687\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_26_intimidate.png", actionOnly: true },
+ { id: "birth_ritual", label: "\u7e41\u6b96\u524d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_27_birth_ritual.png", actionOnly: true },
+];
+const DECOR_ASSETS = [
+ { id: "zunchi", path: "assets/objects/zunchi.png" },
+ { id: "zunchi_02", path: "assets/objects/zunchi_02.png" },
+];
+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 },
+ 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 },
+ 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 },
+ back: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
+ flee: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
+ zunda_eat: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
+ hurt2: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
+ normal_smirk: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
+ normal_tongue: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
+ fear: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
+ flee_fear2: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
+ sleep2: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
+ normal_happy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
+ fear_blue: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
+ fear_cry: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
+ 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: { 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 },
+};
+
+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"];
+const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi", "ro", "mu", "a"];
+
+const CONFIG = {
+ initialPopulation: 18,
+ maxPopulation: Number.MAX_SAFE_INTEGER,
+ itemLimit: 260,
+ zunchiLimit: 56,
+ traceLimit: 64,
+ splatLimit: 48,
+ effectLimit: 96,
+ saveKey: "tarinai-colony-save-v38",
+ worldPadding: 30,
+ hungerPerMinute: 11.2,
+ lonelinessPerMinute: 7.6,
+ energyPerMinute: 8.2,
+ stressDecayPerMinute: 9,
+ reproductionCooldown: 18,
+ dayLength: 120,
+ childGrowTime: 58,
+ fightCooldown: 8,
+ lightingQuality: "cinematic",
+ weatherChangeMin: 46,
+ weatherChangeMax: 96,
+ bubbleLimit: 42,
+ grassLimit: 96,
+ highPopulationMode: 70,
+ ultraPopulationMode: 120,
+};
+const FIELD_TYPES = {
+ cage: { id: "cage", label: "\u30b1\u30fc\u30b8", sizeLabel: "\u5c0f", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 0.46, population: 12, grass: 9 },
+ garden: { id: "garden", label: "\u5ead", sizeLabel: "\u4e2d", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 18 },
+ park: { id: "park", label: "\u516c\u5712", sizeLabel: "\u5927", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 2.35, population: 24, grass: 28 },
+};
+const LIGHTING_TUNING = {
+ rayAlpha: 0.040,
+ nightOverlayAlpha: 0.135,
+ goldenOverlayAlpha: 0.045,
+ bloomWashAlpha: 0.040,
+};
+
+window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, FIELD_TYPES, LIGHTING_TUNING };
diff --git a/js/items.js b/js/items.js
new file mode 100644
index 0000000..1c4083f
--- /dev/null
+++ b/js/items.js
@@ -0,0 +1,491 @@
+"use strict";
+
+class Item {
+ constructor(type, x, y) {
+ this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
+ this.type = type;
+ this.x = x;
+ this.y = y;
+ this.r = {
+ food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
+ }[type] || 12;
+ this.amount = {
+ food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
+ }[type] || 80;
+ this.age = 0;
+ this.seed = Math.random() * 1000;
+ this.dropTimer = 0;
+ this.dropMax = 0;
+ this.dropImpactDone = false;
+ this.lifecycleTimer = rand(0, type === "grass" ? 1.15 : 0.55);
+ this.lifecycleInterval = type === "grass" ? (1.75 + stableUnit(this.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(this.id, "zunchi-life") * 0.45) : 0);
+ if (type === "grass") {
+ this.growth = rand(0.18, 0.42);
+ this.health = 1;
+ this.seedTimer = rand(18, 38);
+ this.eatenAmount = 0;
+ this.fertilityBoost = 0;
+ }
+ if (type === "bed") {
+ this.comfort = rand(0.86, 1.14);
+ this.wear = 0;
+ }
+ if (type === "zunchi") {
+ this.stage = "fresh";
+ this.stageTimer = 0;
+ this.fertility = 0;
+ this.freshness = 1;
+ this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
+ }
+ if (type === "firecracker") {
+ this.fuseTimer = 5;
+ this.fuseMax = 5;
+ }
+ }
+ update(dt, worldRef = world) {
+ this.age += dt;
+ if (this.dropTimer > 0) {
+ const beforeDrop = this.dropTimer;
+ this.dropTimer = Math.max(0, this.dropTimer - dt);
+ if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) {
+ this.dropImpactDone = true;
+ if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
+ }
+ }
+ if (this.type === "water") this.amount -= dt * 0.9;
+ if (this.type === "sweet") this.amount -= dt * 0.12;
+ if (this.type === "trace") this.amount -= dt * 1.35;
+ if (this.type === "splat") this.amount -= dt * 1.05;
+ if (this.type === "firecracker") {
+ this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
+ if (this.fuseTimer <= 0) {
+ if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this);
+ else this.amount = 0;
+ }
+ }
+ if (this.type === "grass") {
+ this.lifecycleTimer += dt;
+ const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
+ const interval = (this.lifecycleInterval || 1.9) * factor;
+ if (this.lifecycleTimer >= interval) {
+ const lifecycleDt = Math.min(this.lifecycleTimer, 4.8);
+ this.lifecycleTimer = 0;
+ this.updateGrass(lifecycleDt, worldRef);
+ }
+ } else if (this.type === "zunchi") {
+ this.lifecycleTimer += dt;
+ const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45);
+ if (this.lifecycleTimer >= interval) {
+ const lifecycleDt = Math.min(this.lifecycleTimer, 2.4);
+ this.lifecycleTimer = 0;
+ this.updateZunchi(lifecycleDt, worldRef);
+ }
+ }
+ }
+ updateGrass(dt, worldRef) {
+ const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7;
+ const weather = worldRef.weather || "sunny";
+ const grassLoad = worldRef.grassLoadLevel ? worldRef.grassLoadLevel() : 0;
+ if (worldRef.grassBlockedAt && worldRef.grassBlockedAt(this.x, this.y, this)) {
+ this.growth = Math.max(0, (this.growth ?? 0.25) - dt * 0.28);
+ this.amount -= dt * 34;
+ return;
+ }
+ let fertility = 0;
+ let fertileSource = null;
+ let bestFertility = 0;
+ let waterNear = weather === "light_rain" ? 0.35 : 0;
+ const nearbyRadius = grassLoad >= 3 ? 190 : grassLoad >= 2 ? 230 : grassLoad >= 1 ? 290 : 360;
+ const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, nearbyRadius) : (worldRef.items || []);
+ let scanned = 0;
+ const scanLimit = grassLoad >= 3 ? 18 : grassLoad >= 2 ? 26 : grassLoad >= 1 ? 40 : 9999;
+ for (const it of nearby) {
+ if (++scanned > scanLimit) break;
+ if (it === this) continue;
+ const d = distXY(this.x, this.y, it.x, it.y);
+ if ((it.type === "trace" || it.type === "splat") && d < 170) {
+ const influence = clamp(1 - d / 170, 0, 1) * (it.type === "splat" ? 0.92 : 0.55);
+ fertility += influence;
+ if (influence > bestFertility) { bestFertility = influence; fertileSource = it; }
+ }
+ if (it.type === "zunchi" && (it.stage === "decomposing" || it.stage === "fertile_soil")) {
+ const influence = clamp(1 - d / nearbyRadius, 0, 1) * (it.stage === "fertile_soil" ? 1.25 : 0.72);
+ fertility += influence;
+ if (influence > bestFertility) {
+ bestFertility = influence;
+ fertileSource = it;
+ }
+ }
+ if (it.type === "water") waterNear += clamp(1 - d / 90, 0, 1) * 0.25;
+ }
+ this.fertilityBoost = clamp(fertility, 0, 1.4);
+ const weatherBoost = weather === "sunny" ? 0.09 : weather === "light_rain" ? 0.42 : 0.08;
+ const dryStress = weather === "sunny" && light > 0.78 && waterNear < 0.04 && this.fertilityBoost < 0.12 ? 0.012 : 0;
+ const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * 0.5;
+ this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88);
+ this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1);
+ const grassLimit = CONFIG.grassLimit ?? 96;
+ if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
+ const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2);
+ if (spawned > 0) this.growth = rand(0.52, 0.68);
+ this.seedTimer = rand(18, 34);
+ }
+ this.amount = clamp(this.growth * 120 * this.health, 3, 130);
+ this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * 0.5;
+ if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
+ this.seedTimer = rand(24, 54);
+ if (Math.random() < 0.42 + this.fertilityBoost * 0.18) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1);
+ }
+ }
+ seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) {
+ let made = 0;
+ const limit = CONFIG.grassLimit ?? 96;
+ for (let n = 0; n < maxNew && (worldRef.itemCounts?.grass || 0) < limit; n++) {
+ const source = fertile && fertileSource ? fertileSource : this;
+ const spot = worldRef.findGrassSproutSpot
+ ? worldRef.findGrassSproutSpot(source, Boolean(fertile && fertileSource))
+ : { x: clamp(this.x + rand(-58, 58), 44, worldRef.w - 44), y: clamp(this.y + rand(-42, 42), 44, worldRef.h - 44) };
+ if (!spot) break;
+ const sprout = new Item("grass", spot.x, spot.y);
+ sprout.growth = rand(0.08, 0.18);
+ sprout.amount = sprout.growth * 120;
+ worldRef.items.push(sprout);
+ if (worldRef.itemCounts) worldRef.itemCounts.grass = (worldRef.itemCounts.grass || 0) + 1;
+ made += 1;
+ }
+ return made;
+ }
+
+ updateZunchi(dt, worldRef) {
+ const rainBoost = worldRef.weather === "light_rain" ? 1.35 : 1;
+ let waterBoost = 0;
+ const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
+ for (const it of nearby) {
+ if (it.type !== "water") continue;
+ waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
+ }
+ this.stageTimer += dt * 2.0 * (rainBoost + waterBoost * 1.3);
+ if (this.stage === "fresh") {
+ this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
+ if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
+ } else if (this.stage === "dry") {
+ this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
+ if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
+ } else if (this.stage === "decomposing") {
+ this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
+ this.amount -= dt * 0.12;
+ if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
+ } else if (this.stage === "fertile_soil") {
+ this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
+ this.amount -= dt * 0.26;
+ }
+ }
+ get dead() {
+ return this.amount <= 0;
+ }
+ serialize() {
+ return {
+ type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed,
+ stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness,
+ growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost,
+ comfort: this.comfort, wear: this.wear, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax
+ };
+ }
+ static from(o) {
+ const type = o?.type || "grass";
+ const it = Object.create(Item.prototype);
+ Item._loadId = (Item._loadId || 0) + 1;
+ it.id = o.id || `loaded-${Item._loadId}`;
+ it.type = type;
+ it.x = o.x;
+ it.y = o.y;
+ it.r = {
+ food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
+ }[type] || 12;
+ it.amount = o.amount ?? ({
+ food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
+ }[type] || 80);
+ it.age = o.age || 0;
+ it.seed = o.seed || Math.random() * 1000;
+ it.dropTimer = 0;
+ it.dropMax = 0;
+ it.dropImpactDone = false;
+ it.lifecycleTimer = o.lifecycleTimer ?? rand(0, type === "grass" ? 1.15 : 0.55);
+ it.lifecycleInterval = o.lifecycleInterval ?? (type === "grass" ? (1.75 + stableUnit(it.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(it.id, "zunchi-life") * 0.45) : 0));
+ if (type === "grass") {
+ it.growth = 0.25;
+ it.health = 1;
+ it.seedTimer = rand(18, 38);
+ it.eatenAmount = 0;
+ it.fertilityBoost = 0;
+ }
+ if (type === "bed") {
+ it.comfort = 1;
+ it.wear = 0;
+ }
+ if (type === "zunchi") {
+ it.stage = "fresh";
+ it.stageTimer = 0;
+ it.fertility = 0;
+ it.freshness = 1;
+ it.zunchiVariant = "zunchi";
+ }
+ if (type === "firecracker") {
+ it.fuseTimer = 5;
+ it.fuseMax = 5;
+ }
+ it.stage = o.stage || it.stage;
+ it.stageTimer = o.stageTimer || 0;
+ it.fertility = o.fertility || 0;
+ it.freshness = o.freshness ?? it.freshness;
+ it.growth = o.growth ?? it.growth;
+ it.health = o.health ?? it.health;
+ it.seedTimer = o.seedTimer ?? it.seedTimer;
+ it.eatenAmount = o.eatenAmount || 0;
+ it.fertilityBoost = o.fertilityBoost || 0;
+ it.comfort = o.comfort ?? it.comfort;
+ it.wear = o.wear ?? it.wear;
+ it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi";
+ it.toolSize = o.toolSize || it.toolSize || "medium";
+ it.blastScale = o.blastScale || it.blastScale || 1;
+ it.fuseTimer = o.fuseTimer ?? it.fuseTimer;
+ it.fuseMax = o.fuseMax ?? it.fuseMax;
+ it.dropTimer = 0;
+ it.dropMax = 0;
+ it.dropImpactDone = true;
+ return it;
+ }
+ draw(ctx, t, lighting = null) {
+ const lightState = lighting || getLightingState(world);
+ const night = clamp(lightState.nightStrength * 1.35, 0, 1);
+ const warm = lightState.warmth;
+ const styleNight = lightState.light < 0.42;
+ const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
+ const visibleAlpha = clamp(this.amount / 40, 0.24, 1);
+ const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
+ const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0;
+ const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
+ if (this.type !== "trace" && this.type !== "splat") {
+ const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
+ const zunchiImg = zunchiId ? (images.get(zunchiId) || images.get("zunchi")) : null;
+ if (zunchiImg) {
+ const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi");
+ const ratio = metrics?.ratio || 178 / 236;
+ const zunchiH = this.r * 1.66 * ratio;
+ drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, {
+ alpha: shadow.alpha * visibleAlpha * 1.18,
+ widthScale: 0.92,
+ heightScale: 0.92,
+ });
+ } else if (this.type === "grass") {
+ ctx.save();
+ ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13);
+ ctx.fillStyle = shadow.color;
+ ctx.beginPath();
+ ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ } else {
+ drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 });
+ }
+ }
+ drawItemGlow(ctx, this, lightState, visibleAlpha);
+ if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) {
+ ctx.save();
+ ctx.globalAlpha = 0.32;
+ ctx.strokeStyle = "rgba(255, 250, 220, 0.82)";
+ ctx.lineWidth = 1.4;
+ ctx.beginPath();
+ ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+ ctx.save();
+ ctx.translate(this.x, this.y + dropY);
+ ctx.globalAlpha = visibleAlpha;
+ if (this.type === "food" || this.type === "sweet") {
+ const sweet = this.type === "sweet";
+ ctx.fillStyle = sweet ? "#fffaf1" : "#f1dfb7";
+ ctx.strokeStyle = sweet ? "#9a8a72" : "#8d6f48";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ const bob = 0;
+ ctx.ellipse(0, bob, this.r * 1.25, this.r * 0.75, Math.sin(this.seed) * 0.4, 0, Math.PI * 2);
+ ctx.fill(); ctx.stroke();
+ if (sweet) {
+ ctx.beginPath();
+ ctx.fillStyle = "#76b94d";
+ ctx.ellipse(-1, -this.r * 0.24, this.r * 0.76, this.r * 0.26, Math.sin(this.seed) * 0.25, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = "rgba(255,255,255,0.62)";
+ ctx.beginPath();
+ ctx.ellipse(-this.r * 0.32, -this.r * 0.16, this.r * 0.24, this.r * 0.10, -0.35, 0, Math.PI * 2);
+ ctx.fill();
+ } else {
+ ctx.fillStyle = "#fff7dc";
+ for (let i = 0; i < 5; i++) {
+ ctx.beginPath();
+ ctx.arc(randSeed(this.seed + i, -8, 8), randSeed(this.seed + i + 7, -5, 5), 1.6, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ } else if (this.type === "water") {
+ ctx.fillStyle = "rgba(86, 157, 224, 0.50)";
+ ctx.strokeStyle = "rgba(62, 113, 178, 0.42)";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.ellipse(0, Math.sin(t * 1.7 + this.seed) * 1.5, this.r * 1.05, this.r * 0.8, 0, 0, Math.PI * 2);
+ ctx.fill(); ctx.stroke();
+ ctx.fillStyle = "rgba(255,255,255,0.55)";
+ ctx.beginPath(); ctx.arc(-4, -4, 3, 0, Math.PI * 2); ctx.fill();
+ } else if (this.type === "grass") {
+ const growth = this.growth ?? clamp(this.amount / 120, 0, 1);
+ const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature";
+ const eaten = this.eatenAmount > 14 || growth < 0.16;
+ const grassLoad = 1;
+ ctx.strokeStyle = eaten ? "#7b8755" : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"));
+ ctx.shadowColor = "transparent";
+ ctx.shadowBlur = 0;
+ ctx.lineCap = "round";
+ ctx.lineWidth = grassLoad >= 2 ? (styleNight ? 1.3 : 1.5) : (styleNight ? 1.7 : 2);
+ const matureBlades = grassLoad >= 3 ? 2 : grassLoad >= 2 ? 3 : grassLoad >= 1 ? 5 : 7;
+ const bladeCount = eaten ? 2 : (stage === "sprout" ? 2 : stage === "young" ? (grassLoad >= 2 ? 2 : 3) : matureBlades);
+ for (let i = 0; i < bladeCount; i++) {
+ const spread = stage === "mature" ? (grassLoad >= 2 ? 0.92 : 1.18) : 0.70;
+ const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread);
+ const len = this.r * randSeed(this.seed + i + 10, 0.45, stage === "mature" ? (grassLoad >= 2 ? 1.08 : 1.30) : 1.05) * clamp(growth, 0.18, 1.00) * (eaten ? 0.42 : 1);
+ const rootX = stage === "mature" ? randSeed(this.seed + i + 31, -this.r * (grassLoad >= 2 ? 0.24 : 0.32), this.r * (grassLoad >= 2 ? 0.24 : 0.32)) : 0;
+ ctx.beginPath();
+ ctx.moveTo(rootX, 8);
+ ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78);
+ ctx.stroke();
+ }
+ if (stage === "mature" && !eaten && grassLoad <= 1) {
+ ctx.save();
+ ctx.globalAlpha = 0.16;
+ ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)";
+ ctx.beginPath();
+ ctx.ellipse(0, 1, this.r * 0.72, this.r * 0.22, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+ } else if (this.type === "stone") {
+ ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8");
+ ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63";
+ ctx.shadowColor = "transparent";
+ ctx.shadowBlur = 0;
+ ctx.lineWidth = 2;
+ roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8);
+ ctx.fill(); ctx.stroke();
+ ctx.fillStyle = "rgba(255,255,255,0.34)";
+ ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill();
+ } else if (this.type === "bed") {
+ ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" );
+ ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" );
+ ctx.lineWidth = 2.2;
+ ctx.beginPath();
+ ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ ctx.strokeStyle = "rgba(116, 89, 42, 0.45)";
+ ctx.lineWidth = 1.5;
+ for (let i = 0; i < 13; i++) {
+ const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08);
+ const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46);
+ const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70);
+ const a = randSeed(this.seed + i + 60, -0.75, 0.75);
+ 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 === "firecracker") {
+ const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
+ const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;
+ ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
+ ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43";
+ ctx.strokeStyle = "rgba(96,48,34,0.78)";
+ ctx.lineWidth = 2;
+ roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = "#f4d15b";
+ ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
+ ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
+ ctx.strokeStyle = "rgba(74,55,39,0.82)";
+ ctx.lineWidth = 2.2;
+ ctx.beginPath();
+ ctx.moveTo(0, -this.r * 0.88);
+ ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
+ ctx.stroke();
+ ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)";
+ ctx.beginPath();
+ ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = "rgba(42,36,29,0.66)";
+ ctx.font = "bold 10px ui-rounded, sans-serif";
+ ctx.textAlign = "center";
+ ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
+ } else if (this.type === "fence_v" || this.type === "fence_h") {
+ const vertical = this.type === "fence_v";
+ const len = Math.max(112, this.r * 3.55);
+ const thick = Math.max(10, this.r * 0.31);
+ ctx.save();
+ ctx.rotate(vertical ? 0 : Math.PI / 2);
+ const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31");
+ const edge = styleNight ? "#4d3d2f" : "#5f3f25";
+ const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)";
+ ctx.fillStyle = wood;
+ ctx.strokeStyle = edge;
+ ctx.lineWidth = 2.4;
+ ctx.beginPath();
+ roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = hi;
+ ctx.beginPath();
+ roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3);
+ ctx.fill();
+ ctx.restore();
+ } else if (this.type === "zunchi") {
+ const zunchiId = this.zunchiVariant || "zunchi";
+ const img = images.get(zunchiId) || images.get("zunchi");
+ if (img) {
+ const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi");
+ const ratio = metrics?.ratio || 178 / 236;
+ const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
+ ctx.globalAlpha *= stageAlpha;
+ ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio);
+ }
+ } else if (this.type === "trace") {
+ ctx.fillStyle = "rgba(122, 100, 76, 0.12)";
+ ctx.strokeStyle = "rgba(122, 100, 76, 0.16)";
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2);
+ ctx.fill(); ctx.stroke();
+ } else if (this.type === "splat") {
+ const fade = clamp(this.amount / 260, 0, 1);
+ ctx.globalAlpha = visibleAlpha * fade;
+ ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`;
+ ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`;
+ ctx.lineWidth = 1.8;
+ const blobs = [
+ [-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5]
+ ];
+ for (const [bx, by, bw, bh] of blobs) {
+ ctx.beginPath();
+ ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ }
+ for (let i = 0; i < 7; i++) {
+ ctx.beginPath();
+ ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+ }
+}
+
diff --git a/js/main.js b/js/main.js
new file mode 100644
index 0000000..7b2b2b6
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,52 @@
+"use strict";
+let last = nowSec();
+let statsTimer = 0;
+let fpsTimer = 0;
+let fpsFrames = 0;
+let perfResizeTimer = 0;
+let lastPerfLevel = 0;
+window.__tarinaiFps = 0;
+function loop() {
+ const t = nowSec();
+ const rawDt = t - last;
+ const dt = clamp(rawDt, 0, 0.05);
+ last = t;
+ fpsTimer += rawDt;
+ fpsFrames += 1;
+ if (fpsTimer >= 0.5) {
+ window.__tarinaiFps = Math.round(fpsFrames / Math.max(fpsTimer, 0.001));
+ fpsTimer = 0;
+ fpsFrames = 0;
+ }
+ world.update(dt);
+ render();
+ statsTimer += dt;
+ const perf = world.performanceLevel ? world.performanceLevel() : 0;
+ perfResizeTimer += rawDt;
+ if (perfResizeTimer > 1.5) {
+ if (perf !== lastPerfLevel) {
+ lastPerfLevel = perf;
+ resizeCanvas();
+ }
+ perfResizeTimer = 0;
+ }
+ const statsInterval = perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48;
+ if (statsTimer > statsInterval) {
+ renderStats();
+ statsTimer = 0;
+ }
+ requestAnimationFrame(loop);
+}
+
+loadImages().then(() => {
+ bindUI();
+ applyFieldLayout("garden");
+ world.reset(null, "garden");
+ selectTool("observe");
+ renderLog(world.logs);
+ renderArchive();
+ render();
+ renderStats();
+ syncTopButtons();
+ loop();
+});
diff --git a/js/math.js b/js/math.js
new file mode 100644
index 0000000..37b6179
--- /dev/null
+++ b/js/math.js
@@ -0,0 +1,13 @@
+"use strict";
+const rand = (min, max) => min + Math.random() * (max - min);
+const randi = (min, max) => Math.floor(rand(min, max + 1));
+const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
+const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+const lerp = (a, b, t) => a + (b - a) * t;
+const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
+const distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
+const nowSec = () => performance.now() / 1000;
+const fmt = (v) => Math.round(v).toString();
+
+
+window.TarinaiMath = { rand, randi, pick, clamp, lerp, dist, distXY, nowSec, fmt };
diff --git a/js/render.js b/js/render.js
new file mode 100644
index 0000000..13e425d
--- /dev/null
+++ b/js/render.js
@@ -0,0 +1,652 @@
+"use strict";
+function randSeed(seed, min, max) {
+ const s = Math.sin(seed * 12.9898) * 43758.5453;
+ return min + (s - Math.floor(s)) * (max - min);
+}
+
+function roundedBlob(ctx, x, y, w, h, wobble = 4) {
+ ctx.beginPath();
+ const steps = 28;
+ for (let i = 0; i <= steps; i++) {
+ const a = (i / steps) * Math.PI * 2;
+ const rr = 1 + Math.sin(a * 3.1) * 0.025 + Math.cos(a * 5.7) * 0.018;
+ const px = x + Math.cos(a) * w * rr + Math.sin(a * 2.0) * wobble * 0.08;
+ const py = y + Math.sin(a) * h * rr + Math.cos(a * 1.7) * wobble * 0.08;
+ if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+}
+
+function getLightingState(targetWorld) {
+ const progress = targetWorld.dayProgress();
+ const light = targetWorld.lightLevel();
+ const dawnStrength = clamp(1 - Math.abs(progress - 0.22) / 0.14, 0, 1);
+ const duskStrength = clamp(1 - Math.abs(progress - 0.58) / 0.16, 0, 1);
+ const goldenStrength = Math.max(dawnStrength * 0.62, duskStrength);
+ const nightStrength = clamp(1 - light * 1.62, 0, 0.54);
+ const noonStrength = clamp((light - 0.72) / 0.28, 0, 1) * clamp(1 - goldenStrength * 0.72, 0, 1);
+ const elevation = clamp(Math.sin(progress * Math.PI), 0.10, 1);
+ const horizon = Math.cos(progress * Math.PI * 2 - Math.PI / 2);
+ const shadowLength = lerp(1.58, 0.52, elevation);
+ const warmth = clamp(0.28 + goldenStrength * 0.70 + noonStrength * 0.18 - nightStrength * 0.12, 0, 1);
+ const bloom = CONFIG.lightingQuality === "cinematic" ? clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58) : 0;
+ return {
+ quality: CONFIG.lightingQuality,
+ progress,
+ light,
+ dawnStrength,
+ duskStrength,
+ goldenStrength,
+ nightStrength,
+ noonStrength,
+ elevation,
+ warmth,
+ bloom,
+ sunDirX: -horizon,
+ sunDirY: 0.42 + (1 - elevation) * 0.32,
+ shadowLength,
+ shadowColor: nightStrength > 0.22 ? "rgba(40, 50, 91, 0.36)" : (goldenStrength > 0.15 ? "rgba(116, 76, 48, 0.28)" : "rgba(83, 72, 54, 0.27)"),
+ shadowAlpha: (nightStrength > 0.22 ? 0.135 : lerp(0.13, 0.22, 1 - elevation) + goldenStrength * 0.035),
+ ambientTint: nightStrength > 0.2 ? "#253a8e" : (goldenStrength > 0.2 ? "#ffad6d" : "#fff7d6"),
+ vignetteAlpha: nightStrength > 0.18 ? 0.13 + nightStrength * 0.13 : 0.06 + goldenStrength * 0.045,
+ };
+}
+
+function projectedShadowParams(lighting, scale = 1) {
+ const edgeLight = clamp((1 - lighting.elevation) * 0.60 + lighting.goldenStrength * 0.40 + lighting.nightStrength * 0.55, 0, 1);
+ const moonLit = lighting.nightStrength > 0.18;
+ const lightEdge = moonLit
+ ? (lighting.progress < 0.5 ? 1 : -1)
+ : (lighting.progress < 0.5 ? -1 : 1);
+ const castDir = -lightEdge;
+ const weather = world.weather || "sunny";
+ const weatherAlpha = weather === "sunny" ? 1.12 : weather === "cloudy" ? 0.72 : 0.38;
+ const length = lerp(0.62, 1.82, edgeLight) * scale;
+ return {
+ dx: castDir * 12 * length,
+ dy: 5.2 * length,
+ skew: castDir * lerp(0.08, 0.34, edgeLight),
+ flatness: lerp(0.42, 0.24, edgeLight),
+ spread: lerp(0.98, 1.52, edgeLight),
+ alpha: clamp((0.16 + edgeLight * 0.09 - lighting.nightStrength * 0.04) * (0.92 + scale * 0.12) * weatherAlpha, 0.05, 0.32),
+ color: lighting.nightStrength > 0.22 ? "rgba(42, 48, 72, 0.34)" : "rgba(78, 65, 43, 0.31)",
+ };
+}
+
+function imageVisibleBottomFromTop(img, top, drawH) {
+ const b = imageAlphaBounds(img);
+ if (!b) return top + drawH;
+ return top + ((b.y + b.h) / b.imageH) * drawH;
+}
+
+function drawImageProjectedShadow(ctx, img, x, groundY, drawW, drawH, params, options = {}) {
+ if (!img) return false;
+ const alpha = options.alpha ?? params.alpha;
+ const rx = Math.max(5, drawW * (options.widthScale ?? 1) * 0.30);
+ const ry = Math.max(2, drawH * (options.heightScale ?? 1) * 0.055);
+ ctx.save();
+ ctx.globalAlpha = clamp(alpha, 0.08, 0.22);
+ ctx.fillStyle = params.color;
+ ctx.beginPath();
+ ctx.ellipse(Math.round(x), Math.round(groundY), rx, ry, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ return true;
+}
+
+function drawProjectedShadow(ctx, x, y, rx, ry, params) {
+ ctx.save();
+ ctx.globalAlpha = clamp(params.alpha || 0.10, 0.035, 0.16);
+ ctx.fillStyle = params.color || "rgba(78, 65, 43, 0.24)";
+ ctx.beginPath();
+ ctx.ellipse(Math.round(x), Math.round(y), Math.max(3, rx * 0.82), Math.max(1.5, ry * 0.46), 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+}
+
+function drawRadialGlow(ctx, x, y, radius, color, alpha) {
+ if (alpha <= 0 || radius <= 0) return;
+ ctx.save();
+ ctx.globalCompositeOperation = "lighter";
+ const g = ctx.createRadialGradient(x, y, 0, x, y, radius);
+ g.addColorStop(0, color.replace("ALPHA", alpha.toFixed(3)));
+ g.addColorStop(0.52, color.replace("ALPHA", (alpha * 0.34).toFixed(3)));
+ g.addColorStop(1, color.replace("ALPHA", "0"));
+ ctx.fillStyle = g;
+ ctx.beginPath();
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+}
+
+function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
+ // \u500b\u5225\u306e\u767a\u5149\u30b0\u30e9\u30c7\u30fc\u30b7\u30e7\u30f3\u306f\u500b\u4f53\u6570\u5897\u52a0\u6642\u306e\u8ca0\u8377\u304c\u9ad8\u3044\u305f\u3081\u3001\u901a\u5e38\u63cf\u753b\u306b\u7d71\u5408\u3059\u308b\u3002
+ return;
+}
+
+function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
+ if (tarinai.dead || tarinai.world.selected !== tarinai) return;
+ ctx.save();
+ ctx.globalAlpha = 0.10;
+ ctx.fillStyle = "rgba(178, 236, 96, 0.42)";
+ ctx.beginPath();
+ ctx.ellipse(tarinai.x, tarinai.y - drawH * 0.06, drawW * 0.58, drawH * 0.42, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+}
+
+function drawDawnRimLight(ctx, drawW, drawH, lighting) {
+ return;
+}
+
+// Cached static garden layer: expensive soil/decor loops are rendered only when
+// canvas size, coarse light phase, or weather changes.
+let activeCtx = null;
+
+const backgroundCache = {
+ canvas: null,
+ ctx: null,
+ w: 0,
+ h: 0,
+ key: "",
+};
+
+function backgroundCacheKey(world, lighting) {
+ const weather = world.weather || "sunny";
+ const field = world.fieldType || "garden";
+ const perf = world.performanceLevel ? world.performanceLevel() : 0;
+ const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180;
+ const progressBucket = Math.round((lighting.progress || 0) * step);
+ const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2));
+ return `${field}:${weather}:${progressBucket}:${weatherBucket}`;
+}
+
+function ensureBackgroundCache(w, h, lighting) {
+ const key = backgroundCacheKey(world, lighting);
+ if (!backgroundCache.canvas) {
+ backgroundCache.canvas = document.createElement("canvas");
+ backgroundCache.ctx = backgroundCache.canvas.getContext("2d");
+ }
+ if (backgroundCache.w === w && backgroundCache.h === h && backgroundCache.key === key) {
+ return backgroundCache.canvas;
+ }
+
+ backgroundCache.w = w;
+ backgroundCache.h = h;
+ backgroundCache.key = key;
+ backgroundCache.canvas.width = Math.max(1, Math.floor(w));
+ backgroundCache.canvas.height = Math.max(1, Math.floor(h));
+
+ const mainCtx = activeCtx;
+ try {
+ activeCtx = backgroundCache.ctx;
+ drawGardenBackdrop(w, h, lighting);
+ drawGardenBed(w, h, lighting);
+ return backgroundCache.canvas;
+ } catch (err) {
+ console.error("background cache redraw failed", err);
+ backgroundCache.key = "";
+ return null;
+ } finally {
+ activeCtx = mainCtx;
+ }
+}
+
+function drawGardenBackdrop(w, h, lighting) {
+ const light = lighting.light;
+ const sky = activeCtx.createLinearGradient(0, 0, 0, h);
+ const topColor = light > 0.5 ? `rgba(${Math.round(142 + light * 62 + lighting.goldenStrength * 24)}, ${Math.round(198 + light * 34 + lighting.goldenStrength * 10)}, ${Math.round(238 + light * 15 - lighting.goldenStrength * 24)}, 1)` : `rgba(${Math.round(42 + light * 78)}, ${Math.round(55 + light * 98)}, ${Math.round(95 + light * 124)}, 1)`;
+ const midColor = light > 0.5 ? `rgba(${Math.round(214 + light * 28 + lighting.goldenStrength * 24)}, ${Math.round(235 + light * 12 + lighting.goldenStrength * 4)}, ${Math.round(246 - light * 8 - lighting.goldenStrength * 30)}, 1)` : `rgba(${Math.round(78 + light * 90)}, ${Math.round(88 + light * 108)}, ${Math.round(128 + light * 104)}, 1)`;
+ const bottomColor = light > 0.5 ? `rgba(${Math.round(230 + lighting.goldenStrength * 18)}, ${Math.round(226 - lighting.goldenStrength * 10)}, ${Math.round(202 - lighting.goldenStrength * 22)}, 1)` : "rgba(74, 84, 119, 1)";
+ sky.addColorStop(0, topColor);
+ sky.addColorStop(0.48, midColor);
+ sky.addColorStop(1, bottomColor);
+ activeCtx.fillStyle = sky;
+ activeCtx.fillRect(0, 0, w, h);
+
+ for (let i = 0; i < 2; i++) {
+ const cx = randSeed(300 + i, 80, w - 80);
+ const cy = randSeed(420 + i, 28, h * 0.24);
+ const alpha = light > 0.4 ? 0.09 + lighting.noonStrength * 0.04 : 0.04;
+ activeCtx.save();
+ activeCtx.fillStyle = `rgba(255,255,255,${alpha})`;
+ for (let j = 0; j < 3; j++) {
+ activeCtx.beginPath();
+ activeCtx.ellipse(cx + j * 22, cy + Math.sin(j) * 5, 34, 18, 0, 0, Math.PI * 2);
+ activeCtx.fill();
+ }
+ activeCtx.restore();
+ }
+
+ if (light < 0.22) {
+ activeCtx.save();
+ activeCtx.fillStyle = `rgba(255,255,255,${0.62 + lighting.nightStrength * 0.30})`;
+ for (let i = 0; i < 26; i++) {
+ const sx = randSeed(i + Math.floor(world.time * 0.1), 40, w - 40);
+ const sy = randSeed(i + Math.floor(world.time * 0.3) + 12, 26, h * 0.34);
+ const size = randSeed(i + 910, 1.2, 2.4);
+ activeCtx.fillRect(sx, sy, size, size);
+ }
+ activeCtx.restore();
+ }
+
+ if (lighting.bloom > 0.08) {
+ const sunX = w * (0.15 + (1 - lighting.progress) * 0.70);
+ const sunY = h * (0.12 + (1 - lighting.elevation) * 0.22);
+ drawRadialGlow(activeCtx, sunX, sunY, Math.max(w, h) * (0.24 + lighting.goldenStrength * 0.10), "rgba(255, 220, 138, ALPHA)", lighting.bloom * 0.10);
+ }
+
+ if (world.weather === "cloudy") {
+ activeCtx.fillStyle = "rgba(218, 223, 214, 0.16)";
+ activeCtx.fillRect(0, 0, w, h);
+ } else if (world.weather === "light_rain") {
+ activeCtx.fillStyle = "rgba(178, 197, 208, 0.22)";
+ activeCtx.fillRect(0, 0, w, h);
+ }
+}
+
+function drawGardenBed(w, h, lighting) {
+ const light = lighting.light;
+ const fieldType = world.fieldType || "garden";
+ const x = 18, y = 22, bw = w - 36, bh = h - 44;
+ const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
+ const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
+ const borderBottom = fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
+ border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72");
+ border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68");
+ activeCtx.fillStyle = border;
+ roundedRect(activeCtx, x, y, bw, bh, 34);
+ activeCtx.fill();
+
+ activeCtx.save();
+ activeCtx.globalAlpha = 0.14;
+ activeCtx.strokeStyle = "rgba(255,255,255,0.85)";
+ activeCtx.lineWidth = 2;
+ roundedRect(activeCtx, x + 6, y + 6, bw - 12, bh - 12, 28);
+ activeCtx.stroke();
+ activeCtx.restore();
+
+ const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14);
+ const palette = fieldType === "park"
+ ? ["#cfe5a8", "#b8d990", "#91bd74"]
+ : fieldType === "cage"
+ ? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
+ : [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
+ soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6");
+ soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd");
+ soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af");
+ activeCtx.fillStyle = soil;
+ roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
+ activeCtx.fill();
+
+ activeCtx.save();
+ activeCtx.beginPath();
+ roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
+ activeCtx.clip();
+
+ const speckCount = fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
+ for (let i = 0; i < speckCount; i++) {
+ const px = randSeed(900 + i, x + 24, x + bw - 24);
+ const py = randSeed(1200 + i, y + 22, y + bh - 24);
+ const r = randSeed(1600 + i, 1.2, 3.8);
+ const a = randSeed(1900 + i, 0.03, 0.09) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70);
+ activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`;
+ activeCtx.beginPath();
+ activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2);
+ activeCtx.fill();
+ }
+
+ if (fieldType === "cage") {
+ activeCtx.save();
+ activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)";
+ activeCtx.lineWidth = 1.1;
+ for (let gx = x + 44; gx < x + bw - 26; gx += 44) {
+ activeCtx.beginPath(); activeCtx.moveTo(gx, y + 20); activeCtx.lineTo(gx, y + bh - 20); activeCtx.stroke();
+ }
+ for (let gy = y + 44; gy < y + bh - 24; gy += 44) {
+ activeCtx.beginPath(); activeCtx.moveTo(x + 22, gy); activeCtx.lineTo(x + bw - 22, gy); activeCtx.stroke();
+ }
+ activeCtx.restore();
+ } else if (fieldType === "park") {
+ activeCtx.save();
+ activeCtx.globalAlpha = light > 0.42 ? 0.24 : 0.14;
+ for (let i = 0; i < 24; i++) {
+ activeCtx.fillStyle = i % 2 ? "rgba(74, 132, 58, 0.35)" : "rgba(238, 222, 112, 0.32)";
+ activeCtx.beginPath();
+ activeCtx.ellipse(randSeed(5400 + i, x + 34, x + bw - 34), randSeed(5600 + i, y + 36, y + bh - 36), randSeed(5800 + i, 3, 8), randSeed(6000 + i, 2, 5), randSeed(6200 + i, -0.8, 0.8), 0, Math.PI * 2);
+ activeCtx.fill();
+ }
+ activeCtx.restore();
+ }
+
+ const patchCount = fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
+ for (let i = 0; i < patchCount; i++) {
+ const px = randSeed(3300 + i, x + 40, x + bw - 40);
+ const py = randSeed(3600 + i, y + 44, y + bh - 46);
+ const rw = randSeed(3900 + i, 24, 64);
+ const rh = randSeed(4200 + i, 12, 28);
+ activeCtx.fillStyle = light > 0.42 ? `rgba(161, 186, 118, ${0.07 + lighting.goldenStrength * 0.05})` : "rgba(118, 141, 132, 0.06)";
+ activeCtx.beginPath();
+ activeCtx.ellipse(px, py, rw, rh, randSeed(4500 + i, -1, 1), 0, Math.PI * 2);
+ activeCtx.fill();
+ }
+
+ // edge grasses and tiny flowers
+ const edgeGrassCount = fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
+ for (let i = 0; i < edgeGrassCount; i++) {
+ const side = i % 2;
+ const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
+ const py = side === 0 ? y + 20 + randSeed(5700 + i, -4, 8) : y + bh - 24 + randSeed(6000 + i, -8, 4);
+ activeCtx.save();
+ activeCtx.translate(px, py);
+ activeCtx.strokeStyle = light > 0.34 ? `rgba(101, 157, 68, ${0.38 + lighting.goldenStrength * 0.16})` : "rgba(97, 126, 128, 0.25)";
+ activeCtx.lineWidth = 1.4;
+ for (let j = 0; j < 3; j++) {
+ const len = randSeed(6300 + i * 5 + j, 7, 14);
+ const dir = randSeed(6600 + i * 5 + j, -0.65, 0.65);
+ activeCtx.beginPath();
+ activeCtx.moveTo(0, 0);
+ activeCtx.quadraticCurveTo(Math.sin(dir) * len * 0.35, -len * 0.45, Math.sin(dir) * len, -len);
+ activeCtx.stroke();
+ }
+ activeCtx.restore();
+ }
+
+ const flowerCount = fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
+ for (let i = 0; i < flowerCount; i++) {
+ const px = randSeed(7000 + i, x + 44, x + bw - 44);
+ const py = randSeed(7400 + i, y + 46, y + bh - 44);
+ if (i % 3 === 0) {
+ activeCtx.save();
+ activeCtx.translate(px, py);
+ activeCtx.globalAlpha = 0.30;
+ for (let p = 0; p < 4; p++) {
+ activeCtx.fillStyle = p === 3 ? "rgba(248, 204, 82, 0.75)" : "rgba(255,255,255,0.80)";
+ activeCtx.beginPath();
+ if (p === 3) activeCtx.arc(0, 0, 1.6, 0, Math.PI * 2);
+ else activeCtx.ellipse(Math.cos((p / 3) * Math.PI * 2) * 2.2, Math.sin((p / 3) * Math.PI * 2) * 2.2, 1.8, 1.3, 0, 0, Math.PI * 2);
+ activeCtx.fill();
+ }
+ activeCtx.restore();
+ }
+ }
+
+ const leafCount = fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
+ for (let i = 0; i < leafCount; i++) {
+ const px = randSeed(7800 + i, x + 36, x + bw - 36);
+ const py = randSeed(8200 + i, y + 34, y + bh - 34);
+ if (i % 2 === 0) {
+ activeCtx.save();
+ activeCtx.translate(px, py);
+ activeCtx.rotate(randSeed(8500 + i, -1.3, 1.3));
+ activeCtx.fillStyle = light > 0.45 ? `rgba(160, 179, 126, ${0.22 + lighting.goldenStrength * 0.12})` : "rgba(126, 144, 139, 0.16)";
+ activeCtx.beginPath();
+ activeCtx.ellipse(-4, 0, 4.8, 2.2, -0.4, 0, Math.PI * 2);
+ activeCtx.ellipse(4, 0, 4.8, 2.2, 0.4, 0, Math.PI * 2);
+ activeCtx.ellipse(0, -4, 4.8, 2.2, 0, 0, Math.PI * 2);
+ activeCtx.fill();
+ activeCtx.restore();
+ } else {
+ activeCtx.save();
+ activeCtx.translate(px, py);
+ activeCtx.rotate(randSeed(8800 + i, -1.1, 1.1));
+ activeCtx.fillStyle = "rgba(158, 140, 108, 0.22)";
+ activeCtx.beginPath();
+ activeCtx.ellipse(0, 0, randSeed(9000 + i, 5, 9), randSeed(9300 + i, 2.2, 3.6), 0, 0, Math.PI * 2);
+ activeCtx.fill();
+ activeCtx.restore();
+ }
+ }
+
+ activeCtx.restore();
+
+ // subtle vignette
+ const vignette = activeCtx.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.22, w * 0.5, h * 0.5, Math.max(w, h) * 0.66);
+ vignette.addColorStop(0, "rgba(0,0,0,0)");
+ vignette.addColorStop(1, light > 0.4 ? `rgba(94, 70, 48, ${0.09 + lighting.goldenStrength * 0.06})` : `rgba(16, 22, 52, ${0.17 + lighting.nightStrength * 0.14})`);
+ activeCtx.fillStyle = vignette;
+ activeCtx.fillRect(0, 0, w, h);
+}
+
+function drawLightRays(ctx, w, h, lighting) {
+ if (lighting.quality !== "cinematic" || lighting.goldenStrength <= 0.08) return;
+ ctx.save();
+ ctx.globalCompositeOperation = "screen";
+ const rayAlpha = lighting.goldenStrength * LIGHTING_TUNING.rayAlpha;
+ const originX = w * (lighting.dawnStrength > lighting.duskStrength ? 0.08 : 0.90);
+ const originY = h * (0.02 + (1 - lighting.elevation) * 0.18);
+ for (let i = 0; i < 3; i++) {
+ const spread = (i - 1) * 0.20;
+ const g = ctx.createLinearGradient(originX, originY, w * (0.50 + spread), h * (0.58 + i * 0.06));
+ g.addColorStop(0, `rgba(255, 230, 170, ${rayAlpha * (1 - i * 0.10)})`);
+ g.addColorStop(0.30, `rgba(255, 198, 120, ${rayAlpha * 0.18})`);
+ g.addColorStop(1, "rgba(255, 188, 108, 0)");
+ ctx.fillStyle = g;
+ ctx.beginPath();
+ ctx.moveTo(originX, originY);
+ ctx.lineTo(w * (0.10 + i * 0.28), h);
+ ctx.lineTo(w * (0.42 + i * 0.28), h);
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.restore();
+}
+
+function drawAtmosphericLighting(ctx, w, h, lighting) {
+ ctx.save();
+ if (lighting.nightStrength > 0) {
+ ctx.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha;
+ ctx.fillStyle = "#223683";
+ ctx.fillRect(0, 0, w, h);
+ }
+ if (lighting.goldenStrength > 0) {
+ ctx.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha;
+ ctx.fillStyle = "#ffad68";
+ ctx.fillRect(0, 0, w, h);
+ }
+ if (lighting.noonStrength > 0) {
+ ctx.globalAlpha = lighting.noonStrength * 0.035;
+ ctx.fillStyle = "#fff8cf";
+ ctx.fillRect(0, 0, w, h);
+ }
+ ctx.restore();
+
+ if (lighting.quality === "cinematic") {
+ drawRadialGlow(ctx, w * 0.5, h * 0.46, Math.max(w, h) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha);
+ }
+
+ ctx.save();
+ const vignette = ctx.createRadialGradient(w * 0.5, h * 0.46, Math.min(w, h) * 0.18, w * 0.5, h * 0.48, Math.max(w, h) * 0.76);
+ vignette.addColorStop(0, "rgba(0,0,0,0)");
+ vignette.addColorStop(0.72, "rgba(0,0,0,0)");
+ vignette.addColorStop(1, `rgba(13, 17, 34, ${lighting.vignetteAlpha})`);
+ ctx.fillStyle = vignette;
+ ctx.fillRect(0, 0, w, h);
+ ctx.restore();
+}
+
+function drawRain(ctx, w, h, t) {
+ if (world.weather !== "light_rain") return;
+ ctx.save();
+ ctx.globalAlpha = 0.28;
+ ctx.strokeStyle = "rgba(197, 220, 230, 0.70)";
+ ctx.lineWidth = 1;
+ for (let i = 0; i < 34; i++) {
+ const x = (randSeed(18000 + i, 0, w) + t * 26) % w;
+ const y = (randSeed(18400 + i, 0, h) + t * 72) % h;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x - 4, y + 10);
+ ctx.stroke();
+ }
+ ctx.restore();
+}
+
+function weatherLabel(weather) {
+ return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c";
+}
+
+function render() {
+ activeCtx = ctx;
+ const sceneW = world.w, sceneH = world.h;
+ const screenW = world.viewportW || sceneW;
+ const screenH = world.viewportH || sceneH;
+ const fieldOffset = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
+ const cameraX = world.cameraX || 0;
+ const cameraY = world.cameraY || 0;
+ const viewScale = world.viewScale ? world.viewScale() : 1;
+ ctx.clearRect(0, 0, screenW, screenH);
+
+ const lighting = getLightingState(world);
+ const light = lighting.light;
+ const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
+
+ const beginFieldTransform = () => {
+ ctx.save();
+ ctx.translate(fieldOffset.x, fieldOffset.y);
+ ctx.scale(viewScale, viewScale);
+ ctx.translate(-cameraX, -cameraY);
+ };
+
+ beginFieldTransform();
+ if (cachedBackground) {
+ ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH);
+ } else {
+ drawGardenBackdrop(sceneW, sceneH, lighting);
+ drawGardenBed(sceneW, sceneH, lighting);
+ }
+ ctx.restore();
+
+ const perf = world.performanceLevel ? world.performanceLevel() : 0;
+ if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting);
+ if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
+
+ beginFieldTransform();
+ for (const it of world.items) {
+ if (it.type !== "zunchi") it.draw(ctx, world.time, lighting);
+ }
+ for (const it of world.items) {
+ if (it.type === "zunchi") it.draw(ctx, world.time, lighting);
+ }
+
+ ctx.save();
+ ctx.lineWidth = 1.15;
+ ctx.setLineDash([4, 4]);
+ ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)";
+ for (const t of world.tarinai) {
+ const p = t.parentToFollow?.();
+ if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96) continue;
+ ctx.beginPath();
+ ctx.moveTo(t.x, t.y);
+ ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y);
+ ctx.stroke();
+ }
+ ctx.setLineDash([]);
+ ctx.restore();
+
+ const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai;
+ for (const t of sorted) t.draw(ctx, world.time, lighting);
+ for (const ef of world.effects) ef.draw(ctx);
+ ctx.restore();
+
+ // Light affecting the whole visible scene, including characters and ground.
+ drawAtmosphericLighting(ctx, screenW, screenH, lighting);
+
+ const drawAtScreenPosition = (entity, drawFn) => {
+ if (!entity) return;
+ const ox = entity.x;
+ const oy = entity.y;
+ const screen = world.worldToScreen ? world.worldToScreen(ox, oy) : { x: ox, y: oy };
+ entity.x = screen.x;
+ entity.y = screen.y;
+ try {
+ drawFn();
+ } finally {
+ entity.x = ox;
+ entity.y = oy;
+ }
+ };
+ drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
+
+ // Time HUD
+ ctx.save();
+ const hudW = 150;
+ const hudH = 56;
+ const w = screenW, h = screenH;
+ const hudX = w - hudW - 22;
+ const hudY = 16;
+ ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)";
+ roundedRect(ctx, hudX, hudY, hudW, hudH, 18);
+ ctx.fill();
+ ctx.strokeStyle = light > 0.42 ? "rgba(118,103,83,0.12)" : "rgba(206,213,242,0.15)";
+ ctx.stroke();
+ ctx.fillStyle = "#2a241d";
+ ctx.font = "bold 16px ui-rounded, sans-serif";
+ ctx.textAlign = "left";
+ ctx.fillText(`${world.day}\u65e5\u76ee`, hudX + 24, hudY + 25);
+ ctx.font = "12px ui-rounded, sans-serif";
+ ctx.fillStyle = "rgba(111,101,88,0.86)";
+ ctx.fillText(`${world.phaseName()} / ${world.clockString()} / ${weatherLabel(world.weather)}`, hudX + 24, hudY + 45);
+ ctx.restore();
+
+ ctx.save();
+ ctx.textAlign = "right";
+ ctx.font = "10px ui-rounded, sans-serif";
+ ctx.fillStyle = light > 0.42 ? "rgba(84,72,55,0.42)" : "rgba(230,235,255,0.48)";
+ ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12);
+ ctx.restore();
+
+
+ if (world.paused) {
+ ctx.save();
+ ctx.fillStyle = "rgba(255,255,255,0.50)";
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = "rgba(42,36,29,0.70)";
+ ctx.textAlign = "center";
+ ctx.font = "bold 28px ui-rounded, sans-serif";
+ ctx.fillText("\u4e00\u6642\u505c\u6b62\u4e2d", w / 2, h / 2);
+ ctx.restore();
+ }
+}
+
+function drawSelectedCard(ctx, world, lighting) {
+ const t = world.selected;
+ if (!t || t.dead || !world.tarinai.includes(t)) return;
+ const w = world.viewportW || world.w;
+ const h = world.viewportH || world.h;
+ const cardW = 152;
+ const cardH = 66;
+ let x = t.x + 18;
+ let y = t.y - 86;
+ x = clamp(x, 10, w - cardW - 10);
+ y = clamp(y, 10, h - cardH - 10);
+ ctx.save();
+ ctx.fillStyle = lighting.light > 0.42 ? "rgba(255,250,239,0.82)" : "rgba(239,243,255,0.74)";
+ roundedRect(ctx, x, y, cardW, cardH, 14);
+ ctx.fill();
+ ctx.strokeStyle = "rgba(84,68,48,0.12)";
+ ctx.stroke();
+ ctx.fillStyle = "rgba(42,36,29,0.86)";
+ ctx.font = "bold 11px ui-rounded, sans-serif";
+ ctx.fillText(t.name, x + 10, y + 18);
+ ctx.font = "10px ui-rounded, sans-serif";
+ const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : "";
+ const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : "";
+ ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35);
+ const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : "";
+ ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52);
+ ctx.restore();
+}
+
+function roundedRect(ctx, x, y, w, h, r) {
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+}
+
+
+window.TarinaiRender = { randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, drawSelectedCard, render, roundedRect };
diff --git a/js/sim.js b/js/sim.js
new file mode 100644
index 0000000..7f68635
--- /dev/null
+++ b/js/sim.js
@@ -0,0 +1,3 @@
+"use strict";
+
+window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world };
diff --git a/js/sim_core.js b/js/sim_core.js
new file mode 100644
index 0000000..3e30e94
--- /dev/null
+++ b/js/sim_core.js
@@ -0,0 +1,306 @@
+"use strict";
+function makeName(worldRef = null) {
+ const existing = new Set((worldRef?.tarinai || []).filter(t => !t.dead).map(t => t.name));
+ const build = () => {
+ const len = randi(2, 7);
+ const hasStop = len >= 2 && Math.random() < 0.30;
+ const middleLen = Math.max(0, len - 1 - (hasStop ? 1 : 0));
+ let name = "\u306f";
+ for (let i = 0; i < middleLen; i++) name += Math.random() < 0.64 ? "\u3046" : "\u3045";
+ if (hasStop) name += "\u3063";
+ return name;
+ };
+ for (let i = 0; i < 80; i++) {
+ const candidate = build();
+ if (!existing.has(candidate)) return candidate;
+ }
+ return build();
+}
+
+function spawnableSprites() {
+ return SPRITES.filter(s => !s.actionOnly && !s.displayOnly);
+}
+
+function displayNormalSprites() {
+ return ["smile", "jito", "normal_smirk"].filter(id => SPRITES.some(s => s.id === id));
+}
+
+function lowStressSprites() {
+ return ["normal_tongue", "normal_happy"].filter(id => SPRITES.some(s => s.id === id));
+}
+
+function stableChoice(seed, salt, options) {
+ if (!options?.length) return null;
+ const i = Math.min(options.length - 1, Math.floor(stableUnit(seed, salt) * options.length));
+ return options[i];
+}
+
+function baseSpriteId() {
+ return pick(spawnableSprites()).id;
+}
+
+function makeTrait(type) {
+ const base = {
+ smile: { hunger: 0.92, social: 1.14, sleep: 1.00, stress: 0.82, speed: 1.02 },
+ angry: { hunger: 1.05, social: 0.84, sleep: 0.94, stress: 1.24, speed: 1.15 },
+ teary: { hunger: 0.96, social: 1.22, sleep: 1.04, stress: 1.12, speed: 0.96 },
+ jito: { hunger: 0.88, social: 0.80, sleep: 0.96, stress: 0.80, speed: 0.98 },
+ drool: { hunger: 1.30, social: 1.02, sleep: 1.04, stress: 0.98, speed: 1.00 },
+ cry: { hunger: 1.00, social: 1.30, sleep: 1.12, stress: 1.36, speed: 0.90 },
+ sleep: { hunger: 0.78, social: 0.88, sleep: 1.44, stress: 0.72, speed: 0.70 },
+ pokan: { hunger: 1.02, social: 1.02, sleep: 1.02, stress: 1.06, speed: 0.88 },
+ stretch: { hunger: 1.08, social: 0.90, sleep: 0.92, stress: 1.08, speed: 1.26 },
+ }[type] || { hunger: 1, social: 1, sleep: 1, stress: 1, speed: 1 };
+ return { ...base };
+}
+
+function stableUnit(seed, salt = "") {
+ const str = `${seed}:${salt}`;
+ let h = 2166136261;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return ((h >>> 0) % 100000) / 100000;
+}
+
+const PERSONALITIES = {
+ timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00 },
+ glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42 },
+ sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92 },
+ lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00 },
+ irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94 },
+ calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94 },
+};
+const PERSONALITY_IDS = Object.keys(PERSONALITIES);
+
+function personalityForSeed(seed) {
+ return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm";
+}
+
+function relationDefaults() {
+ return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 };
+}
+
+function isParentChild(a, b) {
+ if (!a || !b) return false;
+ return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id));
+}
+
+function relationDisplayName(worldRef, id) {
+ if (!id) return "\u306a\u3057";
+ const live = worldRef?.tarinai?.find(t => t.id === id);
+ if (live) return live.name;
+ const fam = worldRef?.family?.[id];
+ return fam?.name || "\u4e0d\u660e";
+}
+
+class Effect {
+ constructor(type, x, y, options = {}) {
+ this.type = type;
+ this.x = x;
+ this.y = y;
+ this.vx = options.vx ?? rand(-10, 10);
+ this.vy = options.vy ?? rand(-10, 10);
+ this.size = options.size ?? rand(3, 8);
+ this.life = options.life ?? 0.7;
+ this.maxLife = this.life;
+ this.color = options.color || "#fff4a8";
+ this.seed = Math.random() * 1000;
+ this.text = options.text || "";
+ }
+
+ update(dt) {
+ this.life -= dt;
+ this.x += this.vx * dt;
+ this.y += this.vy * dt;
+ this.vx *= Math.pow(0.92, dt * 60);
+ this.vy *= Math.pow(0.92, dt * 60);
+ }
+ get dead() { return this.life <= 0; }
+
+ draw(ctx) {
+ const alpha = clamp(this.life / this.maxLife, 0, 1);
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.translate(this.x, this.y);
+ if (this.type === "eat") {
+ ctx.fillStyle = this.color;
+ ctx.strokeStyle = "rgba(143, 111, 43, 0.7)";
+ ctx.lineWidth = 1.2;
+ ctx.beginPath();
+ ctx.arc(0, 0, this.size, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ } else if (this.type === "bleed") {
+ ctx.fillStyle = this.color;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (this.type === "ring") {
+ ctx.strokeStyle = this.color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2);
+ ctx.stroke();
+ } else if (this.type === "fight") {
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ const puffColor = this.color || "rgba(105, 62, 34, 0.86)";
+ ctx.fillStyle = puffColor;
+ ctx.strokeStyle = puffColor;
+ for (let i = 0; i < 4; i++) {
+ const a = this.seed + i * 1.55;
+ const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.10, 0.72);
+ const py = Math.sin(a) * this.size * randSeed(this.seed + i + 8, 0.08, 0.48);
+ ctx.globalAlpha = alpha * (0.26 + i * 0.08);
+ ctx.beginPath();
+ ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.18, 0.34), this.size * randSeed(this.seed + i + 40, 0.10, 0.22), a, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = alpha * 0.78;
+ ctx.lineWidth = 2.0;
+ for (let i = 0; i < 2; i++) {
+ const dir = i === 0 ? -1 : 1;
+ const y = dir * this.size * 0.14;
+ ctx.beginPath();
+ ctx.moveTo(-this.size * 0.62, y);
+ ctx.quadraticCurveTo(-this.size * 0.12, y - dir * this.size * 0.42, this.size * 0.54, y + dir * this.size * 0.06);
+ ctx.stroke();
+ }
+ } else if (this.type === "fall") {
+ ctx.fillStyle = this.color || "rgba(154, 124, 80, 0.62)";
+ for (let i = 0; i < 5; i++) {
+ const a = this.seed + i * 1.37;
+ const rr = this.size * randSeed(this.seed + i, 0.18, 0.82);
+ const px = Math.cos(a) * rr;
+ const py = Math.sin(a) * rr * 0.42;
+ ctx.globalAlpha = alpha * randSeed(this.seed + i + 9, 0.18, 0.42);
+ ctx.beginPath();
+ ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 20, 0.16, 0.34), this.size * randSeed(this.seed + i + 30, 0.08, 0.18), a, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ } else if (this.type === "explosion") {
+ const burst = 1 - alpha;
+ ctx.globalAlpha = alpha * 0.95;
+ for (let i = 0; i < 3; i++) {
+ ctx.strokeStyle = i === 0 ? "rgba(255,238,145,0.92)" : (i === 1 ? "rgba(255,128,66,0.68)" : "rgba(96,62,42,0.42)");
+ ctx.lineWidth = Math.max(2, this.size * (0.09 - i * 0.018));
+ ctx.beginPath();
+ ctx.arc(0, 0, this.size * (0.28 + burst * (0.78 + i * 0.22)), 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ ctx.fillStyle = "rgba(255,214,86,0.88)";
+ for (let i = 0; i < 14; i++) {
+ const a = this.seed + i * 0.73;
+ const r = this.size * burst * randSeed(this.seed + i, 0.28, 1.00);
+ ctx.globalAlpha = alpha * randSeed(this.seed + i + 4, 0.36, 0.88);
+ ctx.beginPath();
+ ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ } else if (this.type === "bubble") {
+ const w = Math.max(30, 14 + this.text.length * 7);
+ const h = 20;
+ ctx.globalAlpha = Math.min(1, alpha * 1.25);
+ ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
+ ctx.strokeStyle = "rgba(78, 64, 48, 0.22)";
+ ctx.lineWidth = 1.2;
+ roundedRect(ctx, -w / 2, -h, w, h, 9);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
+ ctx.beginPath();
+ ctx.moveTo(-4, -1);
+ ctx.lineTo(2, 5);
+ ctx.lineTo(6, -2);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = this.color || "rgba(42,36,29,0.78)";
+ ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(this.text, 0, -10);
+ }
+ ctx.restore();
+ }
+}
+
+class SpatialGrid {
+ constructor(cellSize = 96) {
+ this.cellSize = cellSize;
+ this.itemCells = new Map();
+ this.tarinaiCells = new Map();
+ }
+
+ clear() {
+ this.itemCells.clear();
+ this.tarinaiCells.clear();
+ }
+
+ keyFor(x, y) {
+ const cx = Math.floor(x / this.cellSize);
+ const cy = Math.floor(y / this.cellSize);
+ return cx + cy * 100000;
+ }
+
+ add(map, entity) {
+ const key = this.keyFor(entity.x, entity.y);
+ let bucket = map.get(key);
+ if (!bucket) {
+ bucket = [];
+ map.set(key, bucket);
+ }
+ bucket.push(entity);
+ }
+
+ rebuild(items, tarinai) {
+ this.clear();
+ for (const it of items) {
+ if (!it.dead) this.add(this.itemCells, it);
+ }
+ for (const t of tarinai) {
+ if (!t.dead) this.add(this.tarinaiCells, t);
+ }
+ }
+
+ nearby(map, x, y, radius, out = []) {
+ out.length = 0;
+ const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
+ const minX = Math.floor((x - r) / this.cellSize);
+ const maxX = Math.floor((x + r) / this.cellSize);
+ const minY = Math.floor((y - r) / this.cellSize);
+ const maxY = Math.floor((y + r) / this.cellSize);
+ for (let cy = minY; cy <= maxY; cy++) {
+ for (let cx = minX; cx <= maxX; cx++) {
+ const bucket = map.get(cx + cy * 100000);
+ if (!bucket) continue;
+ for (const entity of bucket) out.push(entity);
+ }
+ }
+ return out;
+ }
+}
+
+function drawHeartShape(ctx, x, y, size, opts = {}) {
+ const fill = opts.fill || "rgba(240,91,135,0.90)";
+ const stroke = opts.stroke || "rgba(255,252,246,0.92)";
+ const alpha = opts.alpha == null ? 1 : opts.alpha;
+ const rotation = opts.rotation || 0;
+ ctx.save();
+ ctx.translate(x, y);
+ if (rotation) ctx.rotate(rotation);
+ ctx.globalAlpha *= alpha;
+ ctx.fillStyle = fill;
+ ctx.strokeStyle = stroke;
+ ctx.lineWidth = Math.max(1.2, size * 0.14);
+ ctx.beginPath();
+ ctx.moveTo(0, size * 0.72);
+ ctx.bezierCurveTo(-size * 1.55, -size * 0.22, -size * 1.16, -size * 1.36, 0, -size * 0.52);
+ ctx.bezierCurveTo(size * 1.16, -size * 1.36, size * 1.55, -size * 0.22, 0, size * 0.72);
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+}
+
diff --git a/js/tarinai.js b/js/tarinai.js
new file mode 100644
index 0000000..98df229
--- /dev/null
+++ b/js/tarinai.js
@@ -0,0 +1,1448 @@
+"use strict";
+
+class Tarinai {
+ constructor(world, opts = {}) {
+ this.world = world;
+ this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
+ this.name = opts.name || makeName(world);
+ this.personality = opts.personality || personalityForSeed(this.id);
+ this.type = opts.type || baseSpriteId();
+ const typeMeta = SPRITES.find(s => s.id === this.type);
+ if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId();
+ this.trait = makeTrait(this.type);
+ const pdef = PERSONALITIES[this.personality] || PERSONALITIES.calm;
+ this.trait.social *= pdef.social ?? 1;
+ this.trait.stress /= pdef.fear ?? 1;
+ this.trait.sleep *= pdef.sleep ?? 1;
+ this.x = opts.x ?? rand(100, world.w - 100);
+ this.y = opts.y ?? rand(100, world.h - 100);
+ this.vx = opts.vx ?? rand(-12, 12);
+ this.vy = opts.vy ?? rand(-12, 12);
+ this.scale = opts.scale ?? rand(0.22, 0.33);
+ this.radius = 56 * this.scale;
+ this.age = opts.age ?? rand(0, 20);
+ this.lifeSpan = opts.lifeSpan ?? rand(1400, 2200);
+ if (this.lifeSpan < 1200) this.lifeSpan = rand(1400, 2200);
+ this.generation = opts.generation ?? 1;
+ this.hasPaired = opts.hasPaired ?? false;
+ this.hunger = opts.hunger ?? rand(16, 45);
+ this.loneliness = opts.loneliness ?? rand(18, 62);
+ this.energy = opts.energy ?? rand(54, 98);
+ this.stress = opts.stress ?? rand(0, 20);
+ this.hpBarTimer = opts.hpBarTimer ?? 0;
+ this.affection = opts.affection ?? rand(0, 30);
+ this.need = opts.need || pick(NEEDS);
+ this.state = opts.state || "idle";
+ this.dead = false;
+ this.deathReason = "";
+ this.thought = "";
+ this.target = null;
+ this.targetKey = "";
+ this.targetSince = 0;
+ this.targetBestDist = Infinity;
+ this.targetGiveupKey = opts.targetGiveupKey || "";
+ this.targetGiveupUntil = opts.targetGiveupUntil ?? 0;
+ this.panicTarget = opts.panicTarget || null;
+ this.panicTargetUntil = opts.panicTargetUntil ?? 0;
+ this.panicStuckTimer = opts.panicStuckTimer ?? 0;
+ this.wanderAngle = rand(0, Math.PI * 2);
+ this.reproductionTimer = rand(4, CONFIG.reproductionCooldown);
+ this.lastLog = 0;
+ this.sleeping = false;
+ this.blink = rand(0, 10);
+ this.lastSocial = 0;
+ this.goodMode = opts.goodMode ?? stableChoice(this.id, "good-mode", displayNormalSprites()) ?? "smile";
+ this.facing = opts.facing ?? (stableUnit(this.id, "facing") < 0.5 ? -1 : 1);
+ this.visualFacing = opts.visualFacing ?? this.facing;
+ this.facingLockUntil = opts.facingLockUntil ?? 0;
+ this.eatTimer = opts.eatTimer ?? 0;
+ this.eatCooldown = 0;
+ this.foodReactTimer = opts.foodReactTimer ?? 0;
+ this.surpriseTimer = opts.surpriseTimer ?? 0;
+ this.fearTimer = opts.fearTimer ?? 0;
+ this.intimidateTimer = opts.intimidateTimer ?? 0;
+ this.intimidateTargetId = opts.intimidateTargetId || null;
+ this.intimidatedTimer = opts.intimidatedTimer ?? 0;
+ this.birthRitualTimer = opts.birthRitualTimer ?? 0;
+ this.birthRitualMax = opts.birthRitualMax ?? 0;
+ this.birthPartnerId = opts.birthPartnerId || null;
+ this.birthRitualRole = opts.birthRitualRole || 0;
+ this.birthRitualLeader = !!opts.birthRitualLeader;
+ this.pokeFlashTimer = opts.pokeFlashTimer ?? 0;
+ this.zunchiStain = 0;
+ this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain");
+ this.tempComfort = opts.tempComfort ?? 0.62;
+ this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55;
+ this.parents = opts.parents || [];
+ this.parentNames = opts.parentNames || [];
+ this.children = opts.children || [];
+ this.birthTime = opts.birthTime ?? this.world.time;
+ this.nextEatSound = opts.nextEatSound ?? 0;
+ this.fightTimer = opts.fightTimer ?? 0;
+ this.fightCooldown = opts.fightCooldown ?? rand(0, CONFIG.fightCooldown);
+ this.fightTargetId = opts.fightTargetId || null;
+ this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []);
+ this.nextHeadbutt = opts.nextHeadbutt ?? 0;
+ this.digest = opts.digest ?? rand(0, 2.4);
+ this.poopCount = opts.poopCount ?? 0;
+ this.stretchTimer = opts.stretchTimer ?? 0;
+ this.lastMealColor = opts.lastMealColor || "#f7cf67";
+ this.grassEatTimer = opts.grassEatTimer ?? 0;
+ this.hurtTimer = opts.hurtTimer ?? 0;
+ this.defeatedTimer = opts.defeatedTimer ?? 0;
+ this.defeatedById = opts.defeatedById || null;
+ this.fightWinnerId = opts.fightWinnerId || null;
+ this.relationships = opts.relationships && typeof opts.relationships === "object" ? JSON.parse(JSON.stringify(opts.relationships)) : {};
+ this.fallTimer = opts.fallTimer ?? 0;
+ this.fallMax = opts.fallMax ?? 1.15;
+ this.fallDir = opts.fallDir ?? (stableUnit(this.id, "fall-dir") < 0.5 ? -1 : 1);
+ this.blastSpinTimer = opts.blastSpinTimer ?? 0;
+ this.blastSpinMax = opts.blastSpinMax ?? 0;
+ this.postBirthPeaceTimer = opts.postBirthPeaceTimer ?? 0;
+ this.sleepStart = 0.60 + stableUnit(this.id, "sleep-start") * 0.22;
+ this.sleepEnd = 0.15 + stableUnit(this.id, "sleep-end") * 0.14;
+ this.sleepFacing = stableUnit(this.id, "sleep-facing") < 0.5 ? -1 : 1;
+ this.nextBubbleAt = 0;
+ this.nextSleepBubbleAt = 0;
+ this.nextFearBubbleAt = 0;
+ this.aiTimer = opts.aiTimer ?? stableUnit(this.id, "ai-start") * 0.22;
+ this.envTimer = opts.envTimer ?? stableUnit(this.id, "env-start") * 0.55;
+ this.visibleSpriteId = opts.visibleSpriteId || null;
+ this.spriteLockUntil = opts.spriteLockUntil ?? 0;
+ }
+
+ serialize() {
+ return {
+ id: this.id, name: this.name, type: this.type, x: this.x, y: this.y, vx: this.vx, vy: this.vy,
+ scale: this.scale, age: this.age, lifeSpan: this.lifeSpan, generation: this.generation, hasPaired: this.hasPaired,
+ hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer,
+ affection: this.affection, need: this.need, state: this.state,
+ goodMode: this.goodMode, facing: this.facing, visualFacing: this.visualFacing, facingLockUntil: this.facingLockUntil, eatTimer: this.eatTimer,
+ foodReactTimer: this.foodReactTimer, surpriseTimer: this.surpriseTimer, fearTimer: this.fearTimer,
+ intimidateTimer: this.intimidateTimer, intimidateTargetId: this.intimidateTargetId, intimidatedTimer: this.intimidatedTimer,
+ birthRitualTimer: this.birthRitualTimer, birthRitualMax: this.birthRitualMax, birthPartnerId: this.birthPartnerId,
+ birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader,
+ pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed,
+ tempComfort: this.tempComfort, tempTimer: this.tempTimer,
+ parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime,
+ fightTimer: this.fightTimer, fightCooldown: this.fightCooldown, fightTargetId: this.fightTargetId, fightTargetIds: this.fightTargetIds, targetGiveupKey: this.targetGiveupKey, targetGiveupUntil: this.targetGiveupUntil,
+ panicTarget: this.panicTarget, panicTargetUntil: this.panicTargetUntil, panicStuckTimer: this.panicStuckTimer,
+ nextHeadbutt: this.nextHeadbutt, digest: this.digest, poopCount: this.poopCount, stretchTimer: this.stretchTimer,
+ grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer,
+ defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId,
+ relationships: this.relationships, personality: this.personality, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir,
+ blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer,
+ lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer,
+ };
+ }
+
+ static from(world, o) {
+ return new Tarinai(world, o);
+ }
+
+ get lack() {
+ return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100);
+ }
+
+ get mood() {
+ return clamp(100 - (this.hunger * 0.40 + this.loneliness * 0.20 + this.stress * 0.40), 0, 100);
+ }
+
+ get ageSinceBirth() {
+ return Math.max(0, this.world.time - this.birthTime);
+ }
+
+ get growth() {
+ return clamp(this.ageSinceBirth / CONFIG.childGrowTime, 0, 1);
+ }
+
+ get juvenile() {
+ return this.generation > 1 && this.growth < 1;
+ }
+
+ parentToFollow() {
+ if (!this.juvenile || !this.parents?.length) return null;
+ let best = null, bestD = Infinity;
+ for (const id of this.parents) {
+ const p = this.world.tarinai.find(t => t.id === id && !t.dead);
+ if (!p) continue;
+ const d = dist(this, p);
+ if (d < bestD) { best = p; bestD = d; }
+ }
+ return best;
+ }
+
+ label() {
+ const sp = SPRITES.find(s => s.id === this.type);
+ return sp ? sp.label : this.type;
+ }
+
+ personalityProfile() {
+ return PERSONALITIES[this.personality] || PERSONALITIES.calm;
+ }
+
+ personalityLabel() {
+ return this.personalityProfile().label || this.personality || "\u4e0d\u660e";
+ }
+
+ relationTo(id) {
+ if (!id) return relationDefaults();
+ if (!this.relationships) this.relationships = {};
+ if (!this.relationships[id]) this.relationships[id] = relationDefaults();
+ return this.relationships[id];
+ }
+
+ adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") {
+ if (!other || !other.id || other === this) return;
+ const rel = this.relationTo(other.id);
+ rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40);
+ rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40);
+ rel.fightsWon = rel.fightsWon || 0;
+ rel.fightsLost = rel.fightsLost || 0;
+ if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; }
+ }
+
+ strongestRelation(kind = "friend") {
+ let bestId = null;
+ let bestScore = kind === "fear" ? 5 : 8;
+ for (const [id, rel] of Object.entries(this.relationships || {})) {
+ const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0);
+ if (score > bestScore) { bestId = id; bestScore = score; }
+ }
+ return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null;
+ }
+
+ relationSummary() {
+ const friend = this.strongestRelation("friend");
+ const fear = this.strongestRelation("fear");
+ return { friend, fear };
+ }
+
+ bestFriendLive(minScore = 10, maxDist = Infinity) {
+ let best = null, bestScore = minScore;
+ for (const [id, rel] of Object.entries(this.relationships || {})) {
+ const score = rel.affinity || 0;
+ if (score <= bestScore) continue;
+ const t = this.world.tarinai.find(o => o.id === id && !o.dead);
+ if (!t) continue;
+ if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
+ best = t;
+ bestScore = score;
+ }
+ return best;
+ }
+
+ recentFightRival(maxAge = 52, maxDist = Infinity) {
+ let best = null, bestT = -Infinity;
+ for (const [id, rel] of Object.entries(this.relationships || {})) {
+ const event = String(rel.lastEvent || "");
+ const when = rel.lastTime || -Infinity;
+ if (this.world.time - when > maxAge) continue;
+ if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
+ const t = this.world.tarinai.find(o => o.id === id && !o.dead);
+ if (!t) continue;
+ if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
+ if (when > bestT) { best = t; bestT = when; }
+ }
+ return best;
+ }
+
+ poke() {
+ audio.poke();
+ this.damage(rand(9, 16), "\u3064\u3064\u304b\u308c\u3059\u304e");
+ this.stress = clamp(this.stress + 22, 0, 130);
+ this.surpriseTimer = 0.55;
+ this.fearTimer = Math.max(this.fearTimer, 1.2);
+ this.pokeFlashTimer = 0.55;
+ this.hurtTimer = Math.max(this.hurtTimer, 2.4);
+ this.vx += rand(-100, 100);
+ this.vy += rand(-100, 100);
+ if (this.energy <= 0.5) this.die("\u3064\u3064\u304b\u308c\u3059\u304e");
+ }
+
+
+ showHpBar(duration = 4.8) {
+ this.hpBarTimer = Math.max(this.hpBarTimer || 0, duration);
+ }
+
+ damage(amount, reason = "") {
+ const before = this.energy;
+ this.energy = clamp(this.energy - Math.max(0, amount || 0), 0, 100);
+ if (this.energy < before - 0.01) this.showHpBar();
+ if (this.energy <= 0.5 && reason) this.die(reason);
+ }
+
+ recoverHealth(amount) {
+ if (this.dead) return;
+ const before = this.energy;
+ this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100);
+ if (this.energy > before + 0.01 && before < 99.5) this.showHpBar(2.4);
+ }
+
+ familyJoined() {
+ return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length);
+ }
+
+ displayGeneration() {
+ return this.familyJoined() ? this.generation : null;
+ }
+
+ targetIdentity(target = this.target) {
+ if (!target) return "";
+ if (target.id) return `${target.type || "tarinai"}:${target.id}`;
+ if (target.type) return `${target.type}:${Math.round(target.x || 0)}:${Math.round(target.y || 0)}`;
+ if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `point:${Math.round(target.x / 8)}:${Math.round(target.y / 8)}`;
+ return "";
+ }
+
+ targetIgnoresFence(target = this.target) {
+ return Boolean(target?.type === "sweet" || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy");
+ }
+
+ shouldAvoidTarget(target) {
+ if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) return false;
+ if (this.targetIgnoresFence(target)) return false;
+ const key = this.targetIdentity(target);
+ if (key && this.targetGiveupKey === key && this.world.time < (this.targetGiveupUntil || 0)) return true;
+ return this.world.pathBlockedByFence ? this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10) : false;
+ }
+
+ maintainTargetProgress(dt) {
+ const target = this.target;
+ if (!target || target.dead || !Number.isFinite(target.x) || !Number.isFinite(target.y)) {
+ this.targetKey = "";
+ this.targetSince = this.world.time;
+ this.targetBestDist = Infinity;
+ return;
+ }
+ if (this.targetIgnoresFence(target)) return;
+ const key = this.targetIdentity(target);
+ const d = distXY(this.x, this.y, target.x, target.y);
+ if (key !== this.targetKey) {
+ this.targetKey = key;
+ this.targetSince = this.world.time;
+ this.targetBestDist = d;
+ } else if (d < (this.targetBestDist || Infinity) - 8) {
+ this.targetBestDist = d;
+ this.targetSince = this.world.time;
+ }
+ if (this.world.pathBlockedByFence && this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10)) {
+ this.targetGiveupKey = key;
+ this.targetGiveupUntil = this.world.time + 8;
+ this.target = null;
+ this.state = "idle";
+ this.wanderAngle += rand(-1.4, 1.4);
+ return;
+ }
+ if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) {
+ this.targetGiveupKey = key;
+ this.targetGiveupUntil = this.world.time + 12;
+ this.target = null;
+ this.state = "idle";
+ this.wanderAngle += rand(-2.2, 2.2);
+ this.thought = "\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f";
+ }
+ }
+
+ panicDestination(threat = null, force = false) {
+ const now = this.world.time || 0;
+ const current = this.panicTarget;
+ if (!force && current && now < (this.panicTargetUntil || 0) && distXY(this.x, this.y, current.x, current.y) > 34) return current;
+ let ax = Math.cos(this.wanderAngle || 0);
+ let ay = Math.sin(this.wanderAngle || 0);
+ if (threat && Number.isFinite(threat.x) && Number.isFinite(threat.y)) {
+ const dx = this.x - threat.x;
+ const dy = this.y - threat.y;
+ const d = Math.hypot(dx, dy) || 1;
+ ax = dx / d;
+ ay = dy / d;
+ }
+ const turn = rand(-0.75, 0.75);
+ const cos = Math.cos(turn);
+ const sin = Math.sin(turn);
+ const dirX = ax * cos - ay * sin;
+ const dirY = ax * sin + ay * cos;
+ const distance = rand(150, 250);
+ const p = CONFIG.worldPadding + this.radius;
+ let x = clamp(this.x + dirX * distance, p, this.world.w - p);
+ let y = clamp(this.y + dirY * distance, p, this.world.h - p);
+ if (distXY(this.x, this.y, x, y) < 70) {
+ x = clamp(this.x - dirY * distance, p, this.world.w - p);
+ y = clamp(this.y + dirX * distance, p, this.world.h - p);
+ }
+ this.panicTarget = { x, y, dead: false, detour: true, panic: true };
+ this.panicTargetUntil = now + rand(1.6, 3.0);
+ this.panicStuckTimer = 0;
+ this.wanderAngle = Math.atan2(y - this.y, x - this.x);
+ return this.panicTarget;
+ }
+
+ lowHealthSprite() {
+ const critical = this.lack > 96 || this.hunger > 112 || this.energy < 7 || this.stress > 118;
+ if (critical) return this.stretchTimer > 0 ? "stretch" : "weak";
+ const weak = this.lack > 88 || this.hunger > 102 || this.energy < 18 || this.stress > 104;
+ if (weak) return "weak";
+ return null;
+ }
+
+ variantSprite(category) {
+ const pools = {
+ hurt: ["hurt", "hurt2"],
+ fear: ["teary", "fear", "flee_fear2", "fear_blue", "fear_cry"],
+ flee: ["flee", "flee_fear2"],
+ sleep: ["sleep", "sleep2"],
+ stress: ["stress_dizzy", "stress_sweat"],
+ low_stress: lowStressSprites(),
+ intimidate: ["intimidate"],
+ normal: displayNormalSprites(),
+ };
+ const options = (pools[category] || []).filter(id => SPRITES.some(s => s.id === id));
+ return stableChoice(this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile";
+ }
+
+ rawSpriteId() {
+ if (this.dead) return this.variantSprite("fear");
+ if (this.type === "cry") return "cry";
+ if (this.type === "stretch") return "stretch";
+ if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat";
+ if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat";
+ if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt");
+ if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual";
+ const low = this.lowHealthSprite();
+ if (low) return low;
+ if (this.defeatedTimer > 0.04) return this.variantSprite("flee");
+ if (this.state === "intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate");
+ if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear");
+ if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep");
+ if (this.state === "fight" || this.fightTimer > 0.04) return "angry";
+ if (this.surpriseTimer > 0.08) return "pokan";
+ if (this.state === "cursor_friend") return "smile";
+ if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode;
+ if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back";
+ if (this.state === "seek_food") return "drool";
+ if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress");
+ if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress");
+ if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode;
+ if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry";
+ return this.goodMode || this.variantSprite("normal");
+ }
+
+
+ spriteId() {
+ const next = this.rawSpriteId();
+ const now = this.world?.time || 0;
+ if (!this.visibleSpriteId) {
+ this.visibleSpriteId = next;
+ this.spriteLockUntil = now + 1.0;
+ return next;
+ }
+ if (next !== this.visibleSpriteId && now >= this.spriteLockUntil) {
+ this.visibleSpriteId = next;
+ this.spriteLockUntil = now + 1.0;
+ }
+ return this.visibleSpriteId;
+ }
+
+ update(dt) {
+ if (this.dead) return;
+
+ const minute = dt / 60;
+ this.age += dt;
+ this.reproductionTimer -= dt;
+ this.blink += dt;
+ this.eatTimer = Math.max(0, this.eatTimer - dt);
+ this.eatCooldown = Math.max(0, this.eatCooldown - dt);
+ this.foodReactTimer = Math.max(0, this.foodReactTimer - dt);
+ this.surpriseTimer = Math.max(0, this.surpriseTimer - dt);
+ 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.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);
+ if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null;
+ if (this.birthRitualTimer > 0) {
+ const beforeBirthTimer = this.birthRitualTimer;
+ this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt);
+ if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) {
+ const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
+ if (partner) this.world.finishBirthRitual(this, partner);
+ this.birthRitualLeader = false;
+ }
+ }
+ this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt);
+ const wasFighting = this.fightTimer > 0.04;
+ this.fightTimer = Math.max(0, this.fightTimer - dt);
+ this.fightCooldown = Math.max(0, this.fightCooldown - dt);
+ this.defeatedTimer = Math.max(0, this.defeatedTimer - dt);
+ if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) {
+ const winner = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead) || null;
+ this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2));
+ this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear);
+ this.hurtTimer = Math.max(this.hurtTimer, 1.8);
+ this.fallTimer = Math.max(this.fallTimer, 1.15);
+ this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer);
+ this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir;
+ this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45);
+ if (winner) this.world.resolveFightOutcome(winner, this);
+ this.fightTargetIds = [];
+ this.fightTargetId = null;
+ }
+ this.stretchTimer = Math.max(0, this.stretchTimer - dt);
+ this.grassEatTimer = Math.max(0, this.grassEatTimer - dt);
+ this.hurtTimer = Math.max(0, this.hurtTimer - dt);
+ this.fallTimer = Math.max(0, this.fallTimer - dt);
+ this.zunchiStain = 0;
+ if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) {
+ this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)");
+ }
+ if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) {
+ this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)");
+ }
+ if (this.hurtTimer > 0 && Math.random() < dt * 5.2) {
+ const side = this.facingDir();
+ this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34));
+ }
+
+ this.tempTimer += dt;
+ if (this.tempTimer >= 0.55) {
+ this.tempComfort = this.world.temperatureAt(this.x, this.y);
+ this.tempTimer = 0;
+ }
+ const tempComfort = this.tempComfort ?? 0.62;
+ const tooCold = Math.max(0, 0.48 - tempComfort);
+ this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8);
+ this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social);
+ this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1);
+ this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress;
+ this.stress += tooCold * dt * 0.25;
+
+ const phase = this.world.lightLevel();
+ if (phase < 0.25) this.energy += dt * 0.07;
+ if (phase > 0.75) this.stress += dt * 0.04;
+ if (this.world.weather === "cloudy") {
+ this.stress -= dt * 0.018;
+ this.vx *= Math.pow(0.996, dt * 60);
+ this.vy *= Math.pow(0.996, dt * 60);
+ } else if (this.world.weather === "light_rain") {
+ this.energy += dt * 0.018;
+ this.stress -= dt * 0.025;
+ } else if (this.world.weather === "sunny") {
+ this.vx *= Math.pow(1.001, dt * 60);
+ this.vy *= Math.pow(1.001, dt * 60);
+ }
+
+ if (this.juvenile) {
+ const adultScale = 0.25 + (makeTrait(this.type).speed % 0.08);
+ this.scale = lerp(this.scale, adultScale, dt * 0.018 + 0.002);
+ this.radius = 56 * this.scale;
+ }
+
+ this.hunger = clamp(this.hunger, 0, 115);
+ this.loneliness = clamp(this.loneliness, 0, 110);
+ this.energy = clamp(this.energy, 0, 100);
+ this.stress = clamp(this.stress, 0, 130);
+
+ this.aiTimer += dt;
+ const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02;
+ const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0;
+ const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.id, "ai-interval") * 0.09 + popPerf * 0.055);
+ if (this.aiTimer >= aiInterval) {
+ const aiDt = Math.min(this.aiTimer, 0.48);
+ this.aiTimer = 0;
+ this.resolveNeeds(aiDt);
+ const sleepingNow = this.state === "sleep";
+ if (!sleepingNow) {
+ this.reactToFreshZunchi(aiDt);
+ this.updateFacing();
+ this.interactWithItems(aiDt);
+ this.interactWithOthers(aiDt);
+ } else {
+ this.updateFacing();
+ }
+ } else {
+ this.updateFacing();
+ }
+ this.envTimer += dt;
+ const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48;
+ if (this.envTimer >= envInterval) {
+ const envDt = Math.min(this.envTimer, 1.2);
+ this.envTimer = 0;
+ this.applyEnvironment(envDt);
+ }
+ this.maintainTargetProgress(dt);
+ this.move(dt);
+ if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this);
+ this.checkLife();
+ }
+
+ applyEnvironment(dt) {
+ const parent = this.parentToFollow ? this.parentToFollow() : null;
+ if (parent && dist(this, parent) < 96) {
+ this.stress = clamp(this.stress - dt * 0.42, 0, 130);
+ this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110);
+ }
+ const friend = this.bestFriendLive ? this.bestFriendLive(12, 92) : null;
+ if (friend) this.stress = clamp(this.stress - dt * 0.36, 0, 130);
+ let grassComfort = 0;
+ for (const it of this.world.nearbyItems(this.x, this.y, 105)) {
+ if (it.type !== "grass" || it.dead) continue;
+ const d = distXY(this.x, this.y, it.x, it.y);
+ const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2);
+ grassComfort += clamp(1 - d / 105, 0, 1) * lush;
+ }
+ if (grassComfort > 0) {
+ this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130);
+ if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06));
+ if (this.need === "\u9759\u3051\u3055" || this.personality === "timid") this.affection += dt * Math.min(0.06, grassComfort * 0.012);
+ }
+ }
+
+ bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)") {
+ if (this.world.time < this.nextBubbleAt) return;
+ this.nextBubbleAt = this.world.time + cooldown;
+ this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color);
+ }
+
+ mouthPosition(target = null) {
+ const dir = target ? Math.sign((target.x ?? this.x) - this.x) || this.facingDir() : this.facingDir();
+ return {
+ x: this.x + dir * this.radius * 0.40 - this.radius * 0.34,
+ y: this.y - this.radius * 0.28,
+ };
+ }
+
+ sleepSpotFor(bed) {
+ if (!bed) return { x: this.x, y: this.y };
+ const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2;
+ const baseRing = bed.r * 0.52;
+ const ring = baseRing + stableUnit(this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95;
+ return {
+ x: clamp(bed.x + Math.cos(angle) * ring, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding),
+ y: clamp(bed.y + Math.sin(angle) * ring * 0.56 - this.radius * 0.10, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding),
+ };
+ }
+
+ desiredFacingDir() {
+ if (this.state === "sleep") return this.sleepFacing || this.facing || 1;
+ if (this.target && Number.isFinite(this.target.x)) return Math.sign(this.target.x - this.x) || this.facing || 1;
+ if (Math.abs(this.vx) > 2.2) return Math.sign(this.vx);
+ return this.facing || 1;
+ }
+
+ facingDir() {
+ return this.visualFacing || this.facing || 1;
+ }
+
+ updateFacing() {
+ const desired = this.desiredFacingDir();
+ const now = this.world?.time || 0;
+ this.facing = desired;
+ if (!this.visualFacing) {
+ this.visualFacing = desired;
+ this.facingLockUntil = now + 1.0;
+ return;
+ }
+ if (Math.sign(desired) !== Math.sign(this.visualFacing) && now >= (this.facingLockUntil || 0)) {
+ this.visualFacing = Math.sign(desired) || this.visualFacing || 1;
+ this.facingLockUntil = now + 1.0;
+ }
+ }
+
+ reactToFreshZunchi(dt) {
+ if (this.state === "panic" || this.fearTimer > 0.2) return;
+ const z = this.world.nearest(this, ["zunchi"], 90);
+ if (!z || z.stage !== "fresh") return;
+ const d = dist(this, z);
+ const push = clamp(1 - d / 90, 0, 1);
+ this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130);
+ this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40;
+ this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40;
+ }
+
+ resolveNeeds(dt) {
+ const wasSleeping = this.state === "sleep";
+ const previousTarget = this.target && !this.target.dead ? this.target : null;
+ this.target = null;
+ this.sleeping = false;
+
+ if (this.birthRitualTimer > 0.04) {
+ this.state = "birth_ritual";
+ this.target = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null;
+ this.thought = "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
+ return;
+ }
+
+ if (this.intimidateTimer > 0.04) {
+ this.state = "intimidate";
+ this.target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null;
+ this.bubble("!", 1.2);
+ this.thought = "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
+ return;
+ }
+
+ if (this.fightTimer > 0.04) {
+ this.state = "fight";
+ this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead));
+ this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId;
+ let best = null, bestD = Infinity;
+ for (const id of this.fightTargetIds) {
+ const t = this.world.tarinai.find(o => o.id === id && !o.dead);
+ if (!t) continue;
+ const d = dist(this, t);
+ if (d < bestD) { best = t; bestD = d; }
+ }
+ this.target = best;
+ this.bubble("!", 1.6);
+ this.thought = "\u55a7\u5629\u3057\u3066\u3044\u308b";
+ return;
+ }
+
+ if (this.defeatedTimer > 0.04) {
+ const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead);
+ this.state = "panic";
+ this.target = this.panicDestination(opponent);
+ this.thought = "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b";
+ if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null;
+ return;
+ } else if (this.defeatedById && this.fightTimer <= 0.04) {
+ this.defeatedById = null;
+ this.fightWinnerId = null;
+ }
+
+ const friendNearby = this.bestFriendLive ? this.bestFriendLive(12, 170) : null;
+ if (friendNearby && friendNearby.state === "panic" && friendNearby.target) {
+ this.state = "panic";
+ this.target = this.panicDestination(friendNearby.target);
+ this.fearTimer = Math.max(this.fearTimer, 0.32);
+ this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b";
+ return;
+ }
+
+ const dayP = this.world.dayProgress();
+ const personalSleep = this.sleepStart < this.sleepEnd
+ ? dayP >= this.sleepStart && dayP < this.sleepEnd
+ : dayP >= this.sleepStart || dayP < this.sleepEnd;
+
+ const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null;
+ const feared = this.strongestRelation("fear");
+ if (!wasSleeping && (recentRival || (feared && feared.score > 12))) {
+ const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead);
+ if (rival && dist(this, rival) < 150) {
+ this.state = "panic";
+ this.target = this.panicDestination(rival);
+ this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear);
+ this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`;
+ if (this.world.relationNotice(this.id, rival.id, "avoid", 22)) this.world.log(`${this.name}\u306f\u4ee5\u524d\u306e\u55a7\u5629\u5f8c\u3001${rival.name}\u3092\u907f\u3051\u305f\u3002`, "relation");
+ return;
+ }
+ }
+
+ const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda;
+ const zunda = this.world.nearest(this, ["sweet"], zundaRange);
+ const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1));
+ const zundaReward = this.affection > 8 && this.hunger > 7;
+ if (zunda && zunda.amount > 1.2 && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) {
+ this.state = "seek_food";
+ this.target = zunda;
+ this.foodReactTimer = Math.max(this.foodReactTimer, 0.36);
+ this.thought = "\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b";
+ return;
+ }
+
+ if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) {
+ this.state = "panic";
+ this.target = this.panicDestination();
+ this.thought = "\u9003\u3052\u3066\u3044\u308b";
+ return;
+ }
+
+ const sleepBias = this.personalityProfile().sleep;
+ const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias);
+ if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) {
+ let bed = previousTarget && previousTarget.type === "bed" ? previousTarget : null;
+ if (!bed && friendNearby && friendNearby.target?.type === "bed" && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target;
+ if (!bed && !wasSleeping) bed = this.world.bestBedFor(this, 360);
+ if (bed && !wasSleeping && this.energy > 18) {
+ const spot = this.sleepSpotFor(bed);
+ if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) {
+ this.state = "seek_bed";
+ this.target = bed;
+ this.thought = "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b";
+ return;
+ }
+ }
+ this.state = "sleep";
+ this.target = bed;
+ this.sleeping = true;
+ if (this.world.time > this.nextSleepBubbleAt) {
+ this.nextSleepBubbleAt = this.world.time + 4.5;
+ this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
+ }
+ this.thought = bed ? "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b";
+ return;
+ }
+
+ const pointer = this.world.pointer;
+ if (pointer?.inside) {
+ const pd = distXY(this.x, this.y, pointer.x, pointer.y);
+ if (pd < 170) {
+ const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry";
+ const friendly = !hostile && (this.mood > 52 || this.affection > 12 || this.goodMode === "smile");
+ if (hostile) {
+ this.state = "cursor_enemy";
+ this.target = { x: pointer.x, y: pointer.y, dead: false };
+ this.fearTimer = Math.max(this.fearTimer, 0.18);
+ if (this.world.time > this.nextFearBubbleAt) {
+ this.nextFearBubbleAt = this.world.time + 2.4;
+ this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)");
+ }
+ this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b";
+ return;
+ }
+ if (friendly) {
+ this.state = "cursor_friend";
+ this.target = { x: pointer.x, y: pointer.y, dead: false };
+ this.goodMode = "smile";
+ this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f";
+ return;
+ }
+ }
+ }
+
+ const parent = this.parentToFollow();
+ if (parent && !this.shouldAvoidTarget(parent)) {
+ const d = dist(this, parent);
+ if (d > 34 || (this.hunger < 78 && this.energy > 24)) {
+ this.state = "follow_parent";
+ this.target = parent;
+ this.bubble("?", 4.0);
+ this.thought = "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f";
+ return;
+ }
+ }
+
+ if (friendNearby && this.hunger > 44 && friendNearby.target && ["sweet", "grass"].includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
+ this.state = "seek_food";
+ this.target = friendNearby.target;
+ this.foodReactTimer = Math.max(this.foodReactTimer, 0.24);
+ this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f";
+ return;
+ }
+
+ if (this.hunger > 62) {
+ const itemTypes = ["sweet", "grass"];
+ const item = this.world.nearest(this, itemTypes, 420);
+ if (item) {
+ this.state = "seek_food";
+ this.target = item;
+ this.foodReactTimer = Math.max(this.foodReactTimer, 0.30);
+ this.thought = "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b";
+ return;
+ }
+ }
+
+ if (this.loneliness > 63 - (this.personality === "lonely" ? 16 : 0)) {
+ const other = this.world.nearestOther(this, 300);
+ if (other) {
+ this.state = "seek_friend";
+ this.target = other;
+ this.thought = "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b";
+ return;
+ }
+ }
+
+ if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) {
+ this.state = "panic";
+ this.thought = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
+ return;
+ }
+
+ if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7);
+ this.state = "idle";
+ this.thought = `${this.need}\u304c\u305f\u308a\u306a\u3044`;
+ }
+
+ makePoop(force = 1) {
+ this.digest += force;
+ if (this.digest < 12.4) return;
+ this.digest = rand(0.4, 1.6);
+ this.poopCount += 1;
+ const side = this.facingDir();
+ const backX = this.x - side * this.radius * rand(0.72, 0.96);
+ const backY = this.y + this.radius * rand(0.25, 0.48);
+ this.world.spawnZunchi(backX, backY);
+ this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)");
+ if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`);
+ }
+
+ interactWithItems(dt) {
+ const canBite = this.eatCooldown <= 0 && this.hunger > 18;
+ const canSweetBite = this.eatCooldown <= 0 && this.hunger > 7;
+ 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)) && it.amount > 1.2) {
+ const eating = Math.min(it.amount, it.type === "sweet" ? 7.2 : 8.5);
+ if (eating > 0) {
+ this.state = "eat";
+ this.target = it;
+ this.vx *= Math.pow(0.25, dt * 60);
+ this.vy *= Math.pow(0.25, dt * 60);
+ it.amount -= eating;
+ this.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95);
+ this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46));
+ this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012;
+ if (it.type === "sweet") {
+ this.stress = clamp(this.stress - eating * 0.42, 0, 130);
+ this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035);
+ }
+ this.eatTimer = Math.max(this.eatTimer, 0.32);
+ this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78);
+ this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)");
+ this.foodReactTimer = Math.max(this.foodReactTimer, 0.16);
+ if (Math.random() < dt * 2.2) {
+ const mouth = this.mouthPosition(it);
+ this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "sweet" ? "#78b957" : "#f1dfb7");
+ }
+ this.lastMealColor = it.type === "sweet" ? "#78b957" : "#f1dfb7";
+ this.makePoop(eating * (it.type === "sweet" ? 0.18 : 0.22));
+ if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; }
+ if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) {
+ this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`);
+ this.lastLog = this.world.time;
+ }
+ break;
+ }
+ }
+
+ if (canBite && it.type === "grass") {
+ const bite = Math.min(it.amount, rand(4.2, 7.2));
+ this.state = "eat";
+ this.target = it;
+ 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);
+ this.hunger -= bite * 0.46;
+ this.recoverHealth(bite * 0.18);
+ this.stress -= dt * 0.5;
+ this.eatTimer = Math.max(this.eatTimer, 0.22);
+ this.eatCooldown = rand(0.68, 1.15);
+ this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)");
+ if (Math.random() < dt * 2.0) {
+ const mouth = this.mouthPosition(it);
+ this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39");
+ }
+ if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; }
+ this.makePoop(bite * 0.10);
+ if (this.type === "jito") this.affection += dt * 0.25;
+ break;
+ }
+
+ if (it.type === "zunchi") {
+ const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1);
+ this.stress = clamp(this.stress + touch * dt * 3.4, 0, 130);
+ this.zunchiStain = 0;
+ if (canBite && this.hunger > 104) {
+ const bite = Math.min(it.amount, rand(3.4, 5.8));
+ this.state = "eat";
+ this.target = it;
+ this.vx *= Math.pow(0.18, dt * 60);
+ this.vy *= Math.pow(0.18, dt * 60);
+ it.amount -= bite;
+ this.hunger -= bite * 0.18;
+ this.stress = clamp(this.stress + bite * 0.52, 0, 130);
+ this.zunchiStain = 0;
+ this.eatTimer = Math.max(this.eatTimer, 0.24);
+ this.eatCooldown = rand(0.9, 1.4);
+ this.bubble("...", 2.2, "rgba(74,91,50,0.78)");
+ const mouth = this.mouthPosition(it);
+ this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f");
+ if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; }
+ break;
+ }
+ }
+
+ if (it.type === "water") {
+ this.stress -= dt * 2.4;
+ this.energy += dt * 0.5;
+ this.hunger -= dt * 0.2;
+ it.amount -= dt * 2.4;
+ if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18;
+ }
+
+ if (it.type === "stone") {
+ this.stress -= dt * 0.85;
+ this.energy += dt * 0.18;
+ if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25;
+ }
+
+ if (it.type === "bed") {
+ if (this.energy < 74 + 6 * this.personalityProfile().sleep) {
+ const occ = this.world.bedOccupancy(it);
+ const comfort = this.world.bedComfort(it);
+ this.state = "sleep";
+ this.sleeping = true;
+ if (this.world.time > this.nextSleepBubbleAt) {
+ this.nextSleepBubbleAt = this.world.time + 4.5;
+ this.world.spawnBubble(this.x, this.y - this.radius * 1.18, occ > 5 ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)");
+ }
+ this.energy += dt * (1.9 + comfort * 1.4);
+ this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort);
+ it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1);
+ }
+ }
+
+ if (it.type === "trace") {
+ this.loneliness -= dt * 0.25;
+ this.stress += dt * 0.06;
+ }
+ }
+ this.hunger = clamp(this.hunger, 0, 115);
+ this.energy = clamp(this.energy, 0, 100);
+ this.stress = clamp(this.stress, 0, 130);
+ this.loneliness = clamp(this.loneliness, 0, 110);
+ }
+
+ interactWithOthers(dt) {
+ let closeCount = 0;
+ for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) {
+ if (o === this || o.dead) continue;
+ const dx = o.x - this.x, dy = o.y - this.y;
+ const d = Math.hypot(dx, dy);
+ if (d < 0.01 || d > 74) continue;
+ closeCount += 1;
+
+ const overlap = this.radius + o.radius - d;
+ if (overlap > -2) {
+ const push = (overlap + 6) * 0.17;
+ this.vx -= (dx / d) * push;
+ this.vy -= (dy / d) * push;
+ if (Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
+ }
+
+ if (d < 60) {
+ const rel = this.relationTo(o.id);
+ const pdef = this.personalityProfile();
+ this.loneliness -= dt * 1.65 * this.trait.social;
+ this.affection += dt * 0.28 * this.trait.social;
+ this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby");
+ if ((rel.affinity || 0) > 10) {
+ this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130);
+ this.affection = clamp(this.affection + dt * 0.18, 0, 80);
+ if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation");
+ }
+ if ((rel.affinity || 0) > 8 && this.world.relationNotice(this.id, o.id, "friend", 32)) {
+ this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation");
+ }
+ if (this.type === "angry") this.stress += dt * 0.08;
+ if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12;
+ }
+
+ const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id));
+ if (fightingThis && d < 36 && this.id < o.id) {
+ const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35;
+ if (pulse && this.world.time > this.nextHeadbutt) {
+ const nx = dx / d, ny = dy / d;
+ this.vx -= nx * rand(24, 44);
+ this.vy -= ny * rand(24, 44);
+ o.vx += nx * rand(34, 64);
+ o.vy += ny * rand(34, 64);
+ this.stress = clamp(this.stress + 1.6, 0, 130);
+ o.stress = clamp(o.stress + 2.8, 0, 130);
+ this.damage(1.2);
+ o.damage(1.6);
+ this.hurtTimer = Math.max(this.hurtTimer, 1.4);
+ o.hurtTimer = Math.max(o.hurtTimer, 1.8);
+ this.adjustRelation(o, -0.16, 0.12, "fight");
+ o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight");
+ if (Math.random() < 0.22 || o.energy < 24) {
+ o.fallTimer = Math.max(o.fallTimer, 0.58);
+ o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer);
+ o.fallDir = nx > 0 ? 1 : -1;
+ this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55);
+ }
+ if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) {
+ o.defeatedById = this.id;
+ o.fightWinnerId = this.id;
+ }
+ this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5);
+ this.nextHeadbutt = this.world.time + rand(0.34, 0.55);
+ }
+ }
+
+ const relA = this.relationTo(o.id);
+ const relB = o.relationTo(this.id);
+ const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || this.personality === "irritable" || o.personality === "irritable");
+ const fearBrake = clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1);
+ const friendBrake = clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1);
+ const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight;
+ const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
+ if (d < 56 && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake) {
+ this.world.startConflict(this, o);
+ }
+
+ if (d < 36 && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) {
+ const enough = this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44;
+ const lonelyBond = this.loneliness > 24 || o.loneliness > 24;
+ if (enough && lonelyBond && Math.random() < dt * 0.25) {
+ this.world.startBirthRitual(this, o);
+ }
+ }
+ }
+
+ if (closeCount > 8) {
+ this.stress += dt * 0.9;
+ this.energy -= dt * 0.25;
+ this.fearTimer = Math.max(this.fearTimer, 0.18);
+ }
+
+ if (this.loneliness < 12 && Math.random() < dt * 0.002) {
+ this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`);
+ }
+ }
+
+ move(dt) {
+ let ax = 0, ay = 0;
+ const baseSpeed = 31 * this.trait.speed * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1);
+
+ if (this.state === "sleep") {
+ const bedComfort = this.target?.type === "bed" ? this.world.bedComfort(this.target) : 0.72;
+ const bedCrowd = this.target?.type === "bed" ? this.world.bedOccupancy(this.target) : 0;
+ this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100);
+ this.stress = clamp(this.stress + dt * (bedCrowd > 5 ? 0.18 * (bedCrowd - 5) : -0.12 * bedComfort), 0, 130);
+ this.hunger = clamp(this.hunger + dt * 0.018, 0, 115);
+ if (this.target && !this.target.dead && this.target.type === "bed") {
+ const spot = this.sleepSpotFor(this.target);
+ const dx = spot.x - this.x;
+ const dy = spot.y - this.y;
+ const d = Math.hypot(dx, dy);
+ if (d > 1.6) {
+ const step = Math.min(d, dt * (12 + bedComfort * 14));
+ this.x += dx / d * step;
+ this.y += dy / d * step;
+ }
+ }
+ this.vx *= Math.pow(0.55, dt * 60);
+ this.vy *= Math.pow(0.55, dt * 60);
+ return;
+ }
+
+ if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") {
+ const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
+ if (partner) {
+ const midX = (this.x + partner.x) / 2;
+ const midY = (this.y + partner.y) / 2;
+ const offset = (this.birthRitualRole || 1) * this.radius * 0.44;
+ const dx = midX - this.x;
+ const dy = (midY + offset) - this.y;
+ this.vx += dx * dt * 2.8;
+ this.vy += dy * dt * 2.8;
+ }
+ this.vx *= Math.pow(0.56, dt * 60);
+ this.vy *= Math.pow(0.56, dt * 60);
+ this.x += Math.sin(this.world.time * 26 + this.age) * 0.32;
+ return;
+ }
+
+ if (this.intimidateTimer > 0.04 || this.state === "intimidate") {
+ this.vx *= Math.pow(0.22, dt * 60);
+ this.vy *= Math.pow(0.22, dt * 60);
+ return;
+ }
+
+ if (this.eatTimer > 0.04 || this.state === "eat") {
+ this.vx = 0;
+ this.vy = 0;
+ return;
+ }
+
+ if (this.state === "panic") {
+ const targetDist = this.target && !this.target.dead ? distXY(this.x, this.y, this.target.x, this.target.y) : Infinity;
+ const speed = Math.hypot(this.vx, this.vy);
+ this.panicStuckTimer = speed < 8 ? (this.panicStuckTimer || 0) + dt : 0;
+ if (!this.target || this.target.dead || targetDist < 28 || this.panicStuckTimer > 0.75) {
+ this.target = this.panicDestination(null, true);
+ }
+ }
+
+ if (this.target && !this.target.dead) {
+ const dx = this.target.x - this.x;
+ const dy = this.target.y - this.y;
+ const d = Math.hypot(dx, dy) || 1;
+ let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1;
+ if (this.target?.detour) sign = 1;
+ let strength = 1;
+ if (this.state === "follow_parent") {
+ strength = d < 42 ? 0.18 : 1.22;
+ sign = 1;
+ } else if (this.state === "cursor_friend") {
+ strength = d < 30 ? 0.12 : 0.95;
+ sign = 1;
+ } else if (this.state === "cursor_enemy") {
+ strength = d < 150 ? 1.35 : 0.35;
+ sign = -1;
+ } else if (this.state === "fight") {
+ const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37);
+ if (d < 18) {
+ strength = 1.05;
+ sign = -1;
+ } else {
+ strength = headbutt > -0.15 ? 2.65 : 0.65;
+ sign = headbutt > -0.15 ? 1 : -1;
+ }
+ }
+ ax += (dx / d) * baseSpeed * sign * strength;
+ ay += (dy / d) * baseSpeed * sign * strength;
+ } else if (this.state === "panic") {
+ this.wanderAngle += rand(-3.8, 3.8) * dt;
+ ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5;
+ ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5;
+ } else {
+ ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34;
+ ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34;
+ }
+
+ this.vx += ax * dt;
+ this.vy += ay * dt;
+ this.vx *= Math.pow(0.84, dt * 8);
+ this.vy *= Math.pow(0.84, dt * 8);
+
+ const maxV = this.state === "panic" ? 116 : 76;
+ const v = Math.hypot(this.vx, this.vy);
+ if (v > maxV) {
+ this.vx = this.vx / v * maxV;
+ this.vy = this.vy / v * maxV;
+ }
+
+ this.x += this.vx * dt;
+ this.y += this.vy * dt;
+
+ const p = CONFIG.worldPadding;
+ if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; }
+ if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; }
+ if (this.x > this.world.w - p) { this.x = this.world.w - p; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; }
+ if (this.y > this.world.h - p) { this.y = this.world.h - p; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; }
+ }
+
+ checkLife() {
+ let reason = "";
+ if (this.hunger >= 108) reason = "\u98df\u3079\u7269\u304c\u305f\u308a\u306a\u3044";
+ else if (this.age >= this.lifeSpan) reason = "\u5bff\u547d";
+ else if (this.stress >= 124) reason = "\u30b9\u30c8\u30ec\u30b9\u304c\u591a\u3059\u304e\u308b";
+ else if (this.energy <= 0 && this.hunger > 90) reason = "\u75b2\u308c\u3068\u7a7a\u8179\u304c\u91cd\u306a\u3063\u305f";
+ if (reason) this.die(reason);
+ }
+
+ die(reason) {
+ if (this.dead) return;
+ this.dead = true;
+ this.deathReason = reason;
+ this.world.deadCount += 1;
+ this.world.items.push(new Item("trace", this.x, this.y));
+ this.world.items.push(new Item("splat", this.x, this.y));
+ if (Math.random() < 0.55) {
+ const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
+ if (spot) this.world.items.push(new Item("grass", spot.x, spot.y));
+ }
+ this.world.markDead(this, reason);
+ audio.death();
+ this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u7406\u7531: ${reason}`, "death");
+ if (this.world.selected === this) this.world.selected = null;
+ }
+
+ draw(ctx, t, lighting = null) {
+ if (this.dead) return;
+ const lightState = lighting || getLightingState(this.world);
+ const sprite = this.spriteId();
+ const img = images.get(sprite) || images.get(this.type);
+ const metrics = getImageMetrics(sprite) || getImageMetrics(this.type);
+ const w = metrics?.w || 512;
+ const h = metrics?.h || 512;
+ const moveSpeed = Math.hypot(this.vx, this.vy);
+ const gait = this.state === "sleep" ? 0.8 : clamp(moveSpeed / 42, 0.2, 1.8);
+ const breathing = Math.sin(t * (this.state === "sleep" ? 1.1 : 2.3) + this.age * 0.18);
+ const headbuttPulse = this.state === "fight" ? Math.max(0, Math.sin(t * 18 + this.age * 0.37)) : 0;
+ const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 54 + this.age) * clamp(Math.max(this.eatTimer / 0.32, this.grassEatTimer / 0.22), 0, 1) : 0;
+ const fallPose = this.fallTimer > 0 ? clamp(this.fallTimer / Math.max(this.fallMax || 1, 0.01), 0, 1) : 0;
+ const fallLift = Math.sin(fallPose * Math.PI) * this.radius * 0.20;
+ const intimidateShake = this.intimidateTimer > 0.04 ? Math.sin(t * 44 + this.age) * this.radius * 0.09 : 0;
+ const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0;
+ const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0;
+ const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25;
+ const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0;
+ const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0);
+ const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin;
+ let drawW = w * this.scale * 0.30;
+ let drawH = h * this.scale * 0.30;
+ if (sprite === "stretch") {
+ drawW *= 1.16;
+ drawH *= 1.24;
+ }
+ if (sprite === "intimidate") {
+ drawW *= 1.32;
+ drawH *= 1.32;
+ }
+ if (sprite === "birth_ritual") {
+ drawW *= 0.64;
+ drawH *= 0.64;
+ }
+ if (fallPose > 0) {
+ drawW *= 1 + fallPose * 0.08;
+ drawH *= 1 - fallPose * 0.07;
+ }
+ const squish = this.state === "sleep"
+ ? 1 + breathing * 0.018
+ : 1 + Math.sin(t * (4.5 + gait * 1.6) + this.age) * (0.012 + gait * 0.018) + headbuttPulse * 0.03;
+ const faceRight = this.state === "sleep"
+ ? this.sleepFacing > 0
+ : this.facingDir() > 0;
+ const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt;
+
+ const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8);
+ const shadowContactY = this.y + bob + (drawH / squish) * 0.30;
+ drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, {
+ faceRight,
+ alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92),
+ widthScale: this.state === "sleep" ? 1.12 : 1,
+ heightScale: this.state === "sleep" ? 0.88 : 1,
+ });
+ drawCreatureGlow(ctx, this, drawW, drawH, lightState);
+ if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) {
+ const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
+ const hx = partner ? (this.x + partner.x) / 2 : this.x;
+ const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60;
+ const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10;
+ const size = Math.max(10, this.radius * 0.62) * pulse;
+ drawHeartShape(ctx, hx, hy, size, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)" });
+ }
+
+ ctx.save();
+ ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY);
+ ctx.rotate(angle);
+
+ if (this.world.selected === this) {
+ ctx.save();
+ ctx.globalAlpha = 0.85;
+ ctx.strokeStyle = "rgba(165, 220, 98, 0.92)";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.ellipse(0, drawH * 0.14, drawW * 0.62, drawH * 0.48, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.setLineDash([5, 4]);
+ ctx.lineWidth = 1.2;
+ ctx.strokeStyle = "rgba(255,255,255,0.86)";
+ ctx.beginPath();
+ ctx.ellipse(0, drawH * 0.14, drawW * 0.72, drawH * 0.56, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.restore();
+ } else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
+ ctx.save();
+ ctx.globalAlpha = 0.28;
+ ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)";
+ ctx.lineWidth = 1.4;
+ ctx.beginPath();
+ ctx.ellipse(0, drawH * 0.14, drawW * 0.58, drawH * 0.43, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ if (this.eatTimer > 0.02) {
+ ctx.save();
+ ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32);
+ ctx.strokeStyle = this.lastMealColor || "#f7cf67";
+ ctx.lineWidth = 1.6;
+ ctx.beginPath();
+ ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - this.eatTimer / 0.32) * 5, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ if (this.pokeFlashTimer > 0.03) {
+ ctx.save();
+ ctx.globalAlpha = this.pokeFlashTimer * 0.9;
+ ctx.fillStyle = "rgba(255, 120, 120, 0.22)";
+ ctx.beginPath();
+ ctx.ellipse(0, 0, drawW * 0.60, drawH * 0.42, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
+ ctx.save();
+ ctx.globalAlpha = this.state === "cursor_friend" ? 0.82 : 0.34;
+ ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9"));
+ for (let i = 0; i < 2; i++) {
+ const px = Math.cos(t * 2.2 + this.age + i * 2.4) * drawW * 0.34;
+ const py = -drawH * 0.24 + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * 0.11;
+ if (this.state === "cursor_friend") {
+ const s = Math.max(5, drawW * 0.075);
+ drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 });
+ } else {
+ ctx.beginPath();
+ ctx.arc(px, py, drawW * 0.045, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+ }
+
+ if (faceRight) ctx.scale(-1, 1);
+ const dw = drawW * squish;
+ const dh = drawH / squish;
+ const brightness = clamp(0.82 + lightState.light * 0.30 + lightState.dawnStrength * 0.08 + lightState.goldenStrength * 0.06 + lightState.noonStrength * 0.04, 0.80, 1.17);
+ const saturation = clamp(0.86 + lightState.light * 0.22 + lightState.goldenStrength * 0.10 - lightState.nightStrength * 0.04, 0.82, 1.14);
+ const renderScale = typeof SPRITE_CACHE_SCALE === "number" ? SPRITE_CACHE_SCALE : 2.0;
+ const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8);
+ const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8);
+ const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img;
+ ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh);
+ this.zunchiStain = 0;
+ drawDawnRimLight(ctx, dw, dh, lightState);
+ if (faceRight) ctx.scale(-1, 1);
+
+ if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) {
+ ctx.save();
+ ctx.globalAlpha = 0.80;
+ ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`;
+ ctx.textAlign = "center";
+ ctx.fillStyle = "rgba(42,36,29,0.72)";
+ const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?");
+ ctx.fillText(mark, 0, -drawH * 0.72);
+ ctx.restore();
+ }
+
+ ctx.restore();
+
+ if ((this.hpBarTimer || 0) > 0) {
+ const bw = Math.max(28, this.radius * 2.35);
+ const bh = 5;
+ const x = this.x - bw / 2;
+ const y = this.y - drawH * 0.55 - 16;
+ const pct = clamp(this.energy / 100, 0, 1);
+ ctx.save();
+ ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1);
+ ctx.fillStyle = "rgba(255,252,242,0.86)";
+ roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5);
+ ctx.fill();
+ ctx.fillStyle = "rgba(72,58,45,0.22)";
+ roundedRect(ctx, x, y, bw, bh, 4);
+ ctx.fill();
+ ctx.fillStyle = pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)");
+ roundedRect(ctx, x, y, bw * pct, bh, 4);
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+
+ contains(x, y) {
+ const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9;
+ return distXY(this.x, this.y, x, y) < hit;
+ }
+}
+
diff --git a/js/ui.js b/js/ui.js
new file mode 100644
index 0000000..1f0de71
--- /dev/null
+++ b/js/ui.js
@@ -0,0 +1,528 @@
+"use strict";
+
+const canvas = document.getElementById("gameCanvas");
+const ctx = canvas.getContext("2d");
+const ui = {
+ pauseBtn: document.getElementById("pauseBtn"),
+ speedBtn: document.getElementById("speedBtn"),
+ soundBtn: document.getElementById("soundBtn"),
+ saveBtn: document.getElementById("saveBtn"),
+ loadBtn: document.getElementById("loadBtn"),
+ resetBtn: document.getElementById("resetBtn"),
+ toolPalette: document.getElementById("toolPalette"),
+ toolCard: document.getElementById("toolCard"),
+ colonyChartTabs: document.getElementById("colonyChartTabs"),
+ selectedInfo: document.getElementById("selectedInfo"),
+ log: document.getElementById("log"),
+ toast: document.getElementById("toast"),
+ statPop: document.getElementById("statPop"),
+ statDead: document.getElementById("statDead"),
+ colonyChart: document.getElementById("colonyChart"),
+ colonyChartLegend: document.getElementById("colonyChartLegend"),
+ archiveContent: document.getElementById("archiveContent"),
+ fieldDialog: document.getElementById("fieldDialog"),
+ fieldCancelBtn: document.getElementById("fieldCancelBtn"),
+};
+
+const uiCache = {
+ stats: {},
+ selectedSnapshot: "",
+ selectedEmpty: false,
+ toolButtons: [],
+ archiveVersion: "",
+ archiveRows: [],
+ archiveScrollScheduled: false,
+ deferArchiveUntil: 0,
+ lastArchiveWindow: "",
+ chartHistory: [],
+ chartBucketStart: -Infinity,
+ chartBucketEnd: -Infinity,
+ chartBucketAccum: null,
+ lastChartSample: -Infinity,
+ lastChartDraw: "",
+ lastChartDrawAt: 0,
+ chartMode: "population",
+ canvasDpr: 0,
+ panning: false,
+ panLastX: 0,
+ panLastY: 0,
+ panMoved: false,
+};
+
+function renderLog(logs) {
+ ui.log.innerHTML = "";
+ const frag = document.createDocumentFragment();
+ for (const raw of logs.slice(0, 32)) {
+ const entry = typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw;
+ const kind = entry.kind || "note";
+ const div = document.createElement("div");
+ div.className = `log-entry log-${kind}`;
+ const tag = document.createElement("span");
+ tag.className = "log-tag";
+ tag.textContent = logKindLabel(kind);
+ const time = document.createElement("span");
+ time.className = "time";
+ time.textContent = formatLogTime(entry.time || 0);
+ div.appendChild(tag);
+ div.appendChild(time);
+ div.append(document.createTextNode(entry.text || ""));
+ frag.appendChild(div);
+ }
+ ui.log.appendChild(frag);
+}
+
+function formatLogTime(timeValue) {
+ const t = Math.max(0, Number(timeValue) || 0);
+ const day = Math.floor(t / CONFIG.dayLength) + 1;
+ const dayT = ((t % CONFIG.dayLength) + CONFIG.dayLength) % CONFIG.dayLength;
+ const totalMinutes = Math.floor(dayT / CONFIG.dayLength * 24 * 60);
+ const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
+ const mm = String(totalMinutes % 60).padStart(2, "0");
+ return `${day}\u65e5 ${hh}\u6642${mm}\u5206`;
+}
+
+function logKindLabel(kind) {
+ return {
+ birth: "\u8a95\u751f", death: "\u6b7b\u4ea1", fight: "\u55a7\u5629", food: "\u98df\u4e8b", grass: "\u8349",
+ weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", note: "\u8a18\u9332"
+ }[kind] || "\u8a18\u9332";
+}
+
+function tarinaiSpritePathForId(id) {
+ const live = world?.tarinai?.find(t => t.id === id && !t.dead);
+ const sid = live?.spriteId ? live.spriteId() : live?.type;
+ const fam = world?.family?.[id];
+ const fallback = fam?.type;
+ return (SPRITES.find(s => s.id === (sid || fallback)) || SPRITES[0]).path;
+}
+
+function relationIconHtml(id) {
+ if (!id) return "";
+ return `
`;
+}
+
+function renderSelected() {
+ const t = world.selected;
+ if (!t || !world.tarinai.includes(t)) {
+ if (uiCache.selectedEmpty) return;
+ uiCache.selectedSnapshot = "";
+ uiCache.selectedEmpty = true;
+ ui.selectedInfo.className = "selected-info empty";
+ ui.selectedInfo.textContent = "\u672a\u9078\u629e";
+ return;
+ }
+
+ uiCache.selectedEmpty = false;
+ const rel = t.relationSummary ? t.relationSummary() : {};
+ const fightRecord = relationFightRecord(t);
+ const snapshot = [
+ t.id, t.type, t.name, (t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : "")), t.state, t.personality, t.thought, targetLabel(t.target),
+ fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
+ fmt(t.energy), fmt(t.stress), fmt(t.mood),
+ t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
+ rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord
+ ].join("|");
+ if (uiCache.selectedSnapshot === snapshot) return;
+ uiCache.selectedSnapshot = snapshot;
+
+ ui.selectedInfo.className = "selected-info";
+ ui.selectedInfo.innerHTML = `
+ ${escapeHtml(t.label())}${escapeHtml(t.name)}${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `\u4e16\u4ee3 ${t.generation}` : ""}
+ \u6027\u683c${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}
+ \u72b6\u614b${escapeHtml(stateLabel(t.state))}
+ \u7406\u7531${escapeHtml(reasonLabel(t))}
+ \u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
+ \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
+ \u7a7a\u8179${fmt(t.hunger)}
+ \u5bc2\u3057\u3055${fmt(t.loneliness)}
+ \u4f53\u529b${fmt(t.energy)}
+ \u30b9\u30c8\u30ec\u30b9${fmt(t.stress)}
+ \u6c17\u5206${fmt(t.mood)}
+ \u4ef2\u826f\u3057${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}
+ \u907f\u3051\u308b\u76f8\u624b${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}
+ \u55a7\u5629\u8a18\u61b6${escapeHtml(fightRecord)}
+ \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
+ \u5b50${t.children?.length || 0}
+ \u305f\u308a\u306a\u3044${fmt(t.lack)}
+ `;
+}
+
+function stateLabel(s) {
+ return {
+ idle: "\u5f85\u6a5f",
+ seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
+ seek_bed: "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
+ seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
+ follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
+ cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f",
+ cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b",
+ fight: "\u55a7\u5629\u4e2d",
+ sleep: "\u7761\u7720\u4e2d",
+ eat: "\u98df\u4e8b\u4e2d",
+ panic: "\u30d1\u30cb\u30c3\u30af",
+ intimidate: "\u5a01\u5687\u4e2d",
+ birth_ritual: "\u8a95\u751f\u524d",
+ }[s] || s;
+}
+
+function reasonLabel(t) {
+ if (!t) return "\u306a\u3057";
+ const thought = String(t.thought || "").trim();
+ if (thought) return thought;
+ if (t.state === "sleep") return "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
+ if (t.state === "panic") return "\u6016\u304c\u3063\u3066\u3044\u308b";
+ if (t.state === "intimidate") return "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
+ if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
+ if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b";
+ return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
+}
+
+function targetLabel(target) {
+ if (!target) return "\u306a\u3057";
+ if (target.name) return target.name;
+ if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
+ if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb";
+ return "\u4e0d\u660e";
+}
+
+function relationFightRecord(t) {
+ let won = 0, lost = 0;
+ for (const rel of Object.values(t.relationships || {})) {
+ won += rel.fightsWon || 0;
+ lost += rel.fightsLost || 0;
+ }
+ return won || lost ? `\u52dd${won} / \u8ca0${lost}` : "\u306a\u3057";
+}
+
+function setTextIfChanged(el, key, value) {
+ if (!el) return;
+ const next = String(value);
+ if (uiCache.stats[key] === next) return;
+ uiCache.stats[key] = next;
+ el.textContent = next;
+}
+
+function setValueIfChanged(el, key, value) {
+ if (!el) return;
+ if (uiCache.stats[key] === value) return;
+ uiCache.stats[key] = value;
+ el.value = value;
+}
+
+function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>"']/g, c => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'"
+ }[c]));
+}
+function resizeCanvas() {
+ const rect = canvas.getBoundingClientRect();
+ const perf = world?.performanceLevel ? world.performanceLevel() : 0;
+ const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2);
+ const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr));
+ const nextW = Math.floor(rect.width * dpr);
+ const nextH = Math.floor(rect.height * dpr);
+ if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;
+ uiCache.canvasDpr = dpr;
+ canvas.width = nextW;
+ canvas.height = nextH;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ world.setViewportSize(rect.width, rect.height);
+ uiCache.lastChartDraw = "";
+}
+
+function applyFieldLayout(fieldType = world.fieldType || "garden") {
+ const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
+ const root = document.documentElement;
+ root.style.setProperty("--field-height", field.height);
+ root.style.setProperty("--field-min-height", field.minHeight || field.height);
+ root.style.setProperty("--field-mobile-height", field.mobileHeight || field.height);
+ root.style.setProperty("--field-mobile-min-height", field.mobileMinHeight || field.mobileHeight || "460px");
+ world.fieldType = field.id;
+ const rect = canvas.getBoundingClientRect();
+ world.setViewportSize(rect.width, rect.height);
+ resizeCanvas();
+}
+
+function openFieldDialog() {
+ ui.fieldDialog?.classList.remove("hidden");
+}
+
+function closeFieldDialog() {
+ ui.fieldDialog?.classList.add("hidden");
+}
+
+function resetWithField(fieldType) {
+ const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
+ closeFieldDialog();
+ applyFieldLayout(field.id);
+ world.reset(null, field.id);
+ uiCache.archiveVersion = "";
+ renderLog(world.logs);
+ renderArchive();
+ renderStats();
+ render();
+}
+
+function screenToWorld(e) {
+ const rect = canvas.getBoundingClientRect();
+ const touch = e.touches ? e.touches[0] : e;
+ const sx = touch.clientX - rect.left;
+ const sy = touch.clientY - rect.top;
+ return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
+}
+
+function resizeFieldFromWheel(e) {
+ if (!world.setFieldZoom) return;
+ e.preventDefault();
+ const rect = canvas.getBoundingClientRect();
+ const sx = e.clientX - rect.left;
+ const sy = e.clientY - rect.top;
+ const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
+ const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
+ if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return;
+ const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
+ const scale = world.viewScale ? world.viewScale() : 1;
+ world.cameraX = focus.x - (sx - off.x) / scale;
+ world.cameraY = focus.y - (sy - off.y) / scale;
+ world.clampCamera?.();
+ render();
+}
+
+function selectTool(tool) {
+ world.tool = tool;
+ for (const btn of uiCache.toolButtons) {
+ btn.classList.toggle("selected", btn.dataset.tool === tool);
+ }
+ syncToolSizeBadges();
+ canvas.style.cursor = tool === "observe" ? "default" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair");
+}
+
+function showToast(text) {
+ ui.toast.textContent = text;
+ ui.toast.classList.remove("hidden");
+ clearTimeout(showToast._timer);
+ showToast._timer = setTimeout(() => ui.toast.classList.add("hidden"), 1400);
+}
+
+function saveGame() {
+ localStorage.setItem(CONFIG.saveKey, JSON.stringify(world.serialize()));
+ showToast("\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002");
+}
+
+function loadGame() {
+ const raw = localStorage.getItem(CONFIG.saveKey);
+ if (!raw) { showToast("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
+ try {
+ const t0 = performance.now();
+ world.load(JSON.parse(raw));
+ applyFieldLayout(world.fieldType || "garden");
+ uiCache.archiveVersion = "";
+ uiCache.deferArchiveUntil = performance.now() + 80;
+ if (ui.archiveContent) ui.archiveContent.innerHTML = `\u5bb6\u7cfb\u56f3\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u3002
`;
+ renderSelected();
+ renderStats();
+ requestAnimationFrame(() => {
+ uiCache.deferArchiveUntil = 0;
+ renderArchive();
+ });
+ if (typeof console !== "undefined") console.debug?.(`loaded save in ${Math.round(performance.now() - t0)}ms`);
+ showToast("\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
+ } catch (e) {
+ console.error(e);
+ showToast("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
+ }
+}
+
+function syncTopButtons() {
+ ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62";
+ ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`;
+ if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF";
+}
+
+const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "grass", "stone", "firecracker", "fence_v", "fence_h"]);
+const SIZE_ORDER = ["small", "medium", "large"];
+const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
+
+function toolSizeFor(tool) {
+ if (!SCALABLE_TOOLS.has(tool)) return "";
+ return (world.toolSizes && world.toolSizes[tool]) || world.toolSize || "medium";
+}
+
+function cycleToolSize(tool) {
+ if (!SCALABLE_TOOLS.has(tool)) return false;
+ if (!world.toolSizes) world.toolSizes = {};
+ const current = toolSizeFor(tool);
+ const idx = SIZE_ORDER.indexOf(current);
+ const next = SIZE_ORDER[(idx + 1 + SIZE_ORDER.length) % SIZE_ORDER.length];
+ world.toolSizes[tool] = next;
+ world.toolSize = next;
+ syncToolSizeBadges();
+ showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[next] || next}`);
+ return true;
+}
+
+function syncToolSizeBadges() {
+ for (const btn of uiCache.toolButtons || []) {
+ const tool = btn.dataset.tool || "";
+ const old = btn.querySelector(".tool-size-badge");
+ if (!SCALABLE_TOOLS.has(tool)) {
+ btn.removeAttribute("data-size");
+ if (old) old.remove();
+ continue;
+ }
+ const size = toolSizeFor(tool);
+ btn.dataset.size = size;
+ let badge = old;
+ if (!badge) {
+ badge = document.createElement("b");
+ badge.className = "tool-size-badge";
+ btn.appendChild(badge);
+ }
+ badge.textContent = SIZE_LABELS[size] || size;
+ }
+}
+
+function bindUI() {
+ uiCache.toolButtons = Array.from(ui.toolPalette.querySelectorAll(".tool"));
+ syncToolSizeBadges();
+ ui.colonyChartTabs?.addEventListener("click", (e) => {
+ const btn = e.target.closest("button[data-chart]");
+ if (!btn) return;
+ uiCache.chartMode = btn.dataset.chart || "population";
+ for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn);
+ uiCache.lastChartDraw = "";
+ uiCache.lastChartDrawAt = 0;
+ renderStats();
+ });
+ syncToolSizeBadges();
+ ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true });
+ ui.archiveContent?.addEventListener("click", (e) => {
+ const node = e.target.closest("[data-family-tarinai-id]");
+ if (!node) return;
+ const id = node.getAttribute("data-family-tarinai-id");
+ const t = world.tarinai.find(x => x.id === id && !x.dead);
+ if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; }
+ world.selected = t;
+ selectTool("observe");
+ renderStats();
+ showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
+ });
+
+ ui.pauseBtn.addEventListener("click", () => {
+ world.paused = !world.paused;
+ syncTopButtons();
+ });
+ ui.speedBtn.addEventListener("click", () => {
+ const speeds = [1, 2, 4];
+ const i = speeds.indexOf(world.speed);
+ world.speed = speeds[(i + 1) % speeds.length];
+ syncTopButtons();
+ });
+ ui.soundBtn?.addEventListener("click", () => {
+ const on = audio.toggle();
+ ui.soundBtn.textContent = on ? "\u97f3 ON" : "\u97f3 OFF";
+ showToast(on ? "\u97f3\u3092\u6709\u52b9\u306b\u3057\u307e\u3057\u305f\u3002" : "\u97f3\u3092\u7121\u52b9\u306b\u3057\u307e\u3057\u305f\u3002");
+ });
+ ui.saveBtn.addEventListener("click", saveGame);
+ ui.loadBtn.addEventListener("click", loadGame);
+ ui.resetBtn.addEventListener("click", () => {
+ openFieldDialog();
+ });
+ ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog);
+ ui.fieldDialog?.addEventListener("click", (e) => {
+ if (e.target === ui.fieldDialog) { closeFieldDialog(); return; }
+ const btn = e.target.closest("[data-field-type]");
+ if (!btn) return;
+ resetWithField(btn.dataset.fieldType || "garden");
+ });
+
+ ui.toolPalette.addEventListener("click", (e) => {
+ const toggle = e.target.closest(".tool-category-toggle");
+ if (toggle) {
+ const category = toggle.closest(".tool-category");
+ if (!category) return;
+ category.classList.toggle("collapsed");
+ toggle.setAttribute("aria-expanded", category.classList.contains("collapsed") ? "false" : "true");
+ return;
+ }
+ const btn = e.target.closest(".tool");
+ if (!btn) return;
+ const tool = btn.dataset.tool;
+ if (world.tool === tool && cycleToolSize(tool)) return;
+ selectTool(tool);
+ });
+
+ const chainScroll = (el) => {
+ if (!el) return;
+ el.addEventListener("wheel", (e) => {
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
+ const atTop = el.scrollTop <= 0;
+ const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
+ if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
+ const page = document.scrollingElement || document.documentElement;
+ page.scrollTop += e.deltaY;
+ }
+ }, { passive: true });
+ };
+ chainScroll(document.querySelector(".panel"));
+ chainScroll(ui.archiveContent);
+ chainScroll(ui.log);
+
+ canvas.addEventListener("contextmenu", (e) => e.preventDefault());
+ canvas.addEventListener("mousedown", (e) => {
+ if (e.button !== 2) return;
+ e.preventDefault();
+ uiCache.panning = true;
+ uiCache.panMoved = false;
+ uiCache.panLastX = e.clientX;
+ uiCache.panLastY = e.clientY;
+ canvas.style.cursor = "grabbing";
+ });
+ window.addEventListener("mouseup", (e) => {
+ if (e.button !== 2 && !uiCache.panning) return;
+ if (uiCache.panning) {
+ uiCache.panning = false;
+ canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair");
+ }
+ });
+ window.addEventListener("mousemove", (e) => {
+ if (!uiCache.panning) return;
+ const dx = e.clientX - uiCache.panLastX;
+ const dy = e.clientY - uiCache.panLastY;
+ uiCache.panLastX = e.clientX;
+ uiCache.panLastY = e.clientY;
+ if (Math.abs(dx) + Math.abs(dy) > 0.5) {
+ world.panCamera?.(-dx, -dy);
+ uiCache.panMoved = true;
+ render();
+ }
+ });
+
+ canvas.addEventListener("click", (e) => {
+ if (uiCache.panMoved) {
+ uiCache.panMoved = false;
+ return;
+ }
+ const p = screenToWorld(e);
+ if (!p.inside) return;
+ world.handleClick(p.x, p.y);
+ renderStats();
+ });
+
+ canvas.addEventListener("mousemove", (e) => {
+ const p = screenToWorld(e);
+ world.pointer.x = p.x;
+ world.pointer.y = p.y;
+ world.pointer.inside = !!p.inside;
+ });
+ canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
+ canvas.addEventListener("mouseleave", () => {
+ world.pointer.inside = false;
+ });
+
+ window.addEventListener("resize", resizeCanvas);
+}
diff --git a/js/ui_charts.js b/js/ui_charts.js
new file mode 100644
index 0000000..df3ffe4
--- /dev/null
+++ b/js/ui_charts.js
@@ -0,0 +1,193 @@
+"use strict";
+
+function renderStats() {
+ const alive = world.tarinai;
+ const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
+ const values = {
+ pop: alive.length,
+ dead: world.deadCount,
+ hunger: avg(t => t.hunger),
+ lonely: avg(t => t.loneliness),
+ stress: avg(t => t.stress),
+ lack: avg(t => t.lack),
+ events: { ...(world.eventCounters || {}) },
+ objects: { ...(world.itemCounts || {}) },
+ };
+ setTextIfChanged(ui.statPop, "pop", values.pop);
+ setTextIfChanged(ui.statDead, "dead", values.dead);
+ updateColonyHistory(values);
+ drawColonyChart(values);
+ renderSelected();
+ renderArchive();
+}
+
+function updateColonyHistory(values) {
+ const t = Math.max(0, world.time || 0);
+ const history = uiCache.chartHistory;
+ const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
+ const maxPoints = 7 * 12;
+ if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) {
+ history.length = 0;
+ uiCache.chartBucketStart = -Infinity;
+ uiCache.chartBucketEnd = -Infinity;
+ uiCache.chartBucketAccum = null;
+ uiCache.lastChartDraw = "";
+ }
+ const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
+ const objectKeys = ["grass", "zunchi"];
+ const makeBucket = (start) => ({
+ start,
+ end: start + interval,
+ count: 0,
+ sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 },
+ objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
+ eventsStart: Object.fromEntries(eventKeys.map(k => [k, (values.events || {})[k] || 0])),
+ events: Object.fromEntries(eventKeys.map(k => [k, 0])),
+ });
+ if (!uiCache.chartBucketAccum) {
+ const start = Math.floor(t / interval) * interval;
+ uiCache.chartBucketStart = start;
+ uiCache.chartBucketEnd = start + interval;
+ uiCache.chartBucketAccum = makeBucket(start);
+ }
+ const pushBucket = (bucket) => {
+ if (!bucket || bucket.count <= 0) return;
+ const row = {
+ t: bucket.end,
+ pop: bucket.sums.pop / bucket.count,
+ hunger: bucket.sums.hunger / bucket.count,
+ lonely: bucket.sums.lonely / bucket.count,
+ stress: bucket.sums.stress / bucket.count,
+ lack: bucket.sums.lack / bucket.count,
+ ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
+ ...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
+ };
+ history.push(row);
+ if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
+ };
+ while (t >= uiCache.chartBucketEnd) {
+ const b = uiCache.chartBucketAccum;
+ for (const k of eventKeys) b.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (b.eventsStart[k] || 0));
+ pushBucket(b);
+ uiCache.chartBucketStart = uiCache.chartBucketEnd;
+ uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
+ uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
+ }
+ const bucket = uiCache.chartBucketAccum;
+ bucket.count += 1;
+ bucket.sums.pop += values.pop || 0;
+ bucket.sums.hunger += values.hunger || 0;
+ bucket.sums.lonely += values.lonely || 0;
+ bucket.sums.stress += values.stress || 0;
+ bucket.sums.lack += values.lack || 0;
+ for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
+ for (const k of eventKeys) bucket.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (bucket.eventsStart[k] || 0));
+}
+
+function chartRows(values) {
+ const bucket = uiCache.chartBucketAccum;
+ const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
+ const objectKeys = ["grass", "zunchi"];
+ const preview = bucket && bucket.count > 0 ? {
+ t: Math.max(world.time || 0, bucket.start || 0),
+ pop: bucket.sums.pop / bucket.count,
+ hunger: bucket.sums.hunger / bucket.count,
+ lonely: bucket.sums.lonely / bucket.count,
+ stress: bucket.sums.stress / bucket.count,
+ lack: bucket.sums.lack / bucket.count,
+ ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
+ ...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
+ } : null;
+ return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }];
+}
+
+function drawColonyChart(values) {
+ const chart = ui.colonyChart;
+ if (!chart) return;
+ const now = performance.now();
+ const perf = world.performanceLevel ? world.performanceLevel() : 0;
+ const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0;
+ if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return;
+ const rows = chartRows(values);
+ const mode = uiCache.chartMode || "population";
+ const last = rows[rows.length - 1] || values;
+ const series = chartSeries(mode);
+ const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
+ const snapshot = `${mode}:${rows.length}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
+ if (uiCache.lastChartDraw === snapshot) return;
+ uiCache.lastChartDraw = snapshot;
+ uiCache.lastChartDrawAt = now;
+ const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
+ const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188));
+ if (mode === "events") return drawEventsChart(chart, rows, cssW, cssH);
+ const pad = { l: 30, r: 12, t: 12, b: 24 };
+ const w = cssW - pad.l - pad.r;
+ const h = cssH - pad.t - pad.b;
+ const maxVal = Math.max(10, ...series.flatMap(s => rows.map(r => Number(r[s.key] || 0))));
+ const xFor = (i) => rows.length <= 1 ? pad.l + w : pad.l + (i / (rows.length - 1)) * w;
+ const yFor = (row, s) => {
+ const raw = Number(row[s.key] || 0);
+ const scale = s.percent ? 100 : maxVal;
+ const v = clamp(raw / Math.max(scale, 1) * 100, 0, 100);
+ return pad.t + h - h * v / 100;
+ };
+ const grid = [0, 25, 50, 75, 100].map(yv => {
+ const y = pad.t + h - h * yv / 100;
+ return `${yv}`;
+ }).join("");
+ const lines = series.map(s => {
+ const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" ");
+ const last = rows[rows.length - 1];
+ const cx = xFor(rows.length - 1).toFixed(1);
+ const cy = yFor(last, s).toFixed(1);
+ return ``;
+ }).join("");
+ chart.innerHTML = ``;
+ if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}`).join("");
+}
+
+function drawEventsChart(chart, rows, cssW, cssH) {
+ const series = chartSeries("events");
+ const weekRows = rows.slice(-7 * 12);
+ const counts = series.map(s => ({ ...s, value: weekRows.reduce((sum, row) => sum + Math.max(0, Number(row[s.key] || 0)), 0) }));
+ const total = Math.max(1, counts.reduce((a, b) => a + b.value, 0));
+ const pad = { l: 68, r: 16, t: 12, b: 16 };
+ const barW = cssW - pad.l - pad.r;
+ const rowH = Math.max(18, (cssH - pad.t - pad.b) / counts.length);
+ const bars = counts.map((s, i) => {
+ const y = pad.t + i * rowH + 4;
+ const pct = s.value / total;
+ const bw = Math.max(2, barW * pct);
+ return `${s.label}${s.value}\u4ef6 / ${Math.round(pct * 100)}%`;
+ }).join("");
+ chart.innerHTML = ``;
+ if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570`;
+}
+
+function chartSeries(mode = "population") {
+ if (mode === "life") return [
+ { key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true },
+ { key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true },
+ { key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true },
+ { key: "lack", label: "\u305f\u308a\u306a\u3044", color: "#5b9d61", percent: true },
+ ];
+ if (mode === "objects") return [
+ { key: "obj_grass", label: "\u8349", color: "#5b9d61" },
+ { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
+ ];
+ if (mode === "events") return [
+ { key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" },
+ { key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" },
+ { key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" },
+ { key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" },
+ { key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" },
+ { key: "ev_grass", label: "\u8349", color: "#489443" },
+ ];
+ return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
+}
+
+function formatChartValue(key, values) {
+ const v = values[key] ?? 0;
+ return String(Math.round(v));
+}
+
diff --git a/js/ui_exports.js b/js/ui_exports.js
new file mode 100644
index 0000000..9a80ef9
--- /dev/null
+++ b/js/ui_exports.js
@@ -0,0 +1,3 @@
+"use strict";
+
+window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, validateFamilyTree, resizeCanvas, applyFieldLayout, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };
diff --git a/js/ui_family.js b/js/ui_family.js
new file mode 100644
index 0000000..9a1dcff
--- /dev/null
+++ b/js/ui_family.js
@@ -0,0 +1,187 @@
+"use strict";
+
+function buildArchiveRows(family) {
+ return liveFamilyComponents(family);
+}
+
+function liveFamilyComponents(family) {
+ const nodes = Object.values(family || {}).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
+ const ids = new Set(nodes.map(n => n.id));
+ const graph = new Map();
+ const addEdge = (a, b) => {
+ if (!a || !b || !ids.has(a) || !ids.has(b)) return;
+ if (!graph.has(a)) graph.set(a, new Set());
+ if (!graph.has(b)) graph.set(b, new Set());
+ graph.get(a).add(b);
+ graph.get(b).add(a);
+ };
+ for (const n of nodes) {
+ if (!graph.has(n.id)) graph.set(n.id, new Set());
+ for (const p of n.parents || []) addEdge(n.id, p);
+ for (const c of n.children || []) addEdge(n.id, c);
+ }
+ const seen = new Set();
+ const comps = [];
+ for (const n of nodes) {
+ if (seen.has(n.id)) continue;
+ const stack = [n.id];
+ const comp = [];
+ seen.add(n.id);
+ while (stack.length) {
+ const id = stack.pop();
+ const item = family[id];
+ if (item) comp.push(item);
+ for (const next of graph.get(id) || []) {
+ if (seen.has(next)) continue;
+ seen.add(next);
+ stack.push(next);
+ }
+ }
+ if (comp.some(x => x.alive)) comps.push(comp);
+ }
+ comps.sort((a, b) => {
+ const ag = Math.min(...a.map(n => n.generation || 1));
+ const bg = Math.min(...b.map(n => n.generation || 1));
+ const at = Math.min(...a.map(n => n.birthTime || 0));
+ const bt = Math.min(...b.map(n => n.birthTime || 0));
+ return ag - bg || at - bt || b.length - a.length;
+ });
+ return comps;
+}
+
+function familyStatusText(n) {
+ if (!n) return "\u4e0d\u660e";
+ return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1";
+}
+
+function lineagePersonHtml(n) {
+ const live = n?.id ? world.tarinai.find(t => t.id === n.id && !t.dead) : null;
+ const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path;
+ const name = escapeHtml(n?.name || "\u4e0d\u660e");
+ const joined = Boolean(n?.hasPaired || (n?.parents || []).length || (n?.children || []).length);
+ const gen = joined && n?.generation ? `\u4e16\u4ee3 ${n.generation}` : "";
+ const status = escapeHtml(familyStatusText(n));
+ const reason = !n?.alive && n?.deathReason ? `\u7406\u7531: ${escapeHtml(n.deathReason)}` : "";
+ const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
+ const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : "";
+ return `
+

+
+ ${name}
+ ${gen ? `${escapeHtml(gen)} / ${status}` : `${status}`}${reason}
+
+
`;
+}
+
+function lineageMiniHtml(n, role = "") {
+ if (!n) return `\u4e0d\u660e`;
+ const img = tarinaiSpritePathForId(n.id);
+ const name = escapeHtml(n.name || n.id);
+ const gen = n.generation ? `G${n.generation}` : "";
+ const status = n.alive ? "\u751f" : "\u6b7b";
+ return `
+
+ ${name}
+ ${gen} / ${status}
+ `;
+}
+
+function lineageFamilyHtml(component, index, family) {
+ const nodeSort = (a, b) =>
+ (a.generation || 1) - (b.generation || 1) ||
+ (a.birthTime || 0) - (b.birthTime || 0) ||
+ String(a.name || "").localeCompare(String(b.name || "")) ||
+ String(a.id || "").localeCompare(String(b.id || ""));
+ const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(nodeSort);
+ const byGeneration = new Map();
+ for (const n of uniqueComponent) {
+ const key = n.generation || 1;
+ if (!byGeneration.has(key)) byGeneration.set(key, []);
+ byGeneration.get(key).push(n);
+ }
+ const generationBlocks = Array.from(byGeneration.entries()).sort((a, b) => a[0] - b[0]).map(([generation, nodes]) => `
+
+
\u4e16\u4ee3 ${generation}
+
${nodes.sort(nodeSort).map(lineagePersonHtml).join("")}
+
+ `).join("");
+
+ const pairMap = new Map();
+ for (const child of uniqueComponent) {
+ const parents = (child.parents || []).filter(id => family[id]);
+ if (!parents.length) continue;
+ const key = parents.slice().sort().join("+");
+ if (!pairMap.has(key)) pairMap.set(key, { parents: parents.slice().sort(), children: [] });
+ pairMap.get(key).children.push(child.id);
+ }
+ const pairs = Array.from(pairMap.values()).sort((a, b) => {
+ const achild = family[a.children[0]];
+ const bchild = family[b.children[0]];
+ return nodeSort(achild, bchild);
+ });
+ const pairCards = pairs.length ? pairs.map(pair => {
+ const parents = pair.parents.map(id => family[id]).filter(Boolean).sort(nodeSort);
+ const children = pair.children.map(id => family[id]).filter(Boolean).sort(nodeSort);
+ return `
+ ${parents.map(n => lineageMiniHtml(n, "parent")).join('+')}
+ \u2193
+ ${children.map(n => lineageMiniHtml(n, "child")).join("")}
+ `;
+ }).join("") : `\u307e\u3060\u89aa\u5b50\u95a2\u4fc2\u306f\u8a18\u9332\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002
`;
+ const aliveCount = uniqueComponent.filter(n => n.alive).length;
+ return `
+ \u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}
+
+
${generationBlocks}
+
+
\u7e41\u6b96\u30da\u30a2
+
${pairCards}
+
+
+ `;
+}
+
+function renderArchive() {
+ if (!ui.archiveContent) return;
+ if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return;
+ uiCache.deferArchiveUntil = 0;
+ if (world.normalizeFamily) world.normalizeFamily();
+ const family = world.family || {};
+ const ids = Object.keys(family);
+ const version = `${world.familyVersion || 0}:${ids.length}`;
+ if (uiCache.archiveVersion === version) return;
+ uiCache.archiveVersion = version;
+ uiCache.lastArchiveWindow = "";
+ const components = liveFamilyComponents(family);
+ uiCache.archiveRows = components;
+ if (!components.length) {
+ ui.archiveContent.innerHTML = `\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
`;
+ return;
+ }
+ const shown = components.reduce((a, c) => a + c.length, 0);
+ const note = `${components.length} \u4ef6\u306e\u5bb6\u7cfb / ${shown} \u5339\u3092\u8868\u793a\u3002\u5168\u6ec5\u3057\u305f\u5bb6\u7cfb\u306f\u975e\u8868\u793a\u3067\u3059\u3002
`;
+ ui.archiveContent.innerHTML = `${note}${components.map((c, i) => lineageFamilyHtml(c, i, family)).join("")}
`;
+}
+
+function validateFamilyTree() {
+ const issues = world.validateFamily ? [...world.validateFamily()] : [];
+ const rendered = new Map();
+ document.querySelectorAll("[data-family-tarinai-id]").forEach(node => {
+ const id = node.getAttribute("data-family-tarinai-id");
+ rendered.set(id, (rendered.get(id) || 0) + 1);
+ });
+ for (const [id, count] of rendered.entries()) {
+ if (count > 1) issues.push({ type: "duplicate-render-node", id, count });
+ }
+ return issues;
+}
+
+function renderArchiveWindow() {
+ renderArchive();
+}
+
+function scheduleArchiveWindowRender() {
+ renderArchive();
+}
+
+window.validateFamilyTree = validateFamilyTree;
diff --git a/js/world.js b/js/world.js
new file mode 100644
index 0000000..9bf36e1
--- /dev/null
+++ b/js/world.js
@@ -0,0 +1,1525 @@
+"use strict";
+
+class World {
+ constructor() {
+ this.w = 1000;
+ this.h = 720;
+ this.viewportW = 1000;
+ this.viewportH = 720;
+ this.time = 0;
+ this.day = 1;
+ this.paused = false;
+ this.speed = 1;
+ this.tool = "observe";
+ this.toolSize = "medium";
+ this.toolSizes = {};
+ this.tarinai = [];
+ this.items = [];
+ this.effects = [];
+ this.logs = [];
+ this.eventCounters = {};
+ this.countsTimer = 0;
+ this.compactTimer = 0;
+ this.drawSortTimer = 0;
+ this.drawListDirty = true;
+ this.selected = null;
+ this.deadCount = 0;
+ this.maxGeneration = 1;
+ this.family = {};
+ this.familyVersion = 0;
+ this.relationNotices = {};
+ this.resolvedFightIds = {};
+ this.pointer = { x: 0, y: 0, inside: false };
+ this.lastPhase = "";
+ this.weather = "sunny";
+ this.fieldType = "garden";
+ this.fieldZoom = 1;
+ this.cameraX = 0;
+ this.cameraY = 0;
+ this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
+ this.spatial = new SpatialGrid(96);
+ this.spatialScratch = [];
+ this.drawList = [];
+ this.itemCounts = {};
+ this.effectCounts = {};
+ this.countsTimer = 0;
+ this.compactTimer = 0;
+ this.drawSortTimer = 0;
+ this.drawListDirty = true;
+ this.updateItemCounts();
+ this.updateEffectCounts();
+ this.rebuildSpatial();
+ }
+
+ dayProgress() {
+ return (this.time % CONFIG.dayLength) / CONFIG.dayLength;
+ }
+
+ lightLevel() {
+ return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2;
+ }
+
+ phaseName() {
+ const p = this.dayProgress();
+ if (p < 0.20) return "\u671d";
+ if (p < 0.48) return "\u663c";
+ if (p < 0.68) return "\u5915\u65b9";
+ return "\u591c";
+ }
+
+ clockString() {
+ const totalMinutes = Math.floor(this.dayProgress() * 24 * 60);
+ const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
+ const mm = String(totalMinutes % 60).padStart(2, "0");
+ return `${hh}:${mm}`;
+ }
+
+ fieldDefinition() {
+ return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden;
+ }
+
+ fieldWorldScale() {
+ const field = this.fieldDefinition();
+ // Field type defines the logical simulation area. Wheel zoom must not
+ // rewrite this size; it is a camera/display transform only.
+ return Math.max(0.22, field.worldScale || 1);
+ }
+
+ viewScale() {
+ return clamp(Number(this.fieldZoom) || 1, 0.42, 2.45);
+ }
+
+ visibleWorldW() {
+ return (this.viewportW || this.w) / this.viewScale();
+ }
+
+ visibleWorldH() {
+ return (this.viewportH || this.h) / this.viewScale();
+ }
+
+ screenSizeToWorld(size) {
+ // Kept as a compatibility shim for older hit-test call sites. The values
+ // currently passed here are already logical world units, not CSS pixels.
+ return size;
+ }
+
+ fieldScreenOffset() {
+ const scale = this.viewScale();
+ return {
+ x: Math.max(0, ((this.viewportW || this.w) - this.w * scale) / 2),
+ y: Math.max(0, ((this.viewportH || this.h) - this.h * scale) / 2),
+ };
+ }
+
+ maxCameraX() {
+ return Math.max(0, this.w - this.visibleWorldW());
+ }
+
+ maxCameraY() {
+ return Math.max(0, this.h - this.visibleWorldH());
+ }
+
+ clampCamera() {
+ this.cameraX = clamp(this.cameraX || 0, 0, this.maxCameraX());
+ this.cameraY = clamp(this.cameraY || 0, 0, this.maxCameraY());
+ }
+
+ panCamera(dx, dy) {
+ const scale = this.viewScale();
+ this.cameraX = (this.cameraX || 0) + dx / scale;
+ this.cameraY = (this.cameraY || 0) + dy / scale;
+ this.clampCamera();
+ }
+
+ screenToWorld(x, y) {
+ const scale = this.viewScale();
+ const off = this.fieldScreenOffset();
+ const rawX = (x - off.x) / scale + (this.cameraX || 0);
+ const rawY = (y - off.y) / scale + (this.cameraY || 0);
+ return {
+ x: clamp(rawX, 0, this.w),
+ y: clamp(rawY, 0, this.h),
+ inside: rawX >= 0 && rawY >= 0 && rawX <= this.w && rawY <= this.h,
+ };
+ }
+
+ worldToScreen(x, y) {
+ const scale = this.viewScale();
+ const off = this.fieldScreenOffset();
+ return {
+ x: (x - (this.cameraX || 0)) * scale + off.x,
+ y: (y - (this.cameraY || 0)) * scale + off.y,
+ };
+ }
+
+ setViewportSize(width, height, opts = {}) {
+ const oldW = this.w || width;
+ const oldH = this.h || height;
+ this.viewportW = Math.max(1, width || this.viewportW || 1000);
+ this.viewportH = Math.max(1, height || this.viewportH || 720);
+ const requestedScale = this.fieldWorldScale();
+ const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH);
+ this.w = this.viewportW * scale;
+ this.h = this.viewportH * scale;
+ this.clampCamera();
+ if (opts.scaleContents && oldW > 0 && oldH > 0) {
+ const sx = this.w / oldW;
+ const sy = this.h / oldH;
+ const scalePoint = (p) => {
+ if (!p) return;
+ if (Number.isFinite(p.x)) p.x *= sx;
+ if (Number.isFinite(p.y)) p.y *= sy;
+ };
+ for (const t of this.tarinai || []) scalePoint(t);
+ for (const it of this.items || []) scalePoint(it);
+ for (const ef of this.effects || []) scalePoint(ef);
+ scalePoint(this.pointer);
+ this.cameraX *= sx;
+ this.cameraY *= sy;
+ this.clampCamera();
+ this.rebuildSpatial();
+ this.drawListDirty = true;
+ }
+ }
+
+ setFieldZoom(nextZoom) {
+ const next = clamp(Number(nextZoom) || 1, 0.42, 2.45);
+ if (Math.abs(next - (this.fieldZoom || 1)) < 0.001) return false;
+ this.fieldZoom = next;
+ this.clampCamera();
+ this.drawListDirty = true;
+ return true;
+ }
+
+ edgeSpawnPoint(targetX = null, targetY = null, margin = 34) {
+ const w = Math.max(this.w || 1000, margin * 2 + 1);
+ const h = Math.max(this.h || 720, margin * 2 + 1);
+ let side;
+ if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
+ const distances = [targetY, w - targetX, h - targetY, targetX];
+ side = distances.indexOf(Math.min(...distances));
+ } else {
+ side = Math.floor(Math.random() * 4);
+ }
+ if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: margin, vx: rand(-10, 10), vy: rand(18, 34), angle: Math.PI / 2 };
+ if (side === 1) return { x: w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-34, -18), vy: rand(-10, 10), angle: Math.PI };
+ if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: h - margin, vx: rand(-10, 10), vy: rand(-34, -18), angle: -Math.PI / 2 };
+ return { x: margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(18, 34), vy: rand(-10, 10), angle: 0 };
+ }
+
+ toolSizeFor(type = "") {
+ if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return "medium";
+ return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
+ }
+
+ toolSizeScale(type = "") {
+ if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return 1;
+ return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1;
+ }
+
+ applyToolSize(item) {
+ if (!item) return item;
+ const scale = this.toolSizeScale(item.type);
+ const size = this.toolSizeFor(item.type);
+ item.toolSize = size;
+ this.toolSize = size;
+ item.r *= scale;
+ if (["sweet", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
+ if (item.type === "firecracker") item.blastScale = scale;
+ return item;
+ }
+
+ reset(seedPopulation = null, fieldType = this.fieldType || "garden") {
+ const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
+ this.fieldType = field.id;
+ this.fieldZoom = 1;
+ this.setViewportSize(this.viewportW, this.viewportH);
+ this.time = 0;
+ this.day = 1;
+ this.paused = false;
+ this.speed = 1;
+ this.toolSize = this.toolSize || "medium";
+ this.toolSizes = this.toolSizes || {};
+ this.tarinai = [];
+ this.items = [];
+ this.effects = [];
+ this.logs = [];
+ this.eventCounters = {};
+ this.countsTimer = 0;
+ this.compactTimer = 0;
+ this.drawSortTimer = 0;
+ this.drawListDirty = true;
+ this.selected = null;
+ this.deadCount = 0;
+ this.maxGeneration = 1;
+ this.family = {};
+ this.familyVersion = 0;
+ this.relationNotices = {};
+ this.resolvedFightIds = {};
+ this.pointer = { x: this.w / 2, y: this.h / 2, inside: false };
+ this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2);
+ this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2);
+ this.clampCamera();
+ this.lastPhase = this.phaseName();
+ this.weather = "sunny";
+ this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
+ seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
+ const grassCount = field.grass ?? 18;
+ const types = spawnableSprites().map(s => s.id);
+ for (let i = 0; i < seedPopulation; i++) {
+ this.addTarinai({
+ type: types[i % types.length],
+ x: rand(80, this.w - 80),
+ y: rand(80, this.h - 80),
+ generation: 1,
+ });
+ }
+ for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)));
+ this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
+ this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22));
+ this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78));
+ this.updateItemCounts();
+ this.updateEffectCounts();
+ this.rebuildSpatial();
+ this.log("\u305f\u308a\u306a\u3044\u3092\u89b3\u5bdf\u7bb1\u306b\u914d\u7f6e\u3057\u307e\u3057\u305f\u3002");
+ showToast("\u65b0\u3057\u3044\u89b3\u5bdf\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002");
+ syncTopButtons();
+ }
+
+
+ recordFamily(t) {
+ if (!this.family) this.family = {};
+ const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean)));
+ const sameList = (a, b) => {
+ const aa = uniqueIds(a);
+ const bb = uniqueIds(b);
+ return aa.length === bb.length && aa.every((id, i) => id === bb[i]);
+ };
+ let changed = false;
+ const nextParents = uniqueIds(t.parents || []);
+ const nextChildren = uniqueIds(t.children || []);
+ if (!this.family[t.id]) {
+ this.family[t.id] = {
+ id: t.id,
+ name: t.name,
+ type: t.type,
+ generation: t.generation,
+ hasPaired: !!t.hasPaired,
+ parents: nextParents,
+ parentNames: [...(t.parentNames || [])],
+ children: nextChildren,
+ birthTime: t.birthTime,
+ alive: !t.dead,
+ deathReason: t.deathReason || "",
+ };
+ changed = true;
+ } else {
+ const entry = this.family[t.id];
+ const nextAlive = !t.dead;
+ if (entry.name !== t.name) changed = true;
+ if (entry.type !== t.type) changed = true;
+ if (entry.hasPaired !== !!t.hasPaired) changed = true;
+ if (entry.generation !== t.generation) changed = true;
+ if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true;
+ if (entry.alive !== nextAlive) changed = true;
+ if (!sameList(entry.parents, nextParents)) changed = true;
+ if (!sameList(entry.children, nextChildren)) changed = true;
+ entry.name = t.name;
+ entry.type = t.type;
+ entry.alive = nextAlive;
+ entry.generation = t.generation;
+ entry.hasPaired = !!t.hasPaired;
+ entry.deathReason = t.deathReason || entry.deathReason || "";
+ entry.parents = nextParents;
+ entry.parentNames = [...(t.parentNames || entry.parentNames || [])];
+ entry.children = nextChildren;
+ }
+ if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
+ }
+
+ normalizeFamily() {
+ if (!this.family) this.family = {};
+ if (this.familyCleanVersion === (this.familyVersion || 0)) return false;
+ const family = this.family;
+ const liveById = new Map((this.tarinai || []).map(t => [t.id, t]));
+ let changed = false;
+ const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id)))));
+ const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
+ for (const t of this.tarinai || []) {
+ const before = this.familyVersion || 0;
+ this.recordFamily(t);
+ changed = changed || before !== (this.familyVersion || 0);
+ }
+ const known = new Set(Object.keys(family));
+ for (const id of Object.keys(family)) {
+ const n = family[id] || {};
+ const live = liveById.get(id);
+ const parents = uniqueIds(n.parents, known);
+ const children = uniqueIds(n.children, known);
+ if (live) {
+ const liveParents = uniqueIds(live.parents, known);
+ const liveChildren = uniqueIds(live.children, known);
+ for (const p of liveParents) if (!parents.includes(p)) parents.push(p);
+ for (const c of liveChildren) if (!children.includes(c)) children.push(c);
+ }
+ const next = {
+ id,
+ name: live?.name || n.name || id,
+ type: live?.type || n.type || "smile",
+ generation: Math.max(1, live?.generation || n.generation || 1),
+ hasPaired: !!(live?.hasPaired || n.hasPaired),
+ parents,
+ parentNames: live?.parentNames ? [...live.parentNames] : [...(n.parentNames || [])],
+ children,
+ birthTime: live?.birthTime ?? n.birthTime ?? 0,
+ alive: live ? !live.dead : n.alive !== false,
+ deathReason: live?.deathReason || n.deathReason || "",
+ };
+ for (const key of Object.keys(next)) {
+ const a = family[id]?.[key];
+ const b = next[key];
+ const equal = Array.isArray(a) && Array.isArray(b) ? sameList(a, b) : a === b;
+ if (!equal) changed = true;
+ }
+ family[id] = next;
+ }
+ for (const child of Object.values(family)) {
+ for (const parentId of child.parents || []) {
+ const parent = family[parentId];
+ if (!parent) continue;
+ if (!parent.children.includes(child.id)) {
+ parent.children.push(child.id);
+ changed = true;
+ }
+ }
+ }
+ for (const parent of Object.values(family)) {
+ const nextChildren = uniqueIds(parent.children, known).filter(childId => family[childId]?.parents?.includes(parent.id));
+ if (!sameList(parent.children || [], nextChildren)) {
+ parent.children = nextChildren;
+ changed = true;
+ }
+ }
+ for (const [id, live] of liveById.entries()) {
+ const n = family[id];
+ if (!n) continue;
+ if (!sameList(live.parents || [], n.parents || [])) {
+ live.parents = [...(n.parents || [])];
+ changed = true;
+ }
+ if (!sameList(live.children || [], n.children || [])) {
+ live.children = [...(n.children || [])];
+ changed = true;
+ }
+ if (live.hasPaired !== !!n.hasPaired) {
+ live.hasPaired = !!n.hasPaired;
+ changed = true;
+ }
+ if (live.generation !== n.generation) {
+ live.generation = n.generation;
+ changed = true;
+ }
+ }
+ if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
+ this.familyCleanVersion = this.familyVersion || 0;
+ return changed;
+ }
+
+ validateFamily() {
+ this.normalizeFamily();
+ const family = this.family || {};
+ const issues = [];
+ const seenRenderIds = new Set();
+ for (const n of Object.values(family)) {
+ if (!n?.id) continue;
+ const childSeen = new Set();
+ for (const p of n.parents || []) {
+ if (!family[p]) issues.push({ type: "missing-parent", id: n.id, parent: p });
+ else if (!family[p].children?.includes(n.id)) issues.push({ type: "missing-reciprocal-child", id: n.id, parent: p });
+ }
+ for (const c of n.children || []) {
+ if (childSeen.has(c)) issues.push({ type: "duplicate-child", id: n.id, child: c });
+ childSeen.add(c);
+ if (!family[c]) issues.push({ type: "missing-child", id: n.id, child: c });
+ else if (!family[c].parents?.includes(n.id)) issues.push({ type: "missing-reciprocal-parent", id: n.id, child: c });
+ }
+ if (seenRenderIds.has(n.id)) issues.push({ type: "duplicate-render-node", id: n.id });
+ seenRenderIds.add(n.id);
+ }
+ return issues;
+ }
+
+ markDead(t, reason) {
+ if (!this.family) this.family = {};
+ if (this.family[t.id]) {
+ if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== reason) {
+ this.family[t.id].alive = false;
+ this.family[t.id].deathReason = reason;
+ this.familyVersion = (this.familyVersion || 0) + 1;
+ }
+ }
+ this.normalizeFamily();
+ this.pruneExtinctFamilies();
+ }
+
+ pruneExtinctFamilies(opts = {}) {
+ if (opts.normalize !== false) this.normalizeFamily();
+ const family = this.family || {};
+ const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
+ if (!nodes.length) return;
+ const ids = new Set(nodes.map(n => n.id));
+ const graph = new Map();
+ const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); };
+ const link = (a, b) => {
+ if (!a || !b || !ids.has(a) || !ids.has(b)) return;
+ add(a); add(b);
+ graph.get(a).add(b);
+ graph.get(b).add(a);
+ };
+ for (const n of nodes) {
+ add(n.id);
+ for (const p of n.parents || []) link(n.id, p);
+ for (const c of n.children || []) link(n.id, c);
+ }
+ const seen = new Set();
+ const remove = [];
+ for (const n of nodes) {
+ if (seen.has(n.id)) continue;
+ const stack = [n.id];
+ const comp = [];
+ seen.add(n.id);
+ while (stack.length) {
+ const id = stack.pop();
+ comp.push(id);
+ for (const next of graph.get(id) || []) {
+ if (seen.has(next)) continue;
+ seen.add(next);
+ stack.push(next);
+ }
+ }
+ if (!comp.some(id => family[id]?.alive)) remove.push(...comp);
+ }
+ if (!remove.length) return;
+ for (const id of remove) delete family[id];
+ this.familyVersion = (this.familyVersion || 0) + 1;
+ this.familyCleanVersion = this.familyVersion || 0;
+ }
+
+ addTarinai(opts = {}) {
+ const t = new Tarinai(this, opts);
+ this.tarinai.push(t);
+ this.drawListDirty = true;
+ this.recordFamily(t);
+ this.maxGeneration = Math.max(this.maxGeneration, t.generation);
+ return t;
+ }
+
+ startBirthRitual(a, b) {
+ if (!a || !b || a.dead || b.dead) return false;
+ if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false;
+ const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length);
+ const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length);
+ if (aJoined && !bJoined) b.generation = a.generation;
+ else if (bJoined && !aJoined) a.generation = b.generation;
+ else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; }
+ a.hasPaired = true;
+ b.hasPaired = true;
+ this.recordFamily(a);
+ this.recordFamily(b);
+ const duration = 10.0;
+ a.birthRitualTimer = b.birthRitualTimer = duration;
+ a.birthRitualMax = b.birthRitualMax = duration;
+ a.birthPartnerId = b.id;
+ b.birthPartnerId = a.id;
+ a.birthRitualRole = -1;
+ b.birthRitualRole = 1;
+ a.birthRitualLeader = true;
+ b.birthRitualLeader = false;
+ a.state = b.state = "birth_ritual";
+ a.target = b;
+ b.target = a;
+ a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
+ b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
+ a.surpriseTimer = Math.max(a.surpriseTimer, 0.35);
+ b.surpriseTimer = Math.max(b.surpriseTimer, 0.35);
+ return true;
+ }
+
+ finishBirthRitual(a, b) {
+ if (!a || !b || a.dead || b.dead) return null;
+ a.birthRitualTimer = b.birthRitualTimer = 0;
+ a.birthPartnerId = b.birthPartnerId = null;
+ a.birthRitualLeader = b.birthRitualLeader = false;
+ a.state = b.state = "idle";
+ a.fightTimer = b.fightTimer = 0;
+ a.fightTargetIds = []; b.fightTargetIds = [];
+ a.fightTargetId = b.fightTargetId = null;
+ a.defeatedById = b.defeatedById = null;
+ a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18;
+ a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0);
+ const children = [];
+ const countRoll = Math.random();
+ const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1);
+ for (let i = 0; i < childCount; i++) {
+ const child = this.spawnChild(a, b, i, childCount);
+ if (child) { child.postBirthPeaceTimer = 18; children.push(child); }
+ }
+ if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b${children.length}\u5339\u306e\u5b50\u304c\u73fe\u308c\u305f\u3002`, "birth");
+ return children[0] || null;
+ }
+
+ spawnChild(a, b, index = 0, total = 1) {
+ const inherited = Math.random() < 0.56 ? a.type : b.type;
+ const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited;
+ const child = this.addTarinai({
+ type: mutation,
+ name: makeName(),
+ age: 0,
+ x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18,
+ y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0),
+ scale: rand(0.17, 0.23),
+ hunger: rand(20, 40),
+ loneliness: rand(30, 56),
+ energy: rand(55, 95),
+ stress: rand(0, 14),
+ need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS),
+ generation: Math.max(a.generation, b.generation) + 1,
+ lifeSpan: rand(1500, 2400),
+ parents: [a.id, b.id],
+ parentNames: [a.name, b.name],
+ birthTime: this.time,
+ });
+ this.maxGeneration = Math.max(this.maxGeneration, child.generation);
+ a.affection = 0; b.affection = 0;
+ a.loneliness *= 0.65; b.loneliness *= 0.65;
+ a.children.push(child.id);
+ b.children.push(child.id);
+ this.recordFamily(a);
+ this.recordFamily(b);
+ this.recordFamily(child);
+ if (this.family[a.id] && !this.family[a.id].children.includes(child.id)) this.family[a.id].children.push(child.id);
+ if (this.family[b.id] && !this.family[b.id].children.includes(child.id)) this.family[b.id].children.push(child.id);
+ this.normalizeFamily();
+ audio.birth();
+ if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b\u73fe\u308c\u305f\u3002`, "birth");
+ return child;
+ }
+
+ startConflict(a, b) {
+ if (!a || !b || a.dead || b.dead) return;
+ if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
+ if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return;
+ const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + rand(-5, 5);
+ const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + rand(-5, 5);
+ const actor = scoreA >= scoreB ? a : b;
+ const target = actor === a ? b : a;
+ const actorScore = actor === a ? scoreA : scoreB;
+ const targetScore = actor === a ? scoreB : scoreA;
+ const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
+ const intimidationChance = 0.40;
+ if (canIntimidate && Math.random() < intimidationChance) {
+ this.startIntimidation(actor, target, actorScore, targetScore);
+ return;
+ }
+ this.startFight(a, b);
+ }
+
+ startIntimidation(actor, target, actorScore = 0, targetScore = 0) {
+ if (!actor || !target || actor.dead || target.dead) return false;
+ if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
+ const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8;
+ const resistance = target.energy * 0.30 + target.mood * 0.16 + (target.personality === "irritable" ? 5 : 0) + rand(-5, 5);
+ const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96);
+ actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20);
+ target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
+ actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
+ target.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
+ actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35));
+ actor.intimidateTargetId = target.id;
+ actor.state = "intimidate";
+ this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
+ life: 0.42,
+ size: actor.radius * 0.55,
+ color: "rgba(145, 106, 55, 0.70)",
+ }));
+ this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
+ target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45));
+ target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
+ if (Math.random() >= successChance) {
+ target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate");
+ return true;
+ }
+ target.defeatedById = actor.id;
+ target.fightWinnerId = actor.id;
+ target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2));
+ target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear);
+ target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4));
+ target.state = "panic";
+ target.target = actor;
+ this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
+ target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54);
+ target.vy += rand(-18, 18);
+ actor.adjustRelation(target, -0.08, 0.06, "intimidate");
+ target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate");
+ if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) {
+ this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight");
+ actor.lastLog = target.lastLog = this.time;
+ }
+ return true;
+ }
+
+ startFight(a, b) {
+ if (!a || !b || a.dead || b.dead) return;
+ if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
+ a.state = "fight"; b.state = "fight";
+ a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0));
+ b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0));
+ a.nextHeadbutt = this.time + rand(0.08, 0.18);
+ b.nextHeadbutt = a.nextHeadbutt;
+ a.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
+ b.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
+ if (!a.fightTargetIds) a.fightTargetIds = [];
+ if (!b.fightTargetIds) b.fightTargetIds = [];
+ if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id);
+ if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id);
+ a.fightTargetIds = a.fightTargetIds.slice(-4);
+ b.fightTargetIds = b.fightTargetIds.slice(-4);
+ a.fightTargetId = a.fightTargetIds[0];
+ b.fightTargetId = b.fightTargetIds[0];
+ const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + rand(-7, 7);
+ const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + rand(-7, 7);
+ const loser = aScore <= bScore ? a : b;
+ const winner = loser === a ? b : a;
+ loser.defeatedById = winner.id;
+ loser.fightWinnerId = winner.id;
+ winner.fightWinnerId = winner.id;
+ winner.defeatedById = null;
+ winner.defeatedTimer = 0;
+ a.stress = clamp(a.stress + rand(8, 18), 0, 130);
+ b.stress = clamp(b.stress + rand(8, 18), 0, 130);
+ a.damage(rand(2, 7));
+ b.damage(rand(2, 7));
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const d = Math.hypot(dx, dy) || 1;
+ a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62);
+ b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62);
+ a.surpriseTimer = 0.24; b.surpriseTimer = 0.24;
+ this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, {
+ size: rand(12, 20),
+ life: rand(0.32, 0.52),
+ color: "rgba(116, 73, 38, 0.82)",
+ }));
+ audio.fight();
+ if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
+ this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight");
+ a.lastLog = b.lastLog = this.time;
+ }
+ }
+
+ explodeFirecracker(it) {
+ if (!it || it.dead || it.type !== "firecracker") return;
+ it.amount = 0;
+ const x = it.x, y = it.y;
+ const blastRadius = 240 * (it.blastScale || 1);
+ this.effects.push(new Effect("explosion", x, y, { size: 86, life: 1.05, color: "rgba(255,182,66,0.92)" }));
+ this.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" }));
+ for (let r = 0; r < 4; r++) {
+ this.effects.push(new Effect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" }));
+ }
+ for (let i = 0; i < 34; i++) {
+ const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10);
+ const speed = rand(90, 260);
+ this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), {
+ vx: Math.cos(a) * speed,
+ vy: Math.sin(a) * speed,
+ size: rand(8, i % 3 === 0 ? 24 : 20),
+ life: rand(0.26, 0.72),
+ color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
+ }));
+ }
+ for (const t of this.tarinai) {
+ if (t.dead) continue;
+ t.stress = clamp(t.stress + rand(8, 15), 0, 130);
+ t.fearTimer = Math.max(t.fearTimer, 0.58);
+ const dx = t.x - x;
+ const dy = t.y - y;
+ const d = Math.hypot(dx, dy) || 1;
+ if (d > blastRadius) continue;
+ const p = clamp(1 - d / blastRadius, 0, 1);
+ t.damage(5 + p * 18, "\u7206\u7af9\u306b\u9a5a\u3044\u305f");
+ t.stress = clamp(t.stress + 22 * p, 0, 130);
+ t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
+ t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
+ t.fearTimer = Math.max(t.fearTimer, 1.7 + p);
+ t.state = "panic";
+ t.target = { x, y };
+ const power = 170 + p * 360;
+ t.vx += dx / d * power + rand(-55, 55);
+ t.vy += dy / d * power + rand(-55, 55);
+ t.fallTimer = Math.max(t.fallTimer, 1.25 + p * 1.35);
+ t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
+ t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
+ t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70);
+ t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
+ this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p);
+ if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
+ }
+ audio.explode();
+ this.log("\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident");
+ }
+
+ spawnZunchi(x, y) {
+ const zunchiLimit = CONFIG.zunchiLimit ?? 56;
+ if ((this.itemCounts.zunchi || 0) >= zunchiLimit) {
+ let oldest = null;
+ for (const it of this.items) {
+ if (it.type !== "zunchi" || it.dead) continue;
+ if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it;
+ }
+ if (oldest) oldest.amount = 0;
+ }
+ const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40));
+ it.amount = 260;
+ this.items.push(it);
+ this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
+ this.spawnBubble(it.x, it.y - 16, "\u3076\u308a\u3085\u3063", "rgba(62,84,45,0.78)");
+ }
+
+ spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") {
+ if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return;
+ if (text === "z") text = "Zzz...";
+ audio.bubble(text);
+ this.effects.push(new Effect("bubble", x, y, {
+ vx: rand(-2, 2),
+ vy: rand(-5, -2),
+ life: rand(2.2, 3.2),
+ size: 8,
+ text,
+ color,
+ }));
+ this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1;
+ }
+
+ spawnHeadbuttEffect(x, y) {
+ this.effects.push(new Effect("fight", x, y, {
+ size: rand(10, 16),
+ life: rand(0.20, 0.34),
+ color: "rgba(105, 62, 34, 0.86)",
+ }));
+ if (Math.random() < 0.5) audio.fight();
+ }
+
+ spawnFallEffect(x, y, scale = 1) {
+ if ((this.effectCounts.fall || 0) > 18) return;
+ this.effects.push(new Effect("fall", x, y, {
+ vx: rand(-12, 12),
+ vy: rand(-4, 3),
+ size: rand(20, 32) * scale,
+ life: rand(0.48, 0.72),
+ color: "rgba(154, 124, 80, 0.58)",
+ }));
+ }
+
+ spawnEatEffect(x, y, color = "#f1dfb7") {
+ if (Math.random() < 0.70) {
+ this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), {
+ vx: rand(-10, 10),
+ vy: rand(-18, -3),
+ size: rand(2.2, 4.2),
+ life: rand(0.18, 0.32),
+ color,
+ }));
+ }
+ if (Math.random() < 0.08) {
+ this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color }));
+ }
+ }
+
+ spawnBleedEffect(x, y) {
+ this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), {
+ vx: rand(-8, 8),
+ vy: rand(3, 14),
+ size: rand(2.0, 3.8),
+ life: rand(0.34, 0.62),
+ color: "rgba(80, 172, 55, 0.78)",
+ }));
+ }
+
+ bedOccupancy(bed) {
+ if (!bed || bed.type !== "bed") return 0;
+ let n = 0;
+ const radius = Math.max(86, bed.r * 2.45);
+ for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) {
+ if (t.dead) continue;
+ if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1;
+ }
+ return n;
+ }
+
+ bedComfort(bed) {
+ if (!bed || bed.type !== "bed") return 0.7;
+ const occ = this.bedOccupancy(bed);
+ return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
+ }
+
+ bestBedFor(t, maxDist = 360) {
+ let best = null;
+ let bestScore = Infinity;
+ for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
+ if (bed.dead || bed.type !== "bed") continue;
+ if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
+ const d = dist(t, bed);
+ const occ = this.bedOccupancy(bed);
+ const comfort = this.bedComfort(bed);
+ const score = d - comfort * 46 + Math.max(0, occ - 3) * 34;
+ if (score < bestScore) { best = bed; bestScore = score; }
+ }
+ return best;
+ }
+
+ relationNotice(aId, bId, kind, cooldown = 30) {
+ const key = [aId, bId].sort().join(":") + `:${kind}`;
+ const last = this.relationNotices?.[key] || -Infinity;
+ if (this.time - last < cooldown) return false;
+ if (!this.relationNotices) this.relationNotices = {};
+ this.relationNotices[key] = this.time;
+ return true;
+ }
+
+ resolveFightOutcome(winner, loser) {
+ if (!winner || !loser || winner.dead || loser.dead) return;
+ const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`;
+ if (!this.resolvedFightIds) this.resolvedFightIds = {};
+ if (this.resolvedFightIds[key]) return;
+ this.resolvedFightIds[key] = true;
+ winner.defeatedById = null;
+ winner.defeatedTimer = 0;
+ winner.fearTimer = Math.max(0, winner.fearTimer - 0.75);
+ winner.fightTargetIds = [];
+ winner.fightTargetId = null;
+ winner.adjustRelation(loser, -0.6, 0.02, "\u55a7\u5629\u306b\u52dd\u3063\u305f");
+ loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f");
+ winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1;
+ loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1;
+ loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear);
+ this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
+ winner.affection = clamp(winner.affection + 0.8, 0, 80);
+ winner.stress = clamp(winner.stress - rand(9, 18), 0, 130);
+ if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) {
+ this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight");
+ }
+ }
+
+ itemDropImpact(it) {
+ if (!it || it.dead) return;
+ const isStone = it.type === "stone";
+ const radius = isStone ? 64 : Math.max(34, (it.r || 14) * 1.75);
+ const damage = isStone ? rand(20, 34) : rand(3, 7);
+ const push = isStone ? rand(145, 210) : rand(54, 92);
+ let hit = 0;
+ for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) {
+ if (!t || t.dead) continue;
+ const d = Math.max(1, distXY(t.x, t.y, it.x, it.y));
+ const p = clamp(1 - d / (radius + t.radius), 0, 1);
+ if (p <= 0) continue;
+ const nx = (t.x - it.x) / d;
+ const ny = (t.y - it.y) / d;
+ t.vx += nx * push * (0.35 + p);
+ t.vy += ny * push * (0.35 + p) - (isStone ? 42 : 18) * p;
+ t.damage(damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f");
+ t.hurtTimer = Math.max(t.hurtTimer, isStone ? 2.7 : 1.25);
+ t.fallTimer = Math.max(t.fallTimer, isStone ? 0.62 : 0.26);
+ t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer);
+ t.fallDir = nx >= 0 ? 1 : -1;
+ hit += 1;
+ }
+ 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) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45);
+ if (isStone && hit > 0) audio.poke();
+ if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
+ }
+
+ nearest(entity, types, maxDist = Infinity) {
+ let best = null, bestD = maxDist;
+ const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
+ for (const it of candidates) {
+ if (it.dead || !types.includes(it.type)) continue;
+ if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue;
+ const d = dist(entity, it);
+ if (d < bestD) { best = it; bestD = d; }
+ }
+ return best;
+ }
+
+ nearestOther(entity, maxDist = Infinity) {
+ let best = null, bestD = maxDist;
+ const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai;
+ for (const o of candidates) {
+ if (o === entity || o.dead) continue;
+ if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue;
+ const d = dist(entity, o);
+ if (d < bestD) { best = o; bestD = d; }
+ }
+ return best;
+ }
+
+ temperatureAt(x, y) {
+ let temp = 0.54 + Math.sin(this.time / 55) * 0.05;
+ for (const it of this.nearbyItems(x, y, 190)) {
+ if (it.type === "stone") {
+ const d = distXY(x, y, it.x, it.y);
+ temp += clamp(1 - d / 190, 0, 1) * 0.35;
+ }
+ }
+ return clamp(temp, 0, 1);
+ }
+
+
+ fenceRect(it) {
+ const vertical = it?.type === "fence_v";
+ const horizontal = it?.type === "fence_h";
+ if (!vertical && !horizontal) return null;
+ const len = Math.max(82, (it.r || 42) * 1.95);
+ const thick = Math.max(20, (it.r || 42) * 0.43);
+ const halfW = vertical ? thick : len;
+ const halfH = vertical ? len : thick;
+ return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal };
+ }
+
+ resolveFenceCollision(t) {
+ if (!t || t.dead) return;
+ const rr = Math.max(8, t.radius * 0.74);
+ for (const it of this.nearbyItems(t.x, t.y, rr + 92)) {
+ if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue;
+ const r = this.fenceRect(it);
+ if (!r) continue;
+ const cx = clamp(t.x, r.left, r.right);
+ const cy = clamp(t.y, r.top, r.bottom);
+ let dx = t.x - cx;
+ let dy = t.y - cy;
+ let d = Math.hypot(dx, dy);
+ if (d >= rr) continue;
+ if (d < 0.001) {
+ const left = Math.abs(t.x - r.left);
+ const right = Math.abs(r.right - t.x);
+ const top = Math.abs(t.y - r.top);
+ const bottom = Math.abs(r.bottom - t.y);
+ const m = Math.min(left, right, top, bottom);
+ if (m === left) { dx = -1; dy = 0; d = 1; }
+ else if (m === right) { dx = 1; dy = 0; d = 1; }
+ else if (m === top) { dx = 0; dy = -1; d = 1; }
+ else { dx = 0; dy = 1; d = 1; }
+ }
+ const push = rr - d + 0.8;
+ t.x += dx / d * push;
+ t.y += dy / d * push;
+ if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
+ else t.vy *= -0.18;
+ }
+ }
+
+
+
+ segmentIntersectsRect(x1, y1, x2, y2, r) {
+ if (!r) return false;
+ if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true;
+ const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => {
+ const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px);
+ return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy);
+ };
+ return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top)
+ || intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom)
+ || intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom)
+ || intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top);
+ }
+
+ pathBlockedByFence(x1, y1, x2, y2, padding = 16) {
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120;
+ for (const it of this.items) {
+ if (it.dead || (it.type !== "fence_v" && it.type !== "fence_h")) continue;
+ if (distXY(cx, cy, it.x, it.y) > maxD + 110) continue;
+ const base = this.fenceRect(it);
+ if (!base) continue;
+ const r = { left: base.left - padding, right: base.right + padding, top: base.top - padding, bottom: base.bottom + padding };
+ if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
+ }
+ return false;
+ }
+
+ grassBlockedAt(x, y, self = null) {
+ for (const it of this.nearbyItems(x, y, 52)) {
+ if (it === self || it.dead) continue;
+ if (it.type === "fence_v" || it.type === "fence_h") {
+ const r = this.fenceRect(it);
+ if (r && x >= r.left - 10 && x <= r.right + 10 && y >= r.top - 10 && y <= r.bottom + 10) return true;
+ }
+ if (it.type !== "zunchi") continue;
+ const dx = (x - it.x) / Math.max(22, it.r * 1.35);
+ const dy = (y - it.y) / Math.max(12, it.r * 0.82);
+ if (dx * dx + dy * dy < 1) return true;
+ }
+ return false;
+ }
+
+ grassCrowdedAt(x, y, minGap = 30, self = null) {
+ for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) {
+ if (it === self || it.dead || it.type !== "grass") continue;
+ if (distXY(x, y, it.x, it.y) < minGap) return true;
+ }
+ return false;
+ }
+
+ grassOnTarinaiAt(x, y, minGap = 24) {
+ for (const t of this.nearbyTarinai(x, y, minGap + 36)) {
+ if (t.dead) continue;
+ if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true;
+ }
+ return false;
+ }
+
+ grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) {
+ if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false;
+ if (this.grassBlockedAt(x, y, self)) return false;
+ if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false;
+ if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false;
+ return true;
+ }
+
+ findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) {
+ const cx = clamp(x, 44, this.w - 44);
+ const cy = clamp(y, 44, this.h - 44);
+ if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy };
+ const blockedAtCenter = this.grassBlockedAt(cx, cy);
+ const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius;
+ const outer = Math.max(maxRadius, inner + 42);
+ const golden = Math.PI * (3 - Math.sqrt(5));
+ for (let i = 0; i < attempts; i++) {
+ const t = attempts <= 1 ? 1 : i / (attempts - 1);
+ const radius = lerp(inner, outer, Math.sqrt(t));
+ const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2;
+ const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44);
+ const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
+ if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py };
+ }
+ return null;
+ }
+
+ findGrassSproutSpot(source, fertile = false) {
+ const attempts = fertile ? 24 : 10;
+ const minR = fertile ? Math.max(30, source.r * 1.9) : 18;
+ const maxR = fertile ? 125 : 72;
+ for (let i = 0; i < attempts; i++) {
+ const angle = rand(0, Math.PI * 2);
+ const radius = rand(minR, maxR);
+ const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44);
+ const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
+ if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y };
+ }
+ return null;
+ }
+
+ performanceLevel() {
+ const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60;
+ if (!fps || !Number.isFinite(fps)) return 0;
+ if (fps < 22) return 3;
+ if (fps < 34) return 2;
+ if (fps < 48) return 1;
+ return 0;
+ }
+
+ grassLoadLevel() {
+ const grass = this.itemCounts?.grass || 0;
+ const perf = this.performanceLevel();
+ if (perf >= 3 || grass >= 180) return 3;
+ if (perf >= 2 || grass >= 120) return 2;
+ if (perf >= 1 || grass >= 72) return 1;
+ return 0;
+ }
+
+ grassUpdateFactor() {
+ return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0;
+ }
+
+ sortedDrawList() {
+ if (!this.drawList) this.drawList = [];
+ if (this.drawListDirty || this.drawList.length !== this.tarinai.length) {
+ this.drawList.length = 0;
+ for (const t of this.tarinai) if (!t.dead) this.drawList.push(t);
+ this.drawList.sort((a, b) => a.y - b.y);
+ this.drawListDirty = false;
+ }
+ return this.drawList;
+ }
+
+ nearbyItems(x, y, radius) {
+ return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialScratch);
+ }
+
+ nearbyTarinai(x, y, radius) {
+ return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialScratch);
+ }
+
+ rebuildSpatial() {
+ this.spatial.rebuild(this.items, this.tarinai);
+ }
+
+ updateItemCounts() {
+ const counts = this.itemCounts;
+ for (const key of Object.keys(counts)) counts[key] = 0;
+ for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1;
+ }
+
+ updateEffectCounts() {
+ const counts = this.effectCounts;
+ for (const key of Object.keys(counts)) counts[key] = 0;
+ for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1;
+ }
+
+ compactItems() {
+ this.enforceItemBudget();
+ const before = this.items.length;
+ let write = 0;
+ for (let read = 0; read < this.items.length; read++) {
+ const it = this.items[read];
+ if (it.type === "mirror") continue;
+ if (!it.dead) this.items[write++] = it;
+ }
+ this.items.length = write;
+ return before !== this.items.length;
+ }
+
+ enforceItemBudget() {
+ const pruneOldest = (type, limit) => {
+ if (!Number.isFinite(limit) || limit < 0) return;
+ let count = 0;
+ for (const it of this.items) if (it.type === type && !it.dead) count += 1;
+ if (count <= limit) return;
+ const victims = this.items
+ .filter(it => it.type === type && !it.dead)
+ .sort((a, b) => (b.age || 0) - (a.age || 0))
+ .slice(0, count - limit);
+ for (const it of victims) it.amount = 0;
+ };
+ pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56);
+ pruneOldest("trace", CONFIG.traceLimit ?? 64);
+ pruneOldest("splat", CONFIG.splatLimit ?? 48);
+ pruneOldest("grass", CONFIG.grassLimit ?? 96);
+
+ const limit = CONFIG.itemLimit ?? Infinity;
+ if (!Number.isFinite(limit) || this.items.length <= limit) return;
+ const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7 };
+ const removable = this.items
+ .filter(it => !it.dead && (priority[it.type] || 99) < 99)
+ .sort((a, b) => (priority[a.type] || 99) - (priority[b.type] || 99) || (b.age || 0) - (a.age || 0));
+ let extra = this.items.length - limit;
+ for (const it of removable) {
+ if (extra <= 0) break;
+ it.amount = 0;
+ extra -= 1;
+ }
+ }
+
+ compactEffects() {
+ const limit = CONFIG.effectLimit ?? 96;
+ if (Number.isFinite(limit) && this.effects.length > limit) {
+ this.effects.sort((a, b) => (a.life / Math.max(a.maxLife || 1, 0.001)) - (b.life / Math.max(b.maxLife || 1, 0.001)));
+ const remove = this.effects.length - limit;
+ for (let i = 0; i < remove; i++) this.effects[i].life = 0;
+ }
+ let write = 0;
+ for (let read = 0; read < this.effects.length; read++) {
+ const ef = this.effects[read];
+ if (!ef.dead) this.effects[write++] = ef;
+ }
+ this.effects.length = write;
+ }
+
+ compactTarinai() {
+ let write = 0;
+ for (let read = 0; read < this.tarinai.length; read++) {
+ const t = this.tarinai[read];
+ if (!t.dead) this.tarinai[write++] = t;
+ }
+ if (this.tarinai.length !== write) this.drawListDirty = true;
+ this.tarinai.length = write;
+ }
+
+ update(dt) {
+ if (this.paused) return;
+ dt *= this.speed;
+ dt = Math.min(dt, 0.12 * this.speed);
+
+ this.time += dt;
+ this.day = Math.floor(this.time / CONFIG.dayLength) + 1;
+ this.updateWeather(dt);
+ this.rebuildSpatial();
+
+ const perf = this.performanceLevel();
+ const itemCountBefore = this.items.length;
+ for (const it of this.items) it.update(dt, this);
+
+ for (const ef of this.effects) ef.update(dt);
+
+ for (const t of this.tarinai) {
+ t.update(dt);
+ }
+
+ this.compactTimer += dt;
+ const compactInterval = perf >= 2 ? 0.95 : 0.58;
+ let itemsCompacted = false;
+ if (this.compactTimer >= compactInterval) {
+ itemsCompacted = this.compactItems();
+ this.compactEffects();
+ this.compactTarinai();
+ this.compactTimer = 0;
+ }
+
+ this.countsTimer += dt;
+ const countsInterval = perf >= 2 ? 0.85 : 0.42;
+ if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) {
+ this.updateItemCounts();
+ this.updateEffectCounts();
+ this.countsTimer = 0;
+ }
+
+ this.drawSortTimer += dt;
+ const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07;
+ if (this.drawSortTimer >= sortInterval) {
+ this.drawListDirty = true;
+ this.drawSortTimer = 0;
+ }
+
+ const phase = this.phaseName();
+ if (phase !== this.lastPhase) {
+ this.lastPhase = phase;
+ audio.phase();
+ }
+
+ if (this.tarinai.length > 0 && this.tarinai.length < 5 && Math.random() < dt * 0.03) {
+ const spot = this.edgeSpawnPoint();
+ const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId() });
+ t.wanderAngle = spot.angle;
+ this.log("\u3069\u3053\u304b\u304b\u3089\u305f\u308a\u306a\u3044\u304c\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002");
+ }
+
+
+ const grassCount = this.itemCounts.grass || 0;
+ if (grassCount < Math.min(24, CONFIG.grassLimit ?? 96) && Math.random() < dt * 0.036) {
+ const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), {
+ allowOriginal: true,
+ minRadius: 28,
+ maxRadius: 140,
+ attempts: 24,
+ });
+ if (spot) {
+ const sprout = new Item("grass", spot.x, spot.y);
+ sprout.amount = rand(22, 48);
+ sprout.growth = rand(0.12, 0.30);
+ this.items.push(sprout);
+ this.itemCounts.grass = grassCount + 1;
+ }
+ }
+
+ if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 7 && Math.random() < dt * 0.035) {
+ const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58));
+ drop.amount = rand(18, 40);
+ this.items.push(drop);
+ this.itemCounts.water = (this.itemCounts.water || 0) + 1;
+ }
+
+ if (Math.random() < dt * 0.004) {
+ const entries = [
+ "\u4f55\u5339\u304b\u304c\u3001\u5168\u54e1\u3067\u306f\u306a\u3044\u304c\u3001\u540c\u3058\u65b9\u5411\u3092\u898b\u305f\u3002",
+ "\u98df\u3079\u7269\u306f\u3042\u308b\u3002\u8db3\u308a\u3066\u3044\u308b\u304b\u306f\u5225\u554f\u984c\u3002",
+ "\u4e00\u5339\u304c\u7720\u308a\u3001\u8fd1\u304f\u306e\u5225\u306e\u4e00\u5339\u3082\u7720\u308b\u3053\u3068\u3092\u8003\u3048\u305f\u3002",
+ "\u8349\u306e\u8fd1\u304f\u306b\u3001\u77ed\u304f\u3066\u610f\u5473\u306e\u306a\u3044\u5217\u304c\u3067\u304d\u305f\u3002",
+ `${this.phaseName()}\u3002\u6c17\u5206\u306f\u3086\u3063\u304f\u308a\u5909\u308f\u3063\u3066\u3044\u308b\u3002`
+ ];
+ this.log(pick(entries));
+ }
+ }
+
+ updateWeather(dt) {
+ this.nextWeatherChange -= dt;
+ if (this.nextWeatherChange > 0) return;
+ const current = this.weather || "sunny";
+ const choices = current === "sunny"
+ ? ["sunny", "cloudy", "light_rain"]
+ : current === "cloudy"
+ ? ["sunny", "cloudy", "light_rain"]
+ : ["sunny", "cloudy"];
+ this.weather = pick(choices);
+ this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
+ this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`);
+ }
+
+ placeItem(item, dropped = true) {
+ if (!item) return null;
+ if (dropped) {
+ item.dropMax = item.type === "stone" ? 0.72 : 0.58;
+ item.dropTimer = item.dropMax;
+ item.dropImpactDone = false;
+ }
+ this.items.push(item);
+ this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
+ this.rebuildSpatial();
+ return item;
+ }
+
+ handleClick(x, y) {
+ const tool = this.tool;
+ if (tool === "observe") {
+ let found = null;
+ for (let i = this.tarinai.length - 1; i >= 0; i--) {
+ if (this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; }
+ }
+ this.selected = found;
+ if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`);
+ return;
+ }
+
+ if (tool === "poke") {
+ const target = [...this.tarinai].reverse().find(t => t.contains(x, y));
+ if (target) {
+ target.poke();
+ if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`);
+ } else {
+ this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002");
+ }
+ return;
+ }
+
+ if (tool === "new") {
+ const spot = this.edgeSpawnPoint(x, y);
+ const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1 });
+ t.wanderAngle = spot.angle;
+ audio.birth();
+ this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
+ return;
+ }
+
+ if (tool === "delete") {
+ let best = null, bestD = Infinity;
+ for (let i = this.items.length - 1; i >= 0; i--) {
+ const it = this.items[i];
+ if (it.dead) continue;
+ const visualHit = Math.max(22, (it.r || 12) * 2.0);
+ const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit;
+ const d = distXY(x, y, it.x, it.y);
+ if (d < hit && d < bestD) { best = it; bestD = d; }
+ }
+ if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
+ best.amount = 0;
+ this.compactItems();
+ this.updateItemCounts();
+ this.rebuildSpatial();
+ const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
+ this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
+ return;
+ }
+
+ const itemType = {
+ zunchi: "zunchi", sweet: "sweet", water: "water", grass: "grass",
+ stone: "stone", bed: "bed", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
+ }[tool];
+ if (itemType) {
+ let px = x, py = y;
+ if (itemType === "grass") {
+ const spot = this.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 48 });
+ if (!spot) {
+ showToast("\u8349\u306b\u306f\u7a7a\u3044\u305f\u571f\u304c\u5fc5\u8981\u3067\u3059\u3002");
+ this.log("\u305d\u3053\u306b\u306f\u8349\u304c\u690d\u3048\u3089\u308c\u308b\u7a7a\u304d\u5730\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
+ return;
+ }
+ px = spot.x;
+ py = spot.y;
+ }
+ this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true);
+ const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
+ this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
+ }
+ }
+
+ inferLogKind(text) {
+ const s = String(text || "").toLowerCase();
+ if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident";
+ if (s.includes("fight") || s.includes("lost to") || s.includes("poked") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051") || s.includes("\u3064\u3064\u304b")) return "fight";
+ if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth";
+ if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:")) return "death";
+ if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather";
+ if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food";
+ if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass";
+ if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe";
+ if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation";
+ return "note";
+ }
+
+ log(text, kind = null) {
+ const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) };
+ if (!this.eventCounters) this.eventCounters = {};
+ this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
+ this.logs.unshift(entry);
+ this.logs = this.logs.slice(0, 100);
+ renderLog(this.logs);
+ }
+
+ serialize() {
+ return {
+ w: this.w, h: this.h, time: this.time, day: this.day, deadCount: this.deadCount,
+ maxGeneration: this.maxGeneration,
+ tarinai: this.tarinai.map(t => t.serialize()),
+ items: this.items.map(i => i.serialize()),
+ logs: this.logs.slice(0, 30),
+ family: this.family || {},
+ weather: this.weather || "sunny",
+ fieldType: this.fieldType || "garden",
+ fieldZoom: this.fieldZoom || 1,
+ cameraX: this.cameraX || 0,
+ cameraY: this.cameraY || 0,
+ eventCounters: this.eventCounters || {},
+ toolSize: this.toolSize || "medium",
+ toolSizes: this.toolSizes || {},
+ nextWeatherChange: this.nextWeatherChange || CONFIG.weatherChangeMin,
+ };
+ }
+
+ load(data) {
+ this.time = data.time || 0;
+ this.day = data.day || 1;
+ this.deadCount = data.deadCount || 0;
+ this.eventCounters = data.eventCounters || {};
+ this.toolSize = data.toolSize || this.toolSize || "medium";
+ this.toolSizes = data.toolSizes || this.toolSizes || {};
+ this.maxGeneration = data.maxGeneration || 1;
+ this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden";
+ this.fieldZoom = clamp(Number(data.fieldZoom) || 1, 0.42, 2.45);
+ this.cameraX = Number(data.cameraX) || 0;
+ this.cameraY = Number(data.cameraY) || 0;
+ this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o));
+ this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food").map(o => Item.from(o));
+ this.effects = [];
+ this.compactItems();
+ this.compactEffects();
+ this.logs = (data.logs || []).map(e => typeof e === "string" ? { time: this.time, text: e, kind: this.inferLogKind(e) } : { time: e.time || this.time, text: e.text || "", kind: e.kind || this.inferLogKind(e.text || "") });
+ this.family = data.family || {};
+ this.familyCleanVersion = null;
+ this.familyVersion = (this.familyVersion || 0) + 1;
+ this.weather = data.weather || "sunny";
+ this.nextWeatherChange = data.nextWeatherChange || rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
+ this.relationNotices = {};
+ this.resolvedFightIds = {};
+ for (const t of this.tarinai) this.recordFamily(t);
+ this.normalizeFamily();
+ this.pruneExtinctFamilies({ normalize: false });
+ this.lastPhase = this.phaseName();
+ this.selected = null;
+ this.updateItemCounts();
+ this.updateEffectCounts();
+ this.rebuildSpatial();
+ this.log("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
+ }
+}
+
+const world = new World();