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/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..2c52a91 Binary files /dev/null and b/assets/sprites/tarinai_26_intimidate.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_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..585230f 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..6afa389 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..c774b18 --- /dev/null +++ b/css/styles.css @@ -0,0 +1,495 @@ +: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; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: 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.02em; +} + +.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: 620px; +} + +#gameCanvas { + width: 100%; + height: calc(100vh - 140px); + min-height: 620px; + display: block; + cursor: crosshair; +} + +.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; + max-height: calc(100vh - 120px); + overflow: auto; + padding-right: 3px; +} + +.card { + 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.02em; +} + +.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="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); } +.tool[data-tool="poke"] { --tool-icon: url("../assets/ui/tool_poke.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; +} + +.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: 210px; } +.log { + max-height: 280px; + 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: 66vh; min-height: 460px; } + .stage-wrap { min-height: 460px; } +} + + + + +.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: 360px; +} + +.lineage-main-card h2 { + margin-bottom: 6px; +} + +.lineage-main-content { + max-height: min(64vh, 620px); + min-height: 260px; + overflow: auto; + overscroll-behavior: contain; + 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; +} + + +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: 26px minmax(0, 1fr); + align-items: center; + gap: 6px; + min-width: 0; + min-height: 42px; + padding: 5px 7px; + border-radius: 11px; + 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.dead { opacity: 0.68; filter: grayscale(0.35); } +.family-node.missing { opacity: 0.48; border-style: dashed; } +.family-node img { width: 26px; height: 26px; 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 strong { font-size: 12px; } +.family-copy span { color: var(--muted); font-size: 9px; margin-top: 2px; } +@media (max-width: 760px) { + .family-grid { grid-template-columns: 1fr; } + .family-cluster { min-height: 102px; } +} + + diff --git a/docs/design_notes.md b/docs/design_notes.md new file mode 100644 index 0000000..aa580fe --- /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..16eaa1b --- /dev/null +++ b/index.html @@ -0,0 +1,91 @@ + + + + + + たりない観察箱 + + + +
+
+
+

たりない観察箱

+

たりないが暮らす小さな庭を観察します。食べ、眠り、関係を変えます。

+
+
+ + + + + + +
+
+ +
+
+ + +
+ + +
+ +
+

家系図

+

子どもは成長するまで親についていきます。この領域をスクロールして家系を確認できます。

+
+
+
+ + + + + + + + + + + diff --git a/js/assets.js b/js/assets.js new file mode 100644 index 0000000..4796051 --- /dev/null +++ b/js/assets.js @@ -0,0 +1,56 @@ +"use strict"; +const images = new Map(); +const imageBoundsCache = new WeakMap(); +const imageMetrics = new Map(); +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, loadImages, imageAlphaBounds, getImageMetrics }; diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..fb7c90b --- /dev/null +++ b/js/audio.js @@ -0,0 +1,43 @@ +"use strict"; +const audio = { + enabled: false, + ctx: null, + 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(); + return this.ctx; + }, + toggle() { + this.enabled = !this.enabled; + if (this.enabled) this.ensure(); + return this.enabled; + }, + 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); + }, + eat() { 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); }, + 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..7f39020 --- /dev/null +++ b/js/data.js @@ -0,0 +1,94 @@ +"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 }, +]; +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: 99, y: 20, w: 313, h: 434, 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: 96, + itemLimit: 110, + saveKey: "tarinai-colony-save-v18", + worldPadding: 30, + hungerPerMinute: 11.2, + lonelinessPerMinute: 7.6, + energyPerMinute: 8.2, + stressDecayPerMinute: 9, + reproductionCooldown: 28, + dayLength: 120, + childGrowTime: 58, + fightCooldown: 8, + lightingQuality: "cinematic", + weatherChangeMin: 46, + weatherChangeMax: 96, + bubbleLimit: 42, +}; +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, LIGHTING_TUNING }; \ No newline at end of file diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..31cb810 --- /dev/null +++ b/js/main.js @@ -0,0 +1,40 @@ +"use strict"; +let last = nowSec(); +let statsTimer = 0; +let fpsTimer = 0; +let fpsFrames = 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; + if (statsTimer > 0.2) { + renderStats(); + statsTimer = 0; + } + requestAnimationFrame(loop); +} + +loadImages().then(() => { + bindUI(); + resizeCanvas(); + world.reset(); + 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..ed5189e --- /dev/null +++ b/js/render.js @@ -0,0 +1,644 @@ +"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) { + const pixelEllipse = (cx, cy, ew, eh, angle, steps = 10) => { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + ctx.beginPath(); + for (let i = 0; i < steps; i++) { + const a = (i / steps) * Math.PI * 2; + const px = Math.cos(a) * ew; + const py = Math.sin(a) * eh; + const sx = Math.round(cx + px * cos - py * sin); + const sy = Math.round(cy + px * sin + py * cos); + if (i === 0) ctx.moveTo(sx, sy); + else ctx.lineTo(sx, sy); + } + ctx.closePath(); + }; + + ctx.save(); + ctx.translate(x, y); + ctx.imageSmoothingEnabled = false; + ctx.fillStyle = params.color; + const dx = Math.round(params.dx + rx * 0.10); + const dy = Math.round(params.dy + ry * 0.10); + const angle = Math.atan2(dy, dx) * 0.42; + + ctx.globalAlpha = Math.max(0.04, params.alpha * 0.50); + ctx.beginPath(); + ctx.moveTo(Math.round(-rx * 0.28), Math.round(-ry * 0.50)); + ctx.lineTo(Math.round(dx + rx * 1.18), Math.round(dy - ry * 0.36)); + ctx.lineTo(Math.round(dx + rx * 1.42), Math.round(dy + ry * 0.42)); + ctx.lineTo(Math.round(-rx * 0.08), Math.round(ry * 0.72)); + ctx.closePath(); + ctx.fill(); + + ctx.globalAlpha = Math.max(0.055, params.alpha * 0.78); + pixelEllipse(dx * 0.42, dy * 0.42, Math.max(3, rx * 0.90), Math.max(1.8, ry * 0.86), angle, 10); + ctx.fill(); + + ctx.globalAlpha = Math.max(0.07, params.alpha * 1.12); + pixelEllipse(0, 0, Math.max(3, rx * 0.76), Math.max(1.6, ry * 0.72), 0, 10); + 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) { + if (lighting.quality !== "cinematic") return; + const nightBoost = lighting.nightStrength; + const goldenBoost = lighting.goldenStrength; + if (item.type === "food" || item.type === "sweet") { + const sweet = item.type === "sweet"; + drawRadialGlow(ctx, item.x, item.y, item.r * (2.35 + goldenBoost * 0.55), sweet ? "rgba(128, 190, 88, ALPHA)" : "rgba(255, 218, 120, ALPHA)", visibleAlpha * (0.045 + goldenBoost * 0.045 + nightBoost * 0.055)); + } else if (item.type === "water") { + drawRadialGlow(ctx, item.x, item.y, item.r * (2.5 + nightBoost * 0.7), "rgba(116, 183, 255, ALPHA)", visibleAlpha * (0.045 + nightBoost * 0.09)); + } else if (item.type === "grass" && goldenBoost > 0.35) { + drawRadialGlow(ctx, item.x, item.y - item.r * 0.2, item.r * 2.1, "rgba(183, 255, 99, ALPHA)", visibleAlpha * goldenBoost * 0.04); + } +} + +function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) { + if (lighting.quality !== "cinematic" || tarinai.dead) return; + const selected = tarinai.world.selected === tarinai; + const happy = (tarinai.goodMode === "smile" || tarinai.state === "cursor_friend") && tarinai.mood > 58 && tarinai.stress < 44 && tarinai.state !== "sleep"; + const nightAlpha = lighting.nightStrength * (happy ? 0.10 : 0.032); + const selectedAlpha = selected ? 0.13 + lighting.bloom * 0.055 : 0; + const mealAlpha = tarinai.eatTimer > 0.02 ? tarinai.eatTimer * 0.12 : 0; + const alpha = clamp(nightAlpha + selectedAlpha + mealAlpha + lighting.goldenStrength * (happy ? 0.04 : 0.012), 0, 0.22); + if (alpha <= 0.01) return; + const color = selected ? "rgba(178, 236, 96, ALPHA)" : (lighting.nightStrength > 0.22 ? "rgba(180, 213, 255, ALPHA)" : "rgba(255, 222, 132, ALPHA)"); + drawRadialGlow(ctx, tarinai.x, tarinai.y - drawH * 0.10, Math.max(drawW, drawH) * (1.0 + lighting.bloom * 0.34), color, alpha); +} + +function drawDawnRimLight(ctx, drawW, drawH, lighting) { + const alpha = clamp(lighting.dawnStrength * (0.10 + lighting.nightStrength * 0.08), 0, 0.16); + if (alpha <= 0.01) return; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + const g = ctx.createLinearGradient(-drawW * 0.55, -drawH * 0.55, drawW * 0.18, drawH * 0.16); + g.addColorStop(0, `rgba(255, 229, 170, ${alpha})`); + g.addColorStop(0.46, `rgba(255, 213, 142, ${alpha * 0.30})`); + g.addColorStop(1, "rgba(255, 213, 142, 0)"); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.ellipse(-drawW * 0.06, -drawH * 0.06, drawW * 0.60, drawH * 0.42, -0.18, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +// 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"; + return `${weather}:${lighting.progress.toFixed(3)}:${lighting.light.toFixed(3)}:${lighting.goldenStrength.toFixed(3)}:${lighting.nightStrength.toFixed(3)}`; +} + +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 x = 18, y = 22, bw = w - 36, bh = h - 44; + const border = activeCtx.createLinearGradient(0, y, 0, y + bh); + border.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66") : "#6f6c72"); + border.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b") : "#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); + soil.addColorStop(0, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2") : "#bbbcc6"); + soil.addColorStop(0.38, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb") : "#afb1bd"); + soil.addColorStop(1, light > 0.42 ? (lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf") : "#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(); + + for (let i = 0; i < 46; 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(); + } + + for (let i = 0; i < 8; 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 + for (let i = 0; i < 14; 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(); + } + + for (let i = 0; i < 9; 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(); + } + } + + for (let i = 0; i < 10; 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 w = world.w, h = world.h; + ctx.clearRect(0, 0, w, h); + + const lighting = getLightingState(world); + const light = lighting.light; + const cachedBackground = ensureBackgroundCache(w, h, lighting); + if (cachedBackground) { + ctx.drawImage(cachedBackground, 0, 0, w, h); + } else { + drawGardenBackdrop(w, h, lighting); + drawGardenBed(w, h, lighting); + } + drawLightRays(ctx, w, h, lighting); + drawRain(ctx, w, h, world.time); + + 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) / 2, Math.min(t.y, p.y) - 10, p.x, p.y); + ctx.stroke(); + } + ctx.setLineDash([]); + ctx.restore(); + + const sorted = world.drawList || world.tarinai; + sorted.length = 0; + for (const t of world.tarinai) sorted.push(t); + sorted.sort((a, b) => a.y - b.y); + for (const t of sorted) t.draw(ctx, world.time, lighting); + for (const ef of world.effects) ef.draw(ctx); + + // Light affecting the whole scene, including characters and ground. + drawAtmosphericLighting(ctx, w, h, lighting); + + const pressure = clamp((world.tarinai.length - 44) / 24, 0, 1); + if (pressure > 0) { + ctx.save(); + ctx.globalAlpha = pressure * 0.06; + ctx.fillStyle = "#b64545"; + ctx.fillRect(0, 0, w, h); + ctx.restore(); + } + + // Time HUD + ctx.save(); + const hudW = 150; + const hudH = 56; + 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(); + + drawSelectedCard(ctx, world, lighting); + + + 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.w, h = 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); + ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)} \u4e16\u4ee3 ${t.generation} \u3064\u3064\u304d ${t.pokeCount}/9`, 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..c5b8cf1 --- /dev/null +++ b/js/sim.js @@ -0,0 +1,2471 @@ +"use strict"; +function makeName() { + return `${pick(NAMES)}${pick(NAMES)}-${randi(10, 99)}`; +} + +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 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 === "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 11px 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) { + return `${Math.floor(x / this.cellSize)},${Math.floor(y / this.cellSize)}`; + } + + 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}`); + if (!bucket) continue; + for (const entity of bucket) out.push(entity); + } + } + return out; + } +} + +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, trace: 16, splat: 26, zunchi: 14 + }[type] || 12; + this.amount = { + food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, trace: 220, splat: 260, zunchi: 240 + }[type] || 80; + this.age = 0; + this.seed = Math.random() * 1000; + this.lifecycleTimer = rand(0, 0.22); + 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"; + } + } + update(dt, worldRef = world) { + this.age += dt; + 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 === "grass" || this.type === "zunchi") { + this.lifecycleTimer += dt; + if (this.lifecycleTimer >= 0.18) { + const lifecycleDt = Math.min(this.lifecycleTimer, 0.75); + this.lifecycleTimer = 0; + if (this.type === "grass") this.updateGrass(lifecycleDt, worldRef); + if (this.type === "zunchi") this.updateZunchi(lifecycleDt, worldRef); + } + } + } + updateGrass(dt, worldRef) { + const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7; + const weather = worldRef.weather || "sunny"; + 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 nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 360) : (worldRef.items || []); + for (const it of nearby) { + if (it === this) continue; + const d = distXY(this.x, this.y, it.x, it.y); + if (it.type === "zunchi" && (it.stage === "decomposing" || it.stage === "fertile_soil")) { + const influence = clamp(1 - d / 340, 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; + this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 1.2); + this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1); + this.amount = clamp(this.growth * 120 * this.health, 3, 150); + this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25); + if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < 36) { + this.seedTimer = rand(24, 54); + if (Math.random() < 0.42 + this.fertilityBoost * 0.18) { + const fertile = fertileSource && this.fertilityBoost > 0.25; + const spot = worldRef.findGrassSproutSpot + ? worldRef.findGrassSproutSpot(fertile ? fertileSource : this, fertile) + : { x: clamp(this.x + rand(-58, 58), 44, worldRef.w - 44), y: clamp(this.y + rand(-42, 42), 44, worldRef.h - 44) }; + if (!spot) return; + 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; + } + } + } + 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 * (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.06; + 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.13; + } + } + 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 + }; + } + static from(o) { + const it = new Item(o.type, o.x, o.y); + it.amount = o.amount; + it.age = o.age || 0; + it.seed = o.seed || Math.random() * 1000; + 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"; + 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 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); + 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" : growth < 0.86 ? "mature" : "bush"; + const eaten = this.eatenAmount > 14 || growth < 0.16; + ctx.strokeStyle = eaten ? "#7b8755" : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438")); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.lineWidth = styleNight ? 1.7 : 2; + const bladeCount = eaten ? 2 : (stage === "sprout" ? 2 : stage === "young" ? 3 : stage === "mature" ? 5 : 7); + if (stage === "bush" && !eaten) { + ctx.save(); + ctx.lineCap = "round"; + ctx.strokeStyle = styleNight ? "#2f5940" : (styleWarm ? "#6bbf49" : "#579342"); + ctx.lineWidth = styleNight ? 2.2 : 2.6; + for (let i = 0; i < 11; i++) { + const rootX = randSeed(this.seed + i * 3, -this.r * 0.42, this.r * 0.42); + const tipX = rootX + randSeed(this.seed + i * 5 + 4, -this.r * 0.38, this.r * 0.38); + const tipY = -randSeed(this.seed + i + 12, this.r * 0.30, this.r * 0.82); + ctx.beginPath(); + ctx.moveTo(rootX, 8); + ctx.quadraticCurveTo((rootX + tipX) * 0.45, tipY * 0.42, tipX, tipY); + ctx.stroke(); + } + ctx.restore(); + } + for (let i = 0; i < bladeCount; i++) { + const a = -Math.PI / 2 + randSeed(this.seed + i, -0.7, 0.7); + const len = this.r * randSeed(this.seed + i + 10, 0.45, stage === "bush" ? 0.92 : 1.15) * clamp(growth, 0.18, 1.05) * (eaten ? 0.42 : 1); + ctx.beginPath(); + ctx.moveTo(0, 8); + ctx.quadraticCurveTo(Math.cos(a) * len * 0.35, -len * 0.35, Math.cos(a) * len, Math.sin(a) * len); + ctx.stroke(); + } + } else if (this.type === "stone") { + ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8"); + ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63"; + ctx.shadowColor = styleWarm ? "rgba(255, 226, 150, 0.22)" : (styleNight ? "rgba(115, 132, 190, 0.20)" : "transparent"); + ctx.shadowBlur = styleWarm ? 6 : (styleNight ? 5 : 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 === "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(); + } +} + +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(); + 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.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.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.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.pokeCount = opts.pokeCount ?? 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.pokeFlashTimer = opts.pokeFlashTimer ?? 0; + this.zunchiStain = opts.zunchiStain ?? 0; + this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain"); + 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.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; + } + + 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, + hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, + affection: this.affection, need: this.need, state: this.state, + goodMode: this.goodMode, pokeCount: this.pokeCount, eatTimer: this.eatTimer, + foodReactTimer: this.foodReactTimer, surpriseTimer: this.surpriseTimer, fearTimer: this.fearTimer, + intimidateTimer: this.intimidateTimer, intimidateTargetId: this.intimidateTargetId, + pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed, + parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime, + fightTimer: this.fightTimer, fightCooldown: this.fightCooldown, fightTargetId: this.fightTargetId, fightTargetIds: this.fightTargetIds, + 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, + lastMealColor: this.lastMealColor, + }; + } + + 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 }; + } + + poke() { + this.pokeCount += 1; + audio.poke(); + this.stress = clamp(this.stress + 26, 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.pokeCount === 3) { + this.world.log(`${this.name}\u306f\u76ee\u306b\u898b\u3048\u3066\u307a\u305f\u3093\u3068\u3057\u305f\u3002`); + } else if (this.pokeCount === 6) { + this.stretchTimer = Math.max(this.stretchTimer, 12); + this.world.log(`${this.name}\u306f\u9577\u304f\u4f38\u3073\u305f\u3002`); + } else if (this.pokeCount >= 9) { + this.die("\u3064\u3064\u304b\u308c\u3059\u304e"); + } + } + + lowHealthSprite() { + const critical = this.lack > 96 || this.hunger > 112 || this.energy < 7 || this.stress > 118 || this.pokeCount >= 8; + if (critical) return this.stretchTimer > 0 || this.pokeCount >= 6 ? "stretch" : "weak"; + const weak = this.lack > 88 || this.hunger > 102 || this.energy < 18 || this.stress > 104 || this.pokeCount >= 3; + 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"; + } + + spriteId() { + 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.defeatedTimer > 0.04) return this.variantSprite("flee"); + if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08) return this.variantSprite("fear"); + const low = this.lowHealthSprite(); + if (low) return low; + if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); + if (this.state === "intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate"); + 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"); + } + + 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); + if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; + 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 = Math.max(0, this.zunchiStain - dt * 0.0045); + 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)); + } + + const tempComfort = this.world.temperatureAt(this.x, this.y); + 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.resolveNeeds(dt); + const sleepingNow = this.state === "sleep"; + if (!sleepingNow) { + this.reactToFreshZunchi(dt); + this.updateFacing(); + this.interactWithItems(dt); + this.interactWithOthers(dt); + } else { + this.updateFacing(); + } + this.applyEnvironment(dt); + this.move(dt); + this.checkLife(); + } + + applyEnvironment(dt) { + 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), + }; + } + + facingDir() { + 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; + } + + updateFacing() { + this.facing = this.facingDir(); + } + + 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.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 = opponent || null; + 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 dayP = this.world.dayProgress(); + const personalSleep = this.sleepStart < this.sleepEnd + ? dayP >= this.sleepStart && dayP < this.sleepEnd + : dayP >= this.sleepStart || dayP < this.sleepEnd; + + const feared = this.strongestRelation("fear"); + if (!wasSleeping && feared && feared.score > 12) { + const rival = this.world.tarinai.find(o => o.id === feared.id && !o.dead); + if (rival && dist(this, rival) < 150) { + this.state = "panic"; + this.target = rival; + this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear); + this.thought = `${rival.name}\u3092\u4ee5\u524d\u306e\u55a7\u5629\u5f8c\u306b\u907f\u3051\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); + if (zunda && (wasSleeping || this.hunger > 18 - 10 * (this.personalityProfile().zunda - 1) || this.affection > 8)) { + 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.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 && !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\u3057\u8349\u30d9\u30c3\u30c9\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.pokeCount > 0 || 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) { + 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 (this.hunger > 62) { + const itemTypes = ["sweet", "food", "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 < 6.2) 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; + 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 (canBite && (it.type === "food" || it.type === "sweet")) { + 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.energy += eating * 0.04; + 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.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 = clamp((this.zunchiStain || 0) + touch * dt * 0.34, 0, 1); + 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 = clamp((this.zunchiStain || 0) + 0.35, 0, 1); + 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) > 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.energy = clamp(this.energy - 1.2, 0, 100); + o.energy = clamp(o.energy - 1.6, 0, 100); + 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 < 42 || o.mood < 42 || this.type === "angry" || o.type === "angry" || closeCount > 5 || this.personality === "irritable" || o.personality === "irritable"); + const fearBrake = clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 80, 0.22, 1); + const friendBrake = clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 60, 0.35, 1); + const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight; + const notFamily = !(this.parents?.includes(o.id) || o.parents?.includes(this.id)); + if (d < 34 && notFamily && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.11 * 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; + const room = this.world.tarinai.length < CONFIG.maxPopulation; + if (enough && lonelyBond && room && Math.random() < dt * 0.16) { + this.world.spawnChild(this, o); + this.reproductionTimer = CONFIG.reproductionCooldown + rand(4, 20); + o.reproductionTimer = CONFIG.reproductionCooldown + rand(4, 20); + this.surpriseTimer = 0.4; + o.surpriseTimer = 0.4; + } + } + } + + 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.eatTimer > 0.04 || this.state === "eat") { + this.vx = 0; + this.vy = 0; + return; + } + + 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; + 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}`); + 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.grassEatTimer > 0 ? Math.sin(t * 48 + this.age) * clamp(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 bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + eatingShake * 0.8 + fallLift; + const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.025 + this.fallDir * fallPose * 0.92; + let drawW = w * this.scale * 0.30; + let drawH = h * this.scale * 0.30; + if (sprite === "stretch") { + drawW *= 1.16; + drawH *= 1.24; + } + 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); + + ctx.save(); + ctx.translate(this.x + eatingShake * 1.2, this.y + bob); + 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.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.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") { + ctx.save(); + ctx.translate(px, py); + ctx.rotate(Math.sin(t * 3 + i) * 0.18); + const s = Math.max(5, drawW * 0.075); + ctx.beginPath(); + ctx.moveTo(0, s * 0.85); + ctx.bezierCurveTo(-s * 1.9, -s * 0.45, -s * 1.45, -s * 1.9, 0, -s * 0.72); + ctx.bezierCurveTo(s * 1.45, -s * 1.9, s * 1.9, -s * 0.45, 0, s * 0.85); + ctx.fill(); + ctx.restore(); + } 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); + ctx.filter = `brightness(${brightness}) saturate(${saturation})`; + ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh); + ctx.filter = "none"; + if (this.zunchiStain > 0.01) { + ctx.save(); + const stain = clamp(this.zunchiStain, 0, 1); + ctx.beginPath(); + ctx.ellipse(0, dh * 0.37, dw * 0.46, dh * 0.18, 0, 0, Math.PI * 2); + ctx.clip(); + ctx.globalAlpha = 0.15 + stain * 0.34; + ctx.fillStyle = "rgba(34, 86, 24, 0.76)"; + const seed = this.zunchiStainSeed || 0.5; + const spots = [ + [-0.18 + seed * 0.06, 0.34, 0.16, 0.048, -0.12], + [0.18 - seed * 0.05, 0.38, 0.18, 0.052, 0.16], + [0.00, 0.43, 0.24, 0.045, 0.02], + ]; + for (const [sx, sy, sw, sh, rot] of spots) { + ctx.beginPath(); + ctx.ellipse(sx * dw, sy * dh, sw * dw, sh * dh, rot, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + drawDawnRimLight(ctx, dw, dh, lightState); + if (faceRight) ctx.scale(-1, 1); + + if (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(); + } + + contains(x, y) { + return distXY(this.x, this.y, x, y) < this.radius * 1.9; + } +} + +class World { + constructor() { + this.w = 1000; + this.h = 720; + this.time = 0; + this.day = 1; + this.paused = false; + this.speed = 1; + this.tool = "observe"; + this.tarinai = []; + this.items = []; + this.effects = []; + this.logs = []; + 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.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + this.spatial = new SpatialGrid(96); + this.spatialScratch = []; + this.drawList = []; + this.itemCounts = {}; + this.effectCounts = {}; + 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}`; + } + + reset(seedPopulation = CONFIG.initialPopulation) { + this.time = 0; + this.day = 1; + this.paused = false; + this.speed = 1; + this.tarinai = []; + this.items = []; + this.effects = []; + this.logs = []; + 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.lastPhase = this.phaseName(); + this.weather = "sunny"; + this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + 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 < 18; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); + for (let i = 0; i < 5; i++) this.items.push(new Item("food", rand(90, this.w - 90), rand(90, this.h - 90))); + this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); + this.items.push(new Item("bed", this.w * 0.28, this.h * 0.76)); + this.items.push(new Item("bed", this.w * 0.72, this.h * 0.74)); + 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 = {}; + let changed = false; + if (!this.family[t.id]) { + this.family[t.id] = { + id: t.id, + name: t.name, + type: t.type, + generation: t.generation, + parents: [...(t.parents || [])], + parentNames: [...(t.parentNames || [])], + children: [...(t.children || [])], + birthTime: t.birthTime, + alive: true, + }; + changed = true; + } else { + const nextAlive = !t.dead; + const nextChildren = [...(t.children || this.family[t.id].children || [])]; + if (this.family[t.id].alive !== nextAlive) changed = true; + if (nextChildren.length !== (this.family[t.id].children || []).length) changed = true; + this.family[t.id].alive = nextAlive; + this.family[t.id].children = nextChildren; + } + if (changed) this.familyVersion = (this.familyVersion || 0) + 1; + } + + 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; + } + } + } + + addTarinai(opts = {}) { + const t = new Tarinai(this, opts); + this.tarinai.push(t); + this.recordFamily(t); + this.maxGeneration = Math.max(this.maxGeneration, t.generation); + return t; + } + + spawnChild(a, b) { + 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), + y: (a.y + b.y) / 2 + rand(-14, 14), + 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), + pokeCount: 0, + 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); + audio.birth(); + this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u8fd1\u304f\u306b\u73fe\u308c\u305f\u3002`); + return child; + } + + startConflict(a, b) { + if (!a || !b || a.dead || b.dead) 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 intimidationChance = clamp(0.24 + (actor.type === "angry" ? 0.12 : 0) + (actor.personality === "irritable" ? 0.15 : 0) + ((target.relationTo(actor.id).fear || 0) / 90), 0.14, 0.72); + if (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; + 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.28 + (actorScore - targetScore + fearLoad - resistance) / 95, 0.18, 0.82); + 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); + if (Math.random() >= successChance) return false; + actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(0.95, 1.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, "!", "rgba(92,62,34,0.82)"); + 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.05 * target.personalityProfile().fear); + target.state = "panic"; + target.target = actor; + 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; + 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; + a.stress = clamp(a.stress + rand(8, 18), 0, 130); + b.stress = clamp(b.stress + rand(8, 18), 0, 130); + a.energy = clamp(a.energy - rand(2, 7), 0, 100); + b.energy = clamp(b.energy - rand(2, 7), 0, 100); + 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; + } + } + + spawnZunchi(x, y) { + 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)"); + if (this.items.length > CONFIG.itemLimit) this.items.shift(); + } + + spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") { + if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return; + if (text === "z") text = "Zzz..."; + 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; + 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.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); + winner.affection = clamp(winner.affection + 0.8, 0, 80); + 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"); + } + } + + 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; + 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; + 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); + } + + grassBlockedAt(x, y, self = null) { + for (const it of this.nearbyItems(x, y, 52)) { + if (it === self || it.dead || 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; + } + + 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() { + let write = 0; + for (let read = 0; read < this.items.length; read++) { + const it = this.items[read]; + if (it.type === "mirror") continue; + if (it.type === "grass" && this.grassBlockedAt(it.x, it.y, it)) continue; + if (!it.dead) this.items[write++] = it; + } + this.items.length = write; + if (this.items.length > CONFIG.itemLimit) { + this.items.splice(0, this.items.length - CONFIG.itemLimit); + } + } + + compactEffects() { + 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; + } + 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(); + + for (const it of this.items) it.update(dt, this); + this.compactItems(); + this.updateItemCounts(); + this.rebuildSpatial(); + + for (const ef of this.effects) ef.update(dt); + this.compactEffects(); + this.updateEffectCounts(); + + for (const t of this.tarinai) { + t.update(dt); + } + this.compactTarinai(); + this.rebuildSpatial(); + + 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) { + this.addTarinai({ x: rand(80, this.w - 80), y: rand(80, this.h - 80), type: baseSpriteId() }); + this.log("\u3069\u3053\u304b\u304b\u3089\u305f\u308a\u306a\u3044\u304c\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002"); + } + + if (((this.itemCounts.food || 0) + (this.itemCounts.sweet || 0)) < 2 && Math.random() < dt * 0.02) { + this.items.push(new Item("food", rand(80, this.w - 80), rand(80, this.h - 80))); + this.itemCounts.food = (this.itemCounts.food || 0) + 1; + } + + const grassCount = this.itemCounts.grass || 0; + if (grassCount < 24 && 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)}`); + } + + 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") { + if (this.tarinai.length >= CONFIG.maxPopulation) { + showToast("\u30b3\u30ed\u30cb\u30fc\u304c\u6df7\u307f\u3059\u304e\u3066\u3044\u307e\u3059\u3002"); + return; + } + const t = this.addTarinai({ x, y, type: baseSpriteId(), generation: 1 + Math.floor(this.maxGeneration * Math.random() * 0.35) }); + audio.birth(); + this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); + return; + } + + const itemType = { + zunchi: "zunchi", food: "food", sweet: "sweet", water: "water", grass: "grass", + stone: "stone", bed: "bed" + }[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.items.push(new Item(itemType, px, py)); + this.itemCounts[itemType] = (this.itemCounts[itemType] || 0) + 1; + this.rebuildSpatial(); + const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u3042\u305f\u305f\u304b\u3044\u77f3", bed: "\u5e72\u3057\u8349\u30d9\u30c3\u30c9" }[itemType]; + this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); + } + } + + inferLogKind(text) { + const s = String(text || "").toLowerCase(); + 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) }; + 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", + nextWeatherChange: this.nextWeatherChange || CONFIG.weatherChangeMin, + }; + } + + load(data) { + this.time = data.time || 0; + this.day = data.day || 1; + this.deadCount = data.deadCount || 0; + this.maxGeneration = data.maxGeneration || 1; + this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o)); + this.items = (data.items || []).filter(o => o?.type !== "mirror").map(o => Item.from(o)); + this.effects = []; + 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.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.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(); + + +window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world }; diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..46c63cd --- /dev/null +++ b/js/ui.js @@ -0,0 +1,389 @@ +"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"), + selectedInfo: document.getElementById("selectedInfo"), + log: document.getElementById("log"), + toast: document.getElementById("toast"), + statPop: document.getElementById("statPop"), + statGen: document.getElementById("statGen"), + statDead: document.getElementById("statDead"), + statDay: document.getElementById("statDay"), + meterHunger: document.getElementById("meterHunger"), + meterLonely: document.getElementById("meterLonely"), + meterStress: document.getElementById("meterStress"), + meterLack: document.getElementById("meterLack"), + archiveContent: document.getElementById("archiveContent"), +}; + +const uiCache = { + stats: {}, + selectedSnapshot: "", + selectedEmpty: false, + toolButtons: [], + archiveVersion: "", + archiveRows: [], + archiveScrollScheduled: false, + lastArchiveWindow: "", +}; + +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", observe: "\u89b3\u5bdf", note: "\u8a18\u9332" + }[kind] || "\u8a18\u9332"; +} + +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.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.pokeCount, + t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), + rel.friend?.id || "", rel.friend?.score || 0, rel.fear?.id || "", rel.fear?.score || 0, fightRecord + ].join("|"); + if (uiCache.selectedSnapshot === snapshot) return; + uiCache.selectedSnapshot = snapshot; + + ui.selectedInfo.className = "selected-info"; + ui.selectedInfo.innerHTML = ` +
${escapeHtml(t.label())}${escapeHtml(t.name)}\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)}
+
\u3064\u3064\u304d${t.pokeCount} / 9
+
\u4ef2\u826f\u3057${escapeHtml(rel.friend ? `${rel.friend.name} +${fmt(rel.friend.score)}` : "\u306a\u3057")}
+
\u907f\u3051\u308b\u76f8\u624b${escapeHtml(rel.fear ? `${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\u3057\u8349\u30d9\u30c3\u30c9\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", + }[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 === "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\u3057\u8349\u30d9\u30c3\u30c9", stone: "\u3042\u305f\u305f\u304b\u3044\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 renderStats() { + const alive = world.tarinai; + const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0; + setTextIfChanged(ui.statPop, "pop", alive.length); + setTextIfChanged(ui.statGen, "gen", world.maxGeneration); + setTextIfChanged(ui.statDead, "dead", world.deadCount); + setTextIfChanged(ui.statDay, "day", `${world.day}\u65e5`); + setValueIfChanged(ui.meterHunger, "hunger", avg(t => t.hunger).toFixed(1)); + setValueIfChanged(ui.meterLonely, "lonely", avg(t => t.loneliness).toFixed(1)); + setValueIfChanged(ui.meterStress, "stress", avg(t => t.stress).toFixed(1)); + setValueIfChanged(ui.meterLack, "lack", avg(t => t.lack).toFixed(1)); + renderSelected(); + renderArchive(); +} + +function setTextIfChanged(el, key, value) { + const next = String(value); + if (uiCache.stats[key] === next) return; + uiCache.stats[key] = next; + el.textContent = next; +} + +function setValueIfChanged(el, key, value) { + if (uiCache.stats[key] === value) return; + uiCache.stats[key] = value; + el.value = value; +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, c => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[c])); +} + +function buildArchiveRows(family) { + const nodes = Object.values(family || {}) + .filter(Boolean) + .filter(n => (n.parents || []).length > 0 || (n.parentNames || []).length > 0) + .sort((a, b) => (a.generation || 1) - (b.generation || 1) || (a.birthTime || 0) - (b.birthTime || 0)); + return nodes.map(n => ({ + kind: "\u8a95\u751f", + id: n.id, + child: n, + leftParent: family[n.parents?.[0]], + rightParent: family[n.parents?.[1]], + leftName: n.parentNames?.[0] || "\u4e0d\u660e", + rightName: n.parentNames?.[1] || "\u4e0d\u660e", + })); +} + +function familyStatusText(n) { + if (!n) return "\u4e0d\u660e"; + return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; +} + +function familyNodeHtml(n, role, fallbackName = "\u4e0d\u660e") { + const img = n ? (SPRITES.find(s => s.id === n.type)?.path || SPRITES[0].path) : SPRITES[0].path; + const name = escapeHtml(n?.name || fallbackName); + const gen = n?.generation ? `\u4e16\u4ee3 ${n.generation}` : "\u4e16\u4ee3 ?"; + const status = escapeHtml(familyStatusText(n)); + const cls = n ? (n.alive ? "alive" : "dead") : "missing"; + return `
+ +
+ ${name} + ${escapeHtml(gen)} / ${status} +
+
`; +} + +function familyClusterHtml(row) { + return `
+
+ ${familyNodeHtml(row.leftParent, "parent", row.leftName)} + ${familyNodeHtml(row.rightParent, "parent", row.rightName)} +
+ +
${familyNodeHtml(row.child, "child")}
+
`; +} + +function renderArchive() { + if (!ui.archiveContent) return; + 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 = ""; + uiCache.archiveRows = buildArchiveRows(family); + const rows = uiCache.archiveRows; + if (!rows.length) { + ui.archiveContent.innerHTML = `
\u5bb6\u65cf\u95a2\u4fc2\u3092\u6301\u3064\u500b\u4f53\u306f\u307e\u3060\u3044\u307e\u305b\u3093\u3002
`; + return; + } + const limit = 240; + const visibleRows = rows.slice(Math.max(0, rows.length - limit)); + const omitted = rows.length > limit ? rows.length - limit : 0; + const note = omitted + ? `
${visibleRows.length} \u4ef6\u3092\u8868\u793a\u4e2d\u3002\u53e4\u3044\u95a2\u4fc2 ${omitted} \u4ef6\u306f\u7701\u7565\u3057\u307e\u3057\u305f\u3002
` + : `
${visibleRows.length} \u4ef6\u306e\u5bb6\u65cf\u95a2\u4fc2\u3002
`; + ui.archiveContent.innerHTML = `${note}
${visibleRows.map(familyClusterHtml).join("")}
`; +} + +function renderArchiveWindow() { + renderArchive(); +} + +function scheduleArchiveWindowRender() { + renderArchive(); +} + +function resizeCanvas() { + const rect = canvas.getBoundingClientRect(); + const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); + canvas.width = Math.floor(rect.width * dpr); + canvas.height = Math.floor(rect.height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + world.w = rect.width; + world.h = rect.height; +} + +function screenToWorld(e) { + const rect = canvas.getBoundingClientRect(); + const touch = e.touches ? e.touches[0] : e; + return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; +} + +function selectTool(tool) { + world.tool = tool; + for (const btn of uiCache.toolButtons) { + btn.classList.toggle("selected", btn.dataset.tool === tool); + } + canvas.style.cursor = tool === "observe" ? "default" : (tool === "poke" ? "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 { + world.load(JSON.parse(raw)); + 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"; +} + +function bindUI() { + uiCache.toolButtons = Array.from(ui.toolPalette.querySelectorAll(".tool")); + ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true }); + + 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", () => { + if (confirm("\u73fe\u5728\u306e\u89b3\u5bdf\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3059\u304b\uff1f \u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u306f\u6b8b\u308a\u307e\u3059\u3002")) world.reset(); + }); + + ui.toolPalette.addEventListener("click", (e) => { + const btn = e.target.closest(".tool"); + if (!btn) return; + selectTool(btn.dataset.tool); + }); + + canvas.addEventListener("click", (e) => { + const p = screenToWorld(e); + 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 = true; + }); + canvas.addEventListener("mouseleave", () => { + world.pointer.inside = false; + }); + + window.addEventListener("resize", resizeCanvas); +} + +window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, resizeCanvas, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };