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();