diff --git a/.htaccess b/.htaccess index d579227..36b3f0a 100644 --- a/.htaccess +++ b/.htaccess @@ -1,21 +1,21 @@ ExpiresActive On ExpiresByType text/html "access plus 0 seconds" - ExpiresByType text/css "access plus 0 seconds" - ExpiresByType application/javascript "access plus 0 seconds" - ExpiresByType application/json "access plus 0 seconds" + ExpiresByType text/css "access plus 30 days" + ExpiresByType application/javascript "access plus 30 days" + ExpiresByType application/json "access plus 30 days" ExpiresByType image/png "access plus 30 days" ExpiresByType image/webp "access plus 30 days" ExpiresByType audio/mpeg "access plus 30 days" ExpiresByType audio/wav "access plus 30 days" - - Header set Cache-Control "no-cache, no-store, must-revalidate" + + Header set Cache-Control "no-cache, must-revalidate" Header set Pragma "no-cache" Header set Expires "0" - + Header set Cache-Control "public, max-age=2592000" diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index 8c5ba5c..938feeb 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,3 +1,18 @@ +## 15.9.2 修正 +- 巣箱アイコンとゲーム内描画から、枠内の明色・透過っぽく見える装飾を除去。 +- アリのHPバーを、たりないと同様にダメージ直後だけ表示する方式へ変更。 +- 道具UIに「げんこつ」を追加。添付画像から正義げんこつ部分を切り抜き、上から落下してたりない・アリにダメージと吹っ飛びを与える効果を追加。 +- 初期ロード画面と読み込み進捗バーを追加。JS/CSSをdefer化し、CSS/JSのサーバーキャッシュを有効化して再訪問時の通信量を削減。 + + +## 15.9.1 修正 +- 草を地面レイヤーとして扱い、既存の草の上にもオブジェクトを配置できるようにしました。草同士・草と障害物の植え付け制限は維持しています。 +- けんか餅のゲーム内描画から星印を削除し、×印のみ表示するようにしました。 +- ダメージ後に出る「こわい…」吹き出しを削除しました。 +- 家系図に「更新」トグルを追加しました。デフォルトはOFFで、ONの間だけ家系図を描画・更新します。 +- 死亡した個体のライブIDを再利用しないようにし、友達・敵対関係が別個体へ引き継がれて見える問題を防止しました。死亡時の一時ターゲット参照も掃除します。 +- たりないデータUIをカテゴリ別の折り畳み表示に変更しました。 + # Patch Notes ## Encoding cleanup diff --git a/README.md b/README.md index a55624e..69725d1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Read only these first: `README.md`, `docs/AI_CONTEXT.md`, `docs/code_map.md`. Th ## Core simulation - Entities: `Tarinai` agents with hunger, sleep/energy, loneliness, stress, health, age, personality, family links, relationships, diseases, favorite flag, target/need AI, transient animation timers. -- World: bounded field with weather, time, lighting, temperature, spatial-grid queries, items, effects, logs, archive/family data, persistence, placement and collision rules. +- World: bounded field with weather, time, lighting, temperature, spatial-grid queries, items, effects, logs, archive/family data, placement and collision rules. - Observation loop: agents autonomously seek food/water/sleep/social targets, react to zunchi, tools, rain, cold, crowding, relationships, disease, damage, conflict, reproduction, and player input. - Records: observation log and family tree store births, deaths, parentage, cause-of-death labels, favorite state, and key state changes. @@ -50,7 +50,7 @@ js/sim_core.js Names, personalities, relationships, effects, spatial grid. js/health.js Damage memory, death-cause normalization, damage application. js/items.js Item state, lifecycle, drawing, grass/zunchi/ball/firecracker logic. js/tarinai.js Agent state, AI, disease state, eating, conflict, reproduction, draw. -js/world.js World update, placement, weather, logs, save/load, global interactions. +js/world.js World update, placement, weather, logs, global interactions. js/ui.js Input, tool selection, dialogs, observation panel. js/ui_charts.js Population/statistics charts. js/ui_family.js Family/archive UI and cause-of-death display. @@ -69,7 +69,7 @@ docs/code_map.md Symbol-to-file lookup table. ## v15 notes - Colony chart tab `events` was removed. `environment` now reports security, hygiene, and happiness. -- Selected Tarinai UI includes per-individual records derived from matching observation log entries. +- Selected Tarinai UI includes per-individual records attached by structured observation events. - Observation highlights selected relatives and major social relations: parent, child, friend, enemy. - Explosions impart velocity to nearby zunchi items; cursor contact reduces stress and emits dense heart effects. diff --git a/assets/objects/genkotsu.png b/assets/objects/genkotsu.png new file mode 100644 index 0000000..8160368 Binary files /dev/null and b/assets/objects/genkotsu.png differ diff --git a/assets/ui/tool_genkotsu.png b/assets/ui/tool_genkotsu.png new file mode 100644 index 0000000..8160368 Binary files /dev/null and b/assets/ui/tool_genkotsu.png differ diff --git a/assets/ui/tool_nest_box.png b/assets/ui/tool_nest_box.png index c681d19..4f60156 100644 Binary files a/assets/ui/tool_nest_box.png and b/assets/ui/tool_nest_box.png differ diff --git a/css/styles.css b/css/styles.css index 2b87d0d..c41a5e0 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,4 +1,3 @@ -@import url("https://fonts.googleapis.com/css2?family=Yomogi&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=M+PLUS+Rounded+1c:wght@400;500;700&family=Yomogi&display=swap"); :root { @@ -36,6 +35,64 @@ button { color: inherit; } +.loading-screen { + position: fixed; + inset: 0; + z-index: 9999; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(circle at 50% 30%, rgba(164, 225, 104, 0.22), transparent 32%), + linear-gradient(180deg, rgba(239, 248, 255, 0.98), rgba(247, 245, 236, 0.98)); + transition: opacity 180ms ease, visibility 180ms ease; +} +.loading-screen.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} +.loading-panel { + width: min(420px, 92vw); + padding: 24px 22px; + border-radius: 24px; + border: 1px solid rgba(84, 68, 48, 0.16); + background: rgba(255, 252, 246, 0.92); + box-shadow: 0 18px 48px rgba(64, 48, 32, 0.16); + text-align: center; +} +.loading-title { + font-weight: 700; + font-size: 18px; + letter-spacing: 0.04em; + margin-bottom: 8px; +} +.loading-text { + color: var(--muted); + font-size: 13px; + line-height: 1.55; + min-height: 1.55em; +} +.loading-bar { + height: 12px; + margin: 18px 0 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(90, 70, 48, 0.12); +} +.loading-bar-fill { + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #9ce84b, #f0d35b); + transition: width 160ms ease; +} +.loading-percent { + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + #app { width: min(1480px, 100vw); margin: 0 auto; @@ -191,23 +248,6 @@ h1 { justify-content: flex-end; gap: 8px; } -.save-text-dialog-panel { - width: min(760px, calc(100vw - 30px)); -} -.save-text-area { - width: 100%; - min-height: 180px; - margin-top: 10px; - padding: 10px; - border-radius: 12px; - border: 1px solid var(--line); - background: rgba(255,255,255,0.82); - color: var(--ink); - font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - resize: vertical; - word-break: break-all; -} -.save-text-actions { flex-wrap: wrap; } .archive-reset-dialog-panel { width: min(420px, 100%); } @@ -349,32 +389,18 @@ h1 { .tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.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="genkotsu"] { --tool-icon: url("../assets/ui/tool_genkotsu.png"); } .tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.png"); } .tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.png"); } .tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.png"); } .tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); } -.tool[data-tool="ball"] { --tool-icon: none; } -.tool[data-tool="ball"]::before { - content: "\26bd "; - background: none; - font-size: 24px; - line-height: 1; - filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); -} -.tool[data-tool="ball"]::after { - content: "\26bd "; - background: none; - font-size: 48px; - line-height: 1; - opacity: 0.10; - right: -6px; - left: auto; - top: 50%; - bottom: auto; - transform: translateY(-50%); - width: 54px; - height: 54px; -} +.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.png"); } +.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.png"); } +.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.png"); } +.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.png"); } +.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.png"); } +.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.png"); } +.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.png"); } .tool[data-tool="poke"] { --tool-icon: none; } .tool[data-tool="poke"]::before { content: "\1F448"; @@ -420,39 +446,19 @@ h1 { width: 54px; height: 54px; } -.tool[data-tool="love_mochi"] { --tool-icon: none; } -.tool[data-tool="love_mochi"]::before { - content: "\1F495"; +.tool[data-tool="water_hose"] { --tool-icon: none; } +.tool[data-tool="water_hose"]::before { + content: "\1F6BF"; background: none; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-size: 24px; line-height: 1; filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); } -.tool[data-tool="love_mochi"]::after { - content: "\1F495"; - background: none; - font-size: 46px; - line-height: 1; - opacity: 0.10; - right: -6px; - left: auto; - top: 50%; - bottom: auto; - transform: translateY(-50%); - width: 54px; - height: 54px; -} -.tool[data-tool="fight_mochi"] { --tool-icon: none; } -.tool[data-tool="fight_mochi"]::before { - content: "\1F4A2"; - background: none; - font-size: 24px; - line-height: 1; - filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); -} -.tool[data-tool="fight_mochi"]::after { - content: "\1F4A2"; +.tool[data-tool="water_hose"]::after { + content: "\1F6BF"; background: none; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-size: 46px; line-height: 1; opacity: 0.10; @@ -506,6 +512,36 @@ h1 { .tool-category.collapsed .tool-category-toggle::before { content: "\25b8 "; } .tool-category-body { padding: 8px; } .tool-category.collapsed .tool-category-body { display: none; } + +.selected-data-groups { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} +.selected-category { + border: 1px solid rgba(84,68,48,0.10); + border-radius: 14px; + background: rgba(255,255,255,0.30); + overflow: hidden; +} +.selected-category-toggle { + width: 100%; + border: 0; + border-bottom: 1px solid rgba(84,68,48,0.08); + background: rgba(255,252,244,0.66); + padding: 8px 10px; + text-align: left; + cursor: pointer; + font-weight: 700; + font-size: 12px; + color: var(--ink); +} +.selected-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); } +.selected-category.collapsed .selected-category-toggle::before { content: "\25b8 "; } +.selected-category-body { padding: 7px 9px; } +.selected-category.collapsed .selected-category-body { display: none; } +.selected-category-body .info-row:last-child { border-bottom: 0; } .tool-size-badge { position: absolute; right: 7px; @@ -993,6 +1029,20 @@ body::before { z-index: 1; isolation: isolate; } + +.lineage-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} +.btn.active { + background: #efffde; + border-color: rgba(103,178,44,0.46); + font-weight: 700; +} + .lineage-render-status { position: sticky; top: 0; @@ -1106,38 +1156,6 @@ body::before { } -.tool[data-tool="water_bowl"] { --tool-icon: none; } -.tool[data-tool="water_bowl"]::before { - content: "\1F963"; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 23px; - display: grid; - place-items: center; -} -.tool[data-tool="sleep_drug"] { --tool-icon: none; } -.tool[data-tool="sleep_drug"]::before { - content: "\1F48A"; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 23px; - display: grid; - place-items: center; -} -.tool[data-tool="nest_box"] { --tool-icon: none; } -.tool[data-tool="nest_box"]::before { - content: "\1FABA"; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 23px; - display: grid; - place-items: center; -} -.tool[data-tool="water_hose"] { --tool-icon: none; } -.tool[data-tool="water_hose"]::before { - content: "\1F6BF"; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 23px; - display: grid; - place-items: center; -} .tool[data-tip] { position: relative; } .tool-tip-popover { position: fixed; @@ -1210,149 +1228,6 @@ body::before { font-weight: 600; } -.tool[data-tool="water_bowl"]::before, -.tool[data-tool="sleep_drug"]::before, -.tool[data-tool="nest_box"]::before, -.tool[data-tool="water_hose"]::before { - position: absolute; - left: 7px; - top: 50%; - width: 30px; - height: 30px; - transform: translateY(-50%); - background: none; - line-height: 30px; - text-align: center; - overflow: hidden; -} -.tool[data-tool="water_bowl"]::after, -.tool[data-tool="sleep_drug"]::after, -.tool[data-tool="nest_box"]::after, -.tool[data-tool="water_hose"]::after { - content: ""; - background: none; -} - -/* Tool UI icons: use in-game styled image assets instead of emoji glyphs. */ -.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.png"); } -.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.png"); } -.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.png"); } -.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.png"); } -.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.png"); } -.tool[data-tool="water_hose"] { --tool-icon: url("../assets/ui/tool_water_hose.png"); } -.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.png"); } - -.tool[data-tool="love_mochi"]::before, -.tool[data-tool="fight_mochi"]::before, -.tool[data-tool="water_bowl"]::before, -.tool[data-tool="sleep_drug"]::before, -.tool[data-tool="nest_box"]::before, -.tool[data-tool="water_hose"]::before, -.tool[data-tool="ball"]::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)); - line-height: normal; - text-align: initial; - overflow: visible; -} - -.tool[data-tool="love_mochi"]::after, -.tool[data-tool="fight_mochi"]::after, -.tool[data-tool="water_bowl"]::after, -.tool[data-tool="sleep_drug"]::after, -.tool[data-tool="nest_box"]::after, -.tool[data-tool="water_hose"]::after, -.tool[data-tool="ball"]::after { - content: ""; - position: absolute; - inset: 0; - width: auto; - height: auto; - left: auto; - right: auto; - top: auto; - bottom: auto; - transform: none; - background: var(--tool-icon) right -8px center / 54px 54px no-repeat; - opacity: 0.10; - pointer-events: none; - font-size: initial; - line-height: normal; -} - - -/* 15.2.0: replace legacy poke/pinch PNG icons with emoji glyphs. */ -.tool[data-tool="poke"], -.tool[data-tool="pinch"] { --tool-icon: none; } -.tool[data-tool="poke"]::before, -.tool[data-tool="pinch"]::before { - background: none; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 24px; - line-height: 30px; - text-align: center; - display: block; - opacity: 0.96; -} -.tool[data-tool="poke"]::before { content: "\1F448"; } -.tool[data-tool="pinch"]::before { content: "\1F90F"; } -.tool[data-tool="poke"]::after, -.tool[data-tool="pinch"]::after { - background: none; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 46px; - line-height: 54px; - text-align: center; - opacity: 0.10; - right: -6px; - left: auto; - top: 50%; - bottom: auto; - transform: translateY(-50%); - width: 54px; - height: 54px; -} -.tool[data-tool="poke"]::after { content: "\1F448"; } -.tool[data-tool="pinch"]::after { content: "\1F90F"; } -.tool[data-tool="poke"] span, -.tool[data-tool="pinch"] span { display: none; } - - -/* 15.3.0: shower uses emoji; nest box and fences use appearance-matched assets/hitboxes. */ -.tool[data-tool="water_hose"] { --tool-icon: none; } -.tool[data-tool="water_hose"]::before { - content: "\1F6BF"; - background: none; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 24px; - line-height: 30px; - text-align: center; - display: block; - opacity: 0.96; -} -.tool[data-tool="water_hose"]::after { - content: "\1F6BF"; - background: none; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 46px; - line-height: 54px; - text-align: center; - opacity: 0.10; - right: -6px; - left: auto; - top: 50%; - bottom: auto; - transform: translateY(-50%); -} - .relation-list-row { align-items: start; } @@ -1416,32 +1291,3 @@ body::before { opacity: 0.72; flex: 0 0 auto; } -.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.png"); } -.tool[data-tool="ant_nest"]::before { - content: ""; - background: var(--tool-icon) center / contain no-repeat; -} -.tool[data-tool="ant_nest"]::after { - content: ""; - background: var(--tool-icon) right -8px center / 54px 54px no-repeat; -} - -/* v15.7.2: object-faithful tool icons for ball and nest box. */ -.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.png"); } -.tool[data-tool="ball"]::before, -.tool[data-tool="ball"]::after { - content: ""; - background: var(--tool-icon) center / contain no-repeat; - font-size: 0; -} -.tool[data-tool="ball"]::after { - background-position: right -8px center; - background-size: 54px 54px; - opacity: 0.10; -} -.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.png"); } -.tool[data-tool="nest_box"]::after { - content: ""; - background: var(--tool-icon) right -8px center / 54px 54px no-repeat; - opacity: 0.10; -} diff --git a/docs/AI_CONTEXT.md b/docs/AI_CONTEXT.md index 593a0dc..53984ed 100644 --- a/docs/AI_CONTEXT.md +++ b/docs/AI_CONTEXT.md @@ -5,9 +5,9 @@ Purpose: dense orientation for code-editing agents. Browser-only simulation; no Load order from `index.html`: `data -> math -> assets -> audio -> render -> sim_core -> items -> health -> tarinai -> world -> text_catalog -> ui -> ui_charts -> ui_family -> main`. Primary objects: -- `World` (`js/world.js`): owns field size, time/weather, tarinai array, items, effects, logs, archive, save/load, placement, collision, interaction dispatch. +- `World` (`js/world.js`): owns field size, time/weather, tarinai array, items, effects, logs, archive, placement, collision, interaction dispatch. - `Tarinai` (`js/tarinai.js`): agent state and AI. Use helper files for shared rules; avoid making this file larger. -- `Item` (`js/items.js`): item state, draw, lifecycle, serialization. +- `Item` (`js/items.js`): item state, draw, lifecycle. - `HEALTH` (`js/health.js`): damage memory, major-damage window, death-cause classification, damage application. - `TEXT_CATALOG` (`js/text_catalog.js`): Japanese tooltips and ecology cards. Keep Japanese escaped. diff --git a/docs/code_map.md b/docs/code_map.md index 6a4bd87..5ae1a80 100644 --- a/docs/code_map.md +++ b/docs/code_map.md @@ -15,7 +15,7 @@ Use this as the first lookup table before opening large files. | Item creation/lifecycle/draw | `js/items.js` / `class Item` | | Cleaning, rain, placement | `js/world.js` / placement/update interaction helpers | | Fence collision | `js/world.js` / `resolveFenceCollision` | -| Logs/archive/save | `js/world.js` | +| Logs/archive | `js/world.js` | | Main renderer | `js/render.js` / `render` helpers | | Agent drawing | `js/tarinai.js` / `draw` | | Input/tools/dialogs | `js/ui.js` | diff --git a/docs/prompt.txt b/docs/prompt.txt index dc90944..c54aa78 100644 --- a/docs/prompt.txt +++ b/docs/prompt.txt @@ -1,265 +1,910 @@ -You are modifying an existing JavaScript browser game project. +You are working on the latest Tarinai observation game build. -Implement a personality system for the \u201ctarinai\u201d creatures. Keep the implementation conservative and debuggable. Do not add extra systems beyond the specification below. +Base input: -## Goal +* Start from `tarinai_colony_game_ant_nest_v5_fixed(1).zip`. +* The extracted project folder may be named `tarinai_colony_game_personality_fixed`. +* Modify the project files directly. +* Return a new zip named something like `tarinai_colony_game_ant_nest_v6_fixed.zip`. +* Preserve the current Japanese UI tone and naming style. +* After changes, run syntax checks on all JavaScript files and verify zip integrity. +* Bump all cache-busting versions in `index.html` consistently so browsers do not keep loading old JS/CSS. -Add four continuous personality parameters to each tarinai: +Important context: + +* This latest build already includes the four-axis personality system. +* This latest build already includes the ant nest feature. +* Do not revert either of those systems. +* Do not reintroduce the old legacy named personality system. +* Do not preserve old save compatibility. +* Old local save data is irrelevant. + +Important intentional behaviors to preserve: + +* The family tree reset feature is intentional. It exists so the player can delete an extremely large/heavy family tree. Do not remove it. +* The family tree reset may clear family tree data/cache and related heavy relationship graph data according to the current intended behavior. Do not “fix” it as a bug. +* Item placement drop-impact damage is intentional. Do not remove it. +* Keep item placement drop damage behavior as-is unless it directly causes a runtime error. +* Grass planting checks may remain mostly as-is. +* Tool-size inheritance may remain as-is. +* Screen resizing may reset the field. Do not add complicated resize preservation logic. + +--- + +# 1. Completely remove save/load functionality + +Remove the save/load feature entirely. + +Requirements: + +* Remove manual save buttons, load buttons, autosave controls, import/export controls, or any equivalent UI if present. +* Remove localStorage save/load usage. +* Remove autosave timers. +* Remove save key constants such as `tarinai-colony-save-*`. +* Remove save migration logic. +* Remove backward compatibility code for old saves. +* Remove serialization/deserialization code that exists only for save/load. +* Remove comments and fallback code related to old save formats. +* Do not preserve compatibility with previous versions. +* Do not add new save/load functionality. +* Do not remove normal image/audio asset loading functions. Only remove game-state save/load. + +Current-code hints: + +* `CONFIG.saveKey` still exists and should be removed. +* `serialize()` / `load(data)` style world-state code still exists and should be removed if it is only used for save/load. +* CSS classes for old save text dialogs should be removed if unused. +* Legacy personality stubs that exist only for old-save compatibility should be removed if they are not used by the current four-axis personality system. + +Expected result: + +* The game starts fresh. +* No save/load UI is visible. +* No old localStorage data is read. +* No migration or compatibility logic remains. + +--- + +# 2. Living Tarinai internal IDs + +Introduce or refactor to a living-only internal ID system for Tarinai. + +Important: + +* The existing code uses `id` widely for Tarinai identity, family tree, ants, relationships, and logs. +* Do not blindly make historical family-tree identity reusable if that would corrupt the family tree. +* The requested reusable ID is the active living individual ID. +* If persistent historical/family-tree identity is still needed, introduce a separate internal snapshot/archive key such as `familyKey` or `archiveKey`. +* Do not display either ID in the UI. + +Requirements: + +* Every living Tarinai must have an internal living ID. +* The living ID must not be displayed. +* Use the living ID internally for: + + * observation event ownership + * relationship highlights + * fight participants + * damage attribution where needed + * current ant targeting/dragging + * current selection state + * avoiding name-based ambiguity +* Do not use display name text as an internal identifier. +* Death must completely remove/release the living ID from the active living registry. +* Released living IDs may be reused by later Tarinai. +* Reusing a living ID must not attach new Tarinai events to an old dead Tarinai’s logs, observation history, family tree entry, or ant target. +* Any historical display that survives death must store a display snapshot separately: + + * name snapshot + * sprite/type snapshot + * death cause + * family links if needed + * any required personality display snapshot +* Ants that target or drag a Tarinai must not accidentally switch to a new Tarinai after a released living ID is reused. Use direct object references, validation tokens, or live ID plus generation token if necessary. + +Expected result: + +* Living Tarinai are internally identifiable without relying on names. +* Dead Tarinai living IDs are released and reusable. +* Historical displays remain stable. +* ID reuse does not corrupt logs, family tree, relationships, selection, or ant behavior. + +--- + +# 3. Four-axis personality compatibility + +The latest build uses four continuous personality axes: * aggression * openness * sociability * neuroticism -Each parameter ranges from `-1.0` to `+1.0`. +Do not reintroduce legacy named personality as a main data model. -Personality should affect behavior only when the value crosses clear thresholds. Small changes should be recorded internally, but observation logs should only be written when a threshold is crossed. +Coward/timid behavior: -## Personality axes +* Do not set a legacy `currentPersonality = "臆病"` style value. +* Do not add a separate cowardice parameter. +* `臆病` should remain a derived tag/state based on the four-axis personality model. +* The existing intended derivation is high neuroticism plus low aggression. +* When a Tarinai takes damage and its total fight losses are greater than its total fight wins: -### aggression + * with 5% chance, shift its current personality slightly toward cowardly behavior, + * for example increase neuroticism and decrease aggression, + * use only total fight wins/losses, + * do not include per-relationship win/loss counts, + * do not double count. +* If this behavior is already implemented correctly, keep it and only clean up naming/comments so it does not imply a legacy named-personality route. -Range: +Expected result: -* low side: calm -* high side: irritable +* The four-axis personality system remains intact. +* `臆病` remains a derived display/behavior tag, not a separate old personality. +* The damage/loss condition uses only aggregate lifetime fight result counters. -Behavior: +--- -* High aggression makes the tarinai more likely to threaten others, start fights, or retaliate. -* Low aggression makes the tarinai less likely to start fights. +# 4. Observation logs, event ownership, filtering, and statistics -Changes: +Current issues: -* Winning fights should slightly increase aggression. -* Losing fights should slightly decrease aggression. +* Observation logs may still be assigned by checking whether event text includes a Tarinai’s name. +* Hidden observation events may still count in event statistics. +* `つつく` / poke may still be classified as fight by text inference. -### openness +Implement: -Range: +* Observation events must store structured participant references. +* Use living Tarinai IDs while participants are alive. +* Also store display-name snapshots so old logs remain correct after death or ID reuse. +* Do not assign observation events by `text.includes(name)` or similar name substring matching. +* Event objects should have structured fields such as: -* low side: conservative -* high side: playful + * `type` + * `actorLiveId` + * `targetLiveId` + * `participantLiveIds` + * `participantSnapshots` + * `hiddenFromObservation` + * `countInStats` +* Exact field names are flexible, but the data must be structured. +* Hidden observation events must not be counted in statistics. +* Do not merely hide them from the UI while still incrementing counters. +* Events hidden from observation UI should either not be logged as visible observation events or should be marked with `countInStats: false`. -Behavior: +Remove from the observation UI and statistics: -* High openness makes the tarinai more likely to wander farther, investigate new objects, and play with toys such as balls. -* Low openness makes the tarinai prefer familiar or nearby areas. +* events about poking a ball +* events of the form “X avoided Y” / `○○が××を避けた` +* events that merely indicate selecting a Tarinai for observation, such as `○○を選択した。` -Changes: +Important classification rule: -* Being petted by the cursor should slightly increase openness. +* `つつく` / poke is not a fight. +* Do not classify poke events as `fight`. +* If there is an `inferLogKind()` or equivalent text-based classifier, fix or replace it so poke does not become fight. +* Prefer structured event types over Japanese text matching. -### sociability +Log display formatting: -Range: +* Remove name-substring based log decoration where practical. +* Render participant names from structured event snapshots. +* If a participant has died and its living ID was released, use the display-name snapshot stored in the event. +* Do not allow reused living IDs to rewrite old log display names. -* low side: loner -* high side: social +Expected result: -Behavior: +* Observation logs attach to the correct individuals. +* Hidden events are not shown and not counted. +* Poke is not treated as fight. +* Similar Tarinai names no longer corrupt logs. -* High sociability makes the tarinai more likely to approach other tarinai, especially parents, children, friends, or the cursor. -* High sociability also makes the tarinai more likely to react when a parent, child, or friend is fighting nearby. -* Low sociability makes the tarinai prefer being alone. +--- -Changes: +# 5. Family tree update performance and scheduling -* Spending time near parents, children, or friends should slightly increase sociability. +The latest build already has some idle/chunked family tree rendering. Preserve it and finish the scheduling/consistency work. -### neuroticism +Requirements: -Range: +* Keep heavy preprocessing split into small chunks. +* Use `requestIdleCallback` when available, with a safe `setTimeout` fallback. +* Avoid doing large graph construction, signature generation, relationship counting, or DOM creation in one blocking pass. +* The game loop must remain responsive while family tree data is prepared. -* low side: insensitive / unfazed -* high side: delicate / sensitive +Update timing rules: -Behavior: +* When the family tree panel is hidden or offscreen: -* High neuroticism makes the tarinai gain more stress from unpleasant stimuli such as corpses, poop, nearby fights, or overcrowding. -* Low neuroticism makes the tarinai less affected by such stimuli. + * Do not update every 5 seconds. + * Check/update at most once every 30 seconds. +* When the family tree panel is visible: -Changes: + * Do not update on a fixed 5-second timer. + * Update only when the family tree is dirty, meaning a relevant change occurred. +* Relevant dirty events include at least: -* Touching poop or corpses should still increase stress immediately. -* However, repeated exposure should slightly reduce neuroticism over time, representing habituation. -* This means tarinai can become less sensitive to unpleasant stimuli after repeated exposure. + * birth + * death + * parent/child relationship creation + * mate/partner relationship creation or change + * name/identity data change that affects the family tree display + * family tree reset +* Mark dirty from the source of the change, not by repeatedly recomputing expensive signatures. +* If a background update is already running, do not start another one. +* If a newer dirty event occurs while an update is running, schedule one additional update after the current update completes. +* If the family tree reset button is used, cancel stale pending render tokens/timers so old async work cannot reinsert deleted tree HTML afterward. -Important: +Important preservation rule: -* Do not treat \u201ccowardice\u201d as a direct low-aggression state. -* If a cowardly tag is needed, derive it as a compound state from high neuroticism and low aggression. -* Do not implement a separate cowardice parameter. +* Do not remove the family tree reset feature. +* It is intentional and may clear family tree data/cache and current heavy relationship graph data. +* Only fix reset-related runtime errors or stale async render problems. -## Birth personality and current personality +Expected result: -Each tarinai should have two personality objects: +* Opening or updating the family tree should not freeze the main simulation. +* Hidden family tree work should be rare. +* Visible family tree should refresh promptly after actual changes. +* Reset remains available and does not allow stale async renders to restore old content. -```js -birthPersonality -currentPersonality -``` +--- -Rules: +# 6. Disease fight initiation and targeting rules -* `birthPersonality` is created at birth and normally does not change. -* `currentPersonality` starts equal to `birthPersonality`. -* Experience modifies only `currentPersonality`. -* The tarinai data UI should show both birth personality and current personality. +Implement the following disease fight rules exactly. -## Inheritance +Fight initiation: -When a child is born: +* Tarinai with `爆発病` can initiate fights. +* Tarinai with `ずんち病` can initiate fights. +* Tarinai with any other disease must not initiate fights. +* Healthy Tarinai may initiate fights according to the existing rules. -```text -child birth personality = - average of the parents' current personality - + random variation between -0.2 and +0.2 for each parameter -``` +Being targeted / challenged: -Clamp each resulting value to `-1.0` through `+1.0`. +* Tarinai with `ねむり病` must not be targeted for fights. +* Tarinai with any other disease may still be targeted/challenged by another Tarinai. +* This means: -Then: + * `爆発病` and `ずんち病`: can initiate and can be targeted. + * `きずつき病` and other non-sleep diseases: cannot initiate, but can be targeted. + * `ねむり病`: cannot initiate and cannot be targeted. -```js -child.currentPersonality = child.birthPersonality -``` +Apply consistently to: -The child should pass its changed current personality to future descendants, not only its original birth personality. +* Normal fight selection. +* Forced fight logic. +* Intimidation logic if it can lead to fights. +* Fight-food effects such as `けんか餅`. +* Any helper such as `canStartFight`, `canBeFightTarget`, or equivalent. -## Thresholds and personality tags +Expected result: -Use two threshold levels: +* Disease rules are handled by clear helper functions. +* No scattered ad-hoc disease checks disagree. -```text --1.0 to -0.75: strong low-side trait --0.75 to -0.5: slight low-side trait --0.5 to +0.5: neutral, no visible personality tag -+0.5 to +0.75: slight high-side trait -+0.75 to +1.0: strong high-side trait -``` +--- -Display tags: +# 7. Collision and external-force unification -* Slight traits should be displayed as \u201cslightly X\u201d. -* Strong traits should be displayed as \u201cX\u201d. -* Neutral values should not create a visible tag. +The latest build already has `applyImpulse()` and some collision refactoring. Preserve and finish the design. -Example: +Requirements: -* aggression `+0.62` -> \u201cslightly irritable\u201d -* aggression `+0.83` -> \u201cirritable\u201d -* sociability `-0.58` -> \u201cslightly loner\u201d -* sociability `-0.82` -> \u201cloner\u201d -* openness `+0.20` -> no tag +* Normal self-propelled movement and external impulse/knockback must remain separate. +* External impulses must not be clamped by the normal Tarinai walking speed cap. +* If helper functions already exist, reuse and tighten them instead of adding duplicates. +* Useful helpers may include: -Behavior activation: + * `applyImpulse(entity, vx, vy, options)` + * `applyKnockback(entity, sourceX, sourceY, strength, options)` + * `applyImpactDamage(target, amount, cause)` + * `resolveCircleCircleCollision(...)` + * `resolveCircleRectCollision(...)` -* Slight traits affect relevant behavior with a 50% chance when choosing a behavior. -* Strong traits affect relevant behavior with a 100% chance when choosing a behavior. -* Do not check this every frame. Apply it only during behavior selection or when evaluating a relevant event. +Unlimited ball speed: -## Daily personality change limit +* Do not reintroduce a ball speed cap. +* Collision detection must handle high-speed balls using swept movement based on actual frame movement distance. +* Remove fixed low `Math.min(...)` collision-search caps that contradict unlimited ball speed. +* Ball vs wall, ball vs fence, ball vs nest box, ball vs ball, and ball vs Tarinai should remain reliable at high speed. -Add a daily change cap so personality values cannot change too quickly. +High-speed ball vs Tarinai: -Recommended limits: +* When a sufficiently fast ball hits a Tarinai: -```text -Per parameter: - maximum increase per day: +0.08 - maximum decrease per day: -0.08 + * Knock the Tarinai away using velocity equal to 1/5 of the ball velocity. + * This knockback must be an external impulse, not normal walking movement. + * Do not let normal “play with ball” behavior also run for that same dangerous impact if it would conflict. + * If the damage from this impact kills the Tarinai, death cause must be `衝突`. -Across all parameters: - maximum total absolute personality change per day: 0.20 -``` +High-speed Tarinai vs Tarinai: -If a change would exceed the daily limit, reduce or ignore the excess change. +* When a sufficiently fast Tarinai hits another Tarinai: -Reset these daily counters at the start of each new in-game day. + * The hit Tarinai should be knocked away. + * Use the attacker’s actual movement/impulse direction and speed. + * If collision damage kills a Tarinai, death cause must be `衝突`. -## Observation log rules +Explosion knockback: -Write personality change entries to the observation log only when a threshold is crossed. +* Keep explosion knockback strong. +* Use external impulse, not normal movement velocity. +* Knockback should be partly random. +* Tarinai closer to the blast center should be thrown faster and farther. +* Tarinai farther from the blast center should be thrown less strongly. +* Preserve or improve existing explosion damage behavior. +* If an explosion kills a Tarinai, keep the appropriate explosion-related death cause, not `衝突`. -Log when: +Ant interaction preservation: -* a value crosses `+0.5` -* a value crosses `+0.75` -* a value crosses `-0.5` -* a value crosses `-0.75` -* a value returns from a visible trait range back into neutral +* Preserve existing ant damage handling from firecracker explosions and dropped-object impacts. +* Do not break worker ants, queen ants, ant corpses, or ant nest reproduction behavior while refactoring forces. -Do not log every small personality change. +Expected result: -Example log messages: +* Normal walking speed remains bounded. +* Knockback from explosions and collisions can exceed walking speed. +* High-speed objects do not frequently tunnel through collisions. +* Collision behavior is handled by shared primitives rather than scattered custom math. -```text -\u201cMochi\u201d has become slightly irritable after winning fights. -\u201cMimi\u201d has become playful after spending time with toys. -\u201cNoko\u201d has become less sensitive after repeated contact with corpses. -\u201cTari\u201d no longer seems especially social. -``` +--- -Use the existing observation log system if one exists. +# 8. Ball-to-ball collision -## Tarinai data UI +Ensure balls collide with each other robustly. -Update the tarinai data UI to show personality information clearly. +Requirements: -It should include: +* Ball vs ball collision must use circle-circle collision. +* Resolve overlap. +* Exchange or reflect velocity in a physically plausible way. +* Work with high-speed balls, not only overlapping balls. +* Avoid infinite acceleration or NaN velocity. +* Do not apply arbitrary maximum speed caps. -1. Birth personality -2. Current personality -3. Visible personality tags derived from current personality +If this is already implemented, verify it and remove any remaining contradictory caps or stale-grid issues. -Example structure: +--- -```text -Personality Tags: -slightly irritable / social +# 9. Item falling speed -Birth Personality: -Aggression: -0.12 -Openness: +0.31 -Sociability: +0.48 -Neuroticism: +0.66 +Current issue: +Item falling motion can look like it changes speed unnaturally. -Current Personality: -Aggression: +0.57 -Openness: +0.34 -Sociability: +0.76 -Neuroticism: +0.22 -``` +Implement: -Keep the UI compact. Do not create a large new panel unless necessary. +* Make the visible item falling motion constant speed. +* Replace nonlinear visual curves such as `Math.pow(dropT, ...)` with linear progress unless a specific item intentionally has a special animation. +* The item should fall at a stable fixed speed from its start height to the ground. +* Keep the existing item placement drop-impact damage behavior. +* Do not remove drop-impact damage. +* Do not make harmless-looking items non-damaging merely because they look harmless. Current placement drop-impact behavior is intentional. -## Implementation constraints +Expected result: -* Keep the code simple and localized. -* Reuse existing behavior, logging, and UI systems where possible. -* Avoid adding unrelated features. -* Avoid adding complex new simulation systems. -* Preserve existing save data as much as possible. -* If old save data lacks personality fields, initialize them safely. -* Clamp all personality values to `-1.0` through `+1.0`. -* Add helper functions where useful, such as: +* Falling items move at a visually consistent constant speed. +* Existing drop impact behavior remains. - * `ensurePersonality(tarinai)` - * `adjustPersonality(tarinai, key, delta, reason)` - * `getPersonalityTraitTags(tarinai)` - * `getTraitStrength(value)` - * `shouldApplyPersonalityBehavior(tarinai, key, direction)` +--- -## Validation +# 10. Solid obstacle and placement logic unification -After implementation: +Current issues: +Fence, nest box, water dish, grass, and placement preview logic are partly fragmented. -* Run syntax checks for all JavaScript files. -* Verify that old tarinai without personality data do not crash the game. -* Verify that personality values are clamped. -* Verify that daily limits work. -* Verify that threshold crossing creates observation logs. -* Verify that minor personality changes do not spam logs. -* Verify that tarinai data UI shows birth personality, current personality, and visible tags. -* Verify that children inherit from parents' current personality with \u00b10.2 random variation. +Implement: + +* Unify placement validation for the object types where overlap matters. +* Overlap is important for: + + * grass + * fence + * nest box + * water dish +* For other items, overlap does not need to be strict unless an existing rule already requires it. +* Do not over-engineer overlap rules for stones, balls, food items, firecrackers, poop, ant nests, ant corpses, or similar objects. + +Solid obstacles: + +* Unify all solid rectangular obstacle collision logic. +* Fence and nest box should use the same obstacle system where possible. +* Rename misleading helpers if needed: + + * For example, functions named only for “fence” but now handling nest boxes too should be renamed or wrapped with neutral names such as `solidObstacle`. +* Ball movement, Tarinai movement, path blocking, and placement checks should reference the same obstacle data. + +Fence collision: + +* Fence collision bounds must match the visible fence appearance. +* If the visual fence is not the same size as the previous hitbox, update the hitbox to match the visual footprint. +* Avoid separate visual size and collision size unless explicitly documented in one definition table. + +Nest box collision: + +* Preserve the existing 3×3 nest box hitbox rule: + + * If the nest box is divided into a 3×3 grid, the collidable cells are: + + * top-left + * top-center + * top-right + * middle-left + * middle-right + * The middle-center, bottom-left, bottom-center, and bottom-right cells are passable/enterable according to the current nest behavior. +* Ball must bounce off these nest box solid cells. +* Tarinai must not path through these solid cells. + +Placement preview: + +* The preview highlight must use the exact same placement validation as actual placement. +* If the preview is red, clicking must not place the object. +* If the preview is green, clicking should place the object at the previewed position. +* Do not silently clamp or auto-correct an out-of-bounds placement to an in-bounds location. +* If the planned placement would extend outside the field, the highlight must turn red. +* Fix any case where a fence preview extending outside the field does not turn red. +* The highlighted preview position should show the true placement footprint, not a different corrected footprint. + +Grass placement: + +* Keep the existing grass planting spot search/check behavior unless it directly conflicts with the unified placement preview/actual placement contract. +* Do not spend time expanding grass checks to every object type. + +Screen resize: + +* Do not implement special preservation behavior for screen resizing. +* Screen size change is allowed to reset the field. +* Do not add complicated coordinate scaling or migration logic for resize. + +Expected result: + +* Placement UI, actual placement, and collision behavior agree. +* No “red but placeable” or “green but placed elsewhere” behavior. +* Grass, fence, nest box, and water dish avoid important overlap. +* Other item overlap can remain permissive. + +--- + +# 11. Spatial query scratch arrays + +Current issue: +`nearbyItems()` and `nearbyTarinai()` may share the same mutable scratch array. Nested spatial queries can overwrite an outer loop’s result. + +Implement: + +* `nearbyItems()` and `nearbyTarinai()` must not share the same mutable scratch array. +* Nested spatial queries must not overwrite an outer loop’s result. +* Use separate scratch arrays, return fresh arrays, or explicitly copy results where nested queries are possible. +* Verify: + + * item interaction loops + * bed/nest occupancy checks + * best bed search + * ball interactions + * Tarinai interaction loops + * ant prey search if it uses nearby queries + +Expected result: + +* Spatial queries are safe even when nested. +* Loops over nearby entities do not mutate unexpectedly. + +--- + +# 12. Spatial rebuild/update phase + +Current issue: +The spatial grid may be rebuilt before entities move, so later collision and interaction passes can use stale positions. + +Implement: + +* Ensure spatial grid data is not stale during collision and interaction passes. +* Prefer phase separation: + + * update items + * update ants if their movement affects targeting/collision + * update Tarinai movement + * rebuild spatial grid + * resolve cross-entity interactions/collisions +* If full phase separation is too invasive, at minimum rebuild the spatial grid after Tarinai movement and before any interaction/collision logic that relies on it. +* Avoid relying on grid data from before entities moved. + +Expected result: + +* Nearby queries use current positions. +* Collision and interaction behavior is more stable. + +--- + +# 13. Duplicate weather log + +Current issue: +Weather changes may create duplicate identical log entries in some builds. + +Implement: + +* Verify `updateWeather()`. +* A weather change must create only one weather log entry. +* Do not log the same weather text twice. +* Preserve the actual weather behavior. + +Expected result: + +* One weather change produces one visible log entry. + +--- + +# 14. Tool/item definition and CSS icon cleanup + +The latest build already has a `TOOL_DEFINITIONS`-style structure. Finish consolidating and remove conflicts. + +Requirements: + +* Use a single current source of truth for tool/item definitions where practical. +* It should include at least: + + * internal id + * Japanese label + * tooltip text + * UI icon or icon renderer + * game appearance reference where applicable + * placement size / footprint + * whether it is placeable + * whether it is scalable + * collision shape if applicable + * simulation-only status if applicable +* Use this definition in the tool UI, placement preview, placement logic, and logs where appropriate. +* Do not leave conflicting duplicate label/icon/size definitions in separate files unless unavoidable. + +Specific icon requirements: + +* `つつく` must use `👈`. +* `つまむ` must use `🤏`. +* `洗浄` must use the shower emoji `🚿`. +* Remove or stop referencing old PNG icons for `つつく` and `つまむ`. +* If `洗浄` has both an emoji and a PNG icon, ensure the displayed tool UI follows the current requested shower emoji specification. +* Fix the nest box tool UI icon so it matches the actual in-game nest box appearance. +* Preserve the ant nest tool icon and ensure it remains visible. +* Ensure every tool that appears in the tool UI has an icon. +* Avoid mojibake/garbled icon rendering. +* CSS icon definitions must be unified to the current specification. +* Remove old CSS generations and conflicting icon overrides. +* Do not rely on CSS order to accidentally choose the correct icon. + +Water tool: + +* `水` must remain removed from the tool UI. +* If water still exists as a simulation object produced by rain or other systems, keep that object behavior. +* `水の皿` remains a valid tool/item. +* Keep `water` as simulation-only if needed. +* Remove leftover UI-only water tool entries, labels, tooltips, and placement handlers unless required by non-UI simulation. + +Static tool buttons: + +* If `index.html` still hardcodes tool buttons, ensure it does not conflict with `TOOL_DEFINITIONS`. +* Prefer generating or validating tool metadata from the definition table if practical. +* At minimum, make sure static buttons and definition data agree. + +Expected result: + +* Tool UI no longer has missing icons, old icons, or mismatched nest box visuals. +* Current icon definitions are clean and non-duplicated. +* Ant nest remains integrated. + +--- + +# 15. Death cause normalization + +Ensure these death causes are applied consistently: + +* Death by old age / lifespan: + + * `天寿を全うした` +* Tarinai deleted because it went too far outside the field: + + * `仕様により画面外で削除` +* Death caused by ball collision or Tarinai collision damage: + + * `衝突` +* Explosion death: + + * Keep explosion-related cause. +* Existing disease-related deaths: + + * Preserve current intended disease death causes unless they conflict with the above. + +Implementation: + +* Centralize death cause normalization if possible. +* Avoid scattered caller-side strings that can drift. +* Ensure observation UI, family tree, memorial/death display, and any other death display show the normalized cause. +* Since save/load is removed, do not implement migration for old death-cause strings. + +Expected result: + +* Death cause strings are consistent. +* Collision deaths do not appear as generic accidents. +* Lifespan deaths show `天寿を全うした`. + +--- + +# 16. Out-of-bounds object policy + +Implement or verify a unified out-of-bounds policy: + +* For objects or entities slightly outside the field: + + * Return them to the nearest valid in-field position. +* For objects or entities far outside the field, or with invalid coordinates such as NaN/Infinity: + + * Delete them. +* For Tarinai deleted by this system: + + * Death cause must be `仕様により画面外で削除`. + * Release its living ID so it may be reused. +* For non-living objects: + + * Delete without a death cause. +* Keep wall bounce behavior for balls at normal field boundaries, but still sanitize balls that somehow get far outside or invalid coordinates. +* Ant workers/queens should not remain permanently invisible offscreen. If they go far out or become invalid, delete or safely return them according to the least disruptive current ant behavior. +* Do not add resize-specific preservation logic; field reset on screen size change is acceptable. + +Expected result: + +* No permanent invisible offscreen objects or actors. +* No NaN entities poison the simulation. +* Far-outside Tarinai deletion has the correct death cause. + +--- + +# 17. Observation relationship highlight + +Current issue: +Relationship highlight during observation can be affected by sprite rotation in some builds. + +Implement or verify: + +* Relationship highlights must be drawn in world/screen space independent of Tarinai sprite rotation. +* Do not draw the highlight inside a rotated sprite transform. +* Ensure highlight position follows the Tarinai but does not rotate with the Tarinai body. +* Use living IDs or stable current object references for relationship lookup where appropriate, not name matching. +* Include the latest intended relationship types: + + * parents + * children + * enemies + * friends, using the current friendship threshold + +Expected result: + +* Highlight remains visually stable regardless of sprite rotation. +* Highlight targets the correct individuals. + +--- + +# 18. Nest box behavior preservation + +Preserve and verify previous nest box fixes: + +* Tarinai inside the nest box should be fully transparent in the main field. +* Nest box can contain up to 5 Tarinai. +* Nest box occupant popup should show each individual on a separate line. +* The nest box solid hitbox must follow the 3×3 rule described above. +* Ball should bounce off nest box solid hitbox cells. +* Living Tarinai IDs must not break nest box occupancy. +* Since save/load is removed, do not add save compatibility for nest box IDs. + +Expected result: + +* Nest box behavior remains stable after obstacle and ID refactoring. + +--- + +# 19. Ant nest behavior preservation + +Preserve the current ant nest feature. + +Do not regress: + +* `ant_nest` remains placeable. +* Each ant nest starts with the intended worker count. +* Worker ants spawn from their home nest. +* Worker ants search for Tarinai and drag them back to the nest. +* Multiple workers dragging the same Tarinai increase carrying speed. +* Dragged Tarinai can struggle and kill workers. +* Dead workers become edible `ant_corpse` items. +* Damaged workers keep their HP when returning to and leaving the nest. +* A full nest can spawn a queen. +* A queen can create a new ant nest in an open spot. +* Queen spawning remains limited according to the current intended daily rule. +* Ant health does not recover. +* Ant sprites and Irasutoya credit remain. + +ID-related requirements: + +* Ants must not use display names to target Tarinai. +* Ant target references must not break after living ID reuse. +* If a target Tarinai dies, is deleted, or its living ID is released, worker ants should safely abandon or retarget according to current behavior. +* Removing save/load must not remove active runtime ant behavior. + +Expected result: + +* Ant nest behavior remains intact after ID, save/load, and spatial refactoring. + +--- + +# 20. Encyclopedia / ecology page + +Preserve and verify the `たりないの生態` addition: + +* It must include an explanation of health. +* It must include an explanation of stress. +* It must use a Tarinai sprite/image with both the health bar and stress bar visible. +* Keep the latest shortened ecology cards and HP/stress meter placement note if present. +* Do not break existing encyclopedia entries. +* Preserve the Irasutoya credit entry added for ant assets. + +--- + +# 21. Naming and Japanese text preservation + +Preserve previous Japanese terminology changes: + +* `喧嘩傷病` must remain renamed to `きずつき病`. +* Lifespan death text must display as `天寿を全うした`. +* Friend relation label should remain `友達` if that is the current intended label. +* Use the existing tone/style for Japanese labels and descriptions. +* Do not reintroduce removed or old names. + +--- + +# 22. Remove obsolete or dead code where safe + +Clean up old or dead specifications, but do not remove active gameplay. + +Remove or simplify where safe: + +* old save/load compatibility code +* old save migration code +* old CSS icon generations +* obsolete tool icon definitions +* UI-only `water` tool leftovers +* unused save-only item serialization fields +* comments describing removed old-save behavior +* old code paths for removed tools such as UI `水` +* old legacy named-personality compatibility stubs if they exist only for old saves +* old `food`, `mirror`, `cardboard`, or similar legacy remnants only if they are truly unreachable and not part of current gameplay + +Keep: + +* Current rain/simulation water behavior if it exists. +* Current grass planting behavior. +* Current tool-size inheritance behavior. +* Current field reset behavior on screen resize. +* Family tree reset feature. +* Item placement drop-impact damage. +* Four-axis personality system. +* Ant nest feature. + +--- + +# 23. Input cleanup + +If practical without destabilizing gameplay, clean up mouse/touch input handling. + +Requirements: + +* Consider converting canvas input to `PointerEvent` handling. +* Keep behavior equivalent on desktop. +* Ensure dragging, pinching, hosing, placing, panning, and clicking remain stable. +* Do not make this a large risky rewrite if the existing input system is stable. +* This is lower priority than save/load removal, IDs, logging, collision, family tree performance, spatial safety, and item falling speed. + +Expected result: + +* Input behavior remains stable. +* Touch and pointer handling do not regress. + +--- + +# 24. Code quality expectations + +Refactor carefully: + +* Prefer small, well-named helper functions. +* Avoid adding more scattered special cases. +* Avoid duplicating collision math. +* Avoid duplicating item/tool metadata. +* Avoid string-matching for gameplay logic where structured data is possible. +* Do not add save compatibility or migration code. +* Do not over-engineer screen-resize preservation. +* Keep intentional odd behaviors when explicitly listed above. + +Add comments only where they clarify non-obvious game rules, especially: + +* disease fight rules +* nest box 3×3 collision cells +* external impulse vs normal movement +* family tree dirty/update scheduling +* reusable living Tarinai IDs +* hidden observation events not counting in statistics +* four-axis personality compatibility +* constant item falling speed +* intentional family tree reset behavior +* intentional item placement drop-impact damage +* ant target safety with reusable living IDs + +--- + +# 25. Validation checklist + +Before returning the zip, verify at minimum: + +Static checks: + +* Run `node --check` on every JavaScript file. +* Ensure there are no `ReferenceError` risks from renamed helpers. +* Ensure all cache version references are updated consistently. +* Ensure save/load functions and UI are removed. +* Ensure no localStorage game-state save/load code remains. +* Ensure old save migration and old-save compatibility comments are removed. + +Manual or scripted behavior checks where practical: + +* The game starts fresh without reading old saves. +* No save/load UI is visible. +* Living Tarinai have internal living IDs. +* Living IDs are not displayed. +* Dead Tarinai living IDs are released and can be reused without corrupting active references. +* Historical family tree/log display snapshots remain stable after death and ID reuse. +* Ant targeting does not break when Tarinai die or living IDs are released. +* Observation events attach by structured IDs/snapshots, not by name substring. +* Hidden observation events do not appear and do not count in statistics. +* `つつく` is not classified as fight. +* Selecting a Tarinai for observation is not counted as a gameplay event/stat. +* Ball vs ball collision works. +* Fast ball hitting Tarinai knocks it by ball velocity / 5. +* Fast collision death cause is `衝突`. +* Explosion throws nearby Tarinai farther than distant Tarinai and is not clamped by walking speed. +* Falling items use visually constant falling speed. +* Existing item placement drop-impact damage still exists. +* Fence preview turns red when the footprint leaves the field. +* Red placement preview cannot place an object. +* Green placement preview places at the same footprint shown. +* Grass, fence, nest box, and water dish respect important overlap checks. +* Other item overlap remains permissive unless already constrained. +* Nest box collision uses the required 3×3 cells. +* Ball bounces off nest box solid cells. +* Spatial query results are safe from nested-query overwrites. +* Spatial grid is not stale during interaction/collision passes. +* A weather change creates only one log entry. +* Family tree hidden/offscreen update is no more frequent than every 30 seconds. +* Family tree visible update happens when dirty, not every 5 seconds. +* Family tree processing does not freeze the game. +* Family tree reset feature still exists and can clear heavy family tree data/cache. +* Family tree reset does not allow stale async renders to restore old tree content. +* Four-axis personality UI still works. +* Legacy named personality is not reintroduced. +* Damage plus total losses greater than total wins can shift personality toward derived `臆病` behavior without creating a legacy personality field. +* `爆発病` and `ずんち病` can initiate fights. +* Other diseases cannot initiate fights. +* `ねむり病` cannot initiate and cannot be targeted. +* Other non-sleep diseases can be targeted. +* Observation UI does not show ball-poke events or avoid events. +* Tool icons are correct: `👈`, `🤏`, `🚿`, nest box matching in-game appearance, and ant nest visible. +* CSS icon definitions are unified to current specs. +* `水` is absent from the tool UI. +* `水の皿` remains available. +* Simulation-only rain water still works if present. +* Lifespan death cause is `天寿を全うした`. +* Far-outside Tarinai deletion cause is `仕様により画面外で削除`. +* Ant nest behavior still works. +* Screen resize may reset the field; no special preservation behavior is required. + +Deliverable: + +* Return a zip named something like `tarinai_colony_game_ant_nest_v6_fixed.zip`. +* Include a concise summary of changed areas and validation results. diff --git a/index.html b/index.html index 9e55382..c95f7b3 100644 --- a/index.html +++ b/index.html @@ -3,13 +3,20 @@ - - - + + たりない観察 - + +
+
+
たりない観察を読み込み中
+
通信量を抑えて初期化しています…
+ +
0%
+
+
@@ -74,6 +81,7 @@
+ @@ -109,7 +117,10 @@

家系図

- +
+ + +
@@ -167,21 +178,21 @@
- - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/js/ants.js b/js/ants.js index 49a17b9..afbc83f 100644 --- a/js/ants.js +++ b/js/ants.js @@ -53,6 +53,8 @@ class AntActor { this.kind = opts.kind || "worker"; this.homeId = opts.homeId || ""; this.targetId = opts.targetId || ""; + this.targetToken = opts.targetToken || 0; + this.targetRef = opts.targetRef || null; this.x = Number.isFinite(opts.x) ? opts.x : 0; this.y = Number.isFinite(opts.y) ? opts.y : 0; this.vx = opts.vx || 0; @@ -60,6 +62,7 @@ class AntActor { this.r = opts.r || (this.kind === "queen" ? ANT_QUEEN_RADIUS : ANT_WORKER_RADIUS); this.maxHp = Number.isFinite(opts.maxHp) ? opts.maxHp : (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP); this.hp = Number.isFinite(opts.hp) ? opts.hp : this.maxHp; + this.hpBarTimer = Number.isFinite(opts.hpBarTimer) ? opts.hpBarTimer : 0; this.state = opts.state || (this.kind === "queen" ? "founding" : "search"); this.age = opts.age || 0; this.seed = opts.seed || Math.random() * 1000; @@ -74,30 +77,19 @@ class AntActor { this.dead = Boolean(opts.dead); } - serialize() { - return { - id: this.id, kind: this.kind, homeId: this.homeId, targetId: this.targetId, - x: this.x, y: this.y, vx: this.vx, vy: this.vy, r: this.r, hp: this.hp, maxHp: this.maxHp, - state: this.state, age: this.age, seed: this.seed, wanderAngle: this.wanderAngle, - nextTurn: this.nextTurn, returnTimer: this.returnTimer, foundingX: this.foundingX, - foundingY: this.foundingY, foundingDelayUntil: this.foundingDelayUntil, carryLogAt: this.carryLogAt, foundingAttempts: this.foundingAttempts, dead: this.dead, - }; - } - - static from(worldRef, data) { - return new AntActor(worldRef, data || {}); - } - homeNest() { return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null; } targetTarinai() { - return (this.world?.tarinai || []).find(t => t && !t.dead && t.id === this.targetId) || null; + if (this.targetRef && !this.targetRef.dead && this.targetRef.id === this.targetId && this.targetRef.liveToken === this.targetToken) return this.targetRef; + const live = this.world?.liveTarinaiById?.(this.targetId, this.targetToken) || null; + this.targetRef = live; + return live; } activeHaulersForTarget() { - return (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId && a.targetId === this.targetId); + return (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId && a.targetId === this.targetId && a.targetToken === this.targetToken); } isLeadHauler() { @@ -116,7 +108,7 @@ class AntActor { const d = distXY(this.x, this.y, t.x, t.y); if (d > maxDist) continue; if ((t.antDraggedTimer || 0) > 0 && (t.antDraggedBy || "") && t.antDraggedBy !== this.id) { - const haulers = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId === t.id).length; + const haulers = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId === t.id && a.targetToken === t.liveToken).length; if (haulers >= ANT_NEST_MAX_OUTSIDE) continue; } const weak = clamp((100 - (t.energy || 0)) / 100, 0, 1); @@ -131,9 +123,12 @@ class AntActor { update(dt) { if (this.dead) return; + const hpBefore = this.hp; this.age += dt; - if (this.kind === "queen") return this.updateQueen(dt); - return this.updateWorker(dt); + if (this.kind === "queen") this.updateQueen(dt); + else this.updateWorker(dt); + if (Number.isFinite(hpBefore) && this.hp < hpBefore - 0.01) this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.25); + this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); } updateWorker(dt) { @@ -156,6 +151,8 @@ class AntActor { if (target) { this.state = "drag"; this.targetId = target.id; + this.targetToken = target.liveToken || 0; + this.targetRef = target; this.carryLogAt = this.world?.time || 0; target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.45); target.antDraggedBy = this.id; @@ -209,6 +206,8 @@ class AntActor { const target = this.targetTarinai(); if (!target || target.dead || target.insideNestBoxId) { this.targetId = ""; + this.targetToken = 0; + this.targetRef = null; this.state = "return"; return; } @@ -225,6 +224,8 @@ class AntActor { if (dHome < Math.max(18, home.r * 0.66)) { this.world.completeAntHaul?.(home, target); this.targetId = ""; + this.targetToken = 0; + this.targetRef = null; this.state = "return"; return; } @@ -253,7 +254,7 @@ class AntActor { if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) { this.carryLogAt = this.world.time || 0; - this.world.log(`${target.name}\u304c\u30a2\u30ea\u306b\u5f15\u3063\u5f35\u3089\u308c\u3066\u3044\u308b\u3002`, "event"); + this.world.log(`${target.name}\u304c\u30a2\u30ea\u306b\u5f15\u3063\u5f35\u3089\u308c\u3066\u3044\u308b\u3002`, "event", { participants: [target] }); } if (this.hp <= 0) this.dieAsCorpse(home); } @@ -373,12 +374,14 @@ class AntActor { } drawHealthBar(ctx) { + if ((this.hpBarTimer || 0) <= 0) return; const maxHp = Math.max(1, this.maxHp || (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP)); const pct = clamp((this.hp || 0) / maxHp, 0, 1); const bw = this.kind === "queen" ? 28 : 18; const bh = this.kind === "queen" ? 4 : 3; const y = this.y - this.r * (this.kind === "queen" ? 2.0 : 2.6); ctx.save(); + ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.65, 0.35, 1); ctx.fillStyle = "rgba(255,252,242,0.86)"; roundedRect(ctx, this.x - bw / 2 - 1.5, y - 1.5, bw + 3, bh + 3, 4); ctx.fill(); diff --git a/js/assets.js b/js/assets.js index e5a8cb2..efd8b2b 100644 --- a/js/assets.js +++ b/js/assets.js @@ -8,8 +8,8 @@ const imageAssetIndex = new Map(); const scaledSpriteCache = new Map(); const stainOverlayCache = new Map(); -const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02"]); -const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02"]); +const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02", "genkotsu"]); +const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02", "genkotsu"]); function trimCanvasCache(cache, limit = 420) { while (cache.size > limit) { @@ -160,14 +160,18 @@ function getRenderableImage(id, fallbackId = "smile") { return null; } -function loadImages(ids = null) { +function loadImages(ids = null, onProgress = null) { setupAssetIndex(); const requested = ids ? new Set(ids) : INITIAL_IMAGE_IDS; - const tasks = []; - for (const id of requested) { - const asset = imageAssetIndex.get(id); - if (asset) tasks.push(loadImageAsset(asset, "high")); - } + const assets = [...requested].map(id => imageAssetIndex.get(id)).filter(Boolean); + const total = Math.max(assets.length, 1); + let done = 0; + if (typeof onProgress === "function") onProgress(done, total, null); + const tasks = assets.map(asset => loadImageAsset(asset, "high").then((img) => { + done += 1; + if (typeof onProgress === "function") onProgress(done, total, asset); + return img; + })); return Promise.all(tasks).then(() => undefined); } diff --git a/js/data.js b/js/data.js index fe68986..dea50df 100644 --- a/js/data.js +++ b/js/data.js @@ -34,6 +34,7 @@ const DECOR_ASSETS = [ { id: "zunchi_02", path: "assets/objects/zunchi_02.png" }, { id: "ant_queen", path: "assets/objects/ant_queen.png" }, { id: "ant_worker", path: "assets/objects/ant_worker.png" }, + { id: "genkotsu", path: "assets/objects/genkotsu.png" }, ]; const TOOL_DEFINITIONS = Object.freeze({ @@ -42,7 +43,7 @@ const TOOL_DEFINITIONS = Object.freeze({ poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" }, new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.png", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, - water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", icon: "assets/ui/tool_water_hose.png", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, + water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, @@ -51,6 +52,7 @@ const TOOL_DEFINITIONS = Object.freeze({ sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.png", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.png", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.png", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, + genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 72, amount: 100, icon: "assets/ui/tool_genkotsu.png", tooltip: "\u4e0a\u304b\u3089\u6b63\u7fa9\u3052\u3093\u3053\u3064\u3092\u843d\u3068\u3057\u3001\u30a2\u30ea\u3068\u305f\u308a\u306a\u3044\u306b\u30c0\u30e1\u30fc\u30b8\u3068\u5439\u3063\u98db\u3073\u3092\u4e0e\u3048\u308b\u3002" }, bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.png", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.png", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.png", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, @@ -131,6 +133,7 @@ const ALPHA_BOUNDS = { zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 }, ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 }, ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 }, + genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 }, }; 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"]; @@ -145,7 +148,6 @@ const CONFIG = { traceLimit: 64, splatLimit: 48, effectLimit: 96, - saveKey: "tarinai-colony-save-v38", worldPadding: 30, hungerPerMinute: 11.2, lonelinessPerMinute: 7.6, diff --git a/js/items.js b/js/items.js index 6a666c5..30c8d86 100644 --- a/js/items.js +++ b/js/items.js @@ -76,6 +76,12 @@ class Item { this.fuseTimer = 5; this.fuseMax = 5; } + if (type === "genkotsu") { + this.amount = 100; + this.dropMax = 0; + this.dropImpactDone = false; + this.impactFlash = 0; + } if (isServingFoodType(type)) { this.toolSize = "medium"; this.foodServingScale = 1; @@ -106,6 +112,11 @@ class Item { else this.amount = 0; } } + if (this.type === "genkotsu") { + this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt); + if (this.dropImpactDone) this.amount -= dt * 115; + else this.amount = Math.max(this.amount || 0, 100); + } if (this.type === "ball") this.updateBall(dt, worldRef); if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); if (this.type === "grass") { @@ -176,8 +187,6 @@ class Item { const speed = Math.hypot(this.vx || 0, this.vy || 0); const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120); const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || []; - let solidChecks = 0; - const maxSolidChecks = CONFIG.maxFenceCollisionChecks ?? 24; for (const it of items) { if (!it || it === this || it.dead) continue; if (it.type === "bed") { @@ -187,8 +196,6 @@ class Item { } const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : []; if (!rects.length) continue; - if (solidChecks >= maxSolidChecks) continue; - solidChecks += 1; for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it); } } @@ -467,148 +474,6 @@ class Item { 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, lifeSpan: this.lifeSpan, wither: this.wither, - comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, foodServingScale: this.foodServingScale, foodServingsMax: this.foodServingsMax, foodServingsRemaining: this.foodServingsRemaining, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax, - vx: this.vx, vy: this.vy, prevX: this.prevX, prevY: this.prevY, - spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId, - lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle, - antCount: this.antCount, antSpawnTimer: this.antSpawnTimer, antSpawnCooldown: this.antSpawnCooldown, antWorkers: Array.isArray(this.antWorkers) ? this.antWorkers.slice() : undefined, queenSpawnAt: this.queenSpawnAt, workerSprite: this.workerSprite, decayTimer: this.decayTimer - }; - } - static from(o) { - const type = o?.type || "grass"; - const it = Object.create(Item.prototype); - Item._loadId = (Item._loadId || 0) + 1; - it.id = o.id || `loaded-${Item._loadId}`; - it.type = type; - it.x = o.x; - it.y = o.y; - it.r = itemRadiusFor(type, 12); - it.amount = o.amount ?? itemAmountFor(type, 80); - it.age = o.age || 0; - it.seed = o.seed || Math.random() * 1000; - it.dropTimer = 0; - it.dropMax = 0; - it.dropImpactDone = false; - it.lifecycleTimer = o.lifecycleTimer ?? rand(0, type === "grass" ? 1.15 : 0.55); - it.lifecycleInterval = o.lifecycleInterval ?? (type === "grass" ? (1.75 + stableUnit(it.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(it.id, "zunchi-life") * 0.45) : 0)); - if (type === "grass") { - it.growth = 0.25; - it.health = 1; - it.seedTimer = rand(18, 38); - it.eatenAmount = 0; - it.fertilityBoost = 0; - } - if (type === "bed") { - it.comfort = 1; - it.wear = 0; - } - if (type === "ball") { - it.vx = o.vx || 0; - it.vy = o.vy || 0; - it.prevX = o.prevX ?? o.x; - it.prevY = o.prevY ?? o.y; - it.spin = o.spin || 0; - it.spinVelocity = o.spinVelocity || 0; - it.lastKickedAt = o.lastKickedAt ?? -999; - it.lastKickerId = o.lastKickerId || ""; - it.lastPokedAt = o.lastPokedAt ?? -999; - it.pokeCombo = o.pokeCombo || 0; - it.lastPokeAngle = o.lastPokeAngle || rand(0, Math.PI * 2); - } - if (type === "zunchi") { - it.stage = "fresh"; - it.stageTimer = 0; - it.fertility = 0; - it.freshness = 1; - it.zunchiVariant = "zunchi"; - } - if (type === "ant_nest") { - it.antCount = Number.isFinite(o.antCount) ? clamp(Math.round(o.antCount), 0, ANT_NEST_MAX_COUNT || 10) : (ANT_NEST_START_COUNT || 6); - it.antSpawnTimer = o.antSpawnTimer ?? rand(0.6, 2.2); - it.antSpawnCooldown = o.antSpawnCooldown ?? rand(0.8, 2.4); - const legacyPool = Array.isArray(o.antWorkers) ? o.antWorkers : (Array.isArray(o.workerHpPool) ? o.workerHpPool : null); - it.antWorkers = Array.isArray(legacyPool) - ? legacyPool.map(v => clamp(Number(v) || ANT_WORKER_HP || 32, 0.1, ANT_WORKER_HP || 32)).slice(0, ANT_NEST_MAX_COUNT || 10) - : (typeof buildAntWorkerPool === "function" - ? buildAntWorkerPool(it.antCount) - : Array.from({ length: it.antCount }, () => ANT_WORKER_HP || 32)); - it.queenSpawnAt = Number.isFinite(o.queenSpawnAt) ? o.queenSpawnAt : -999; - } - if (type === "ant_corpse") { - it.workerSprite = o.workerSprite || "ant_worker"; - it.decayTimer = o.decayTimer || 0; - } - if (type === "firecracker") { - it.fuseTimer = 5; - it.fuseMax = 5; - } - it.stage = o.stage || it.stage; - it.stageTimer = o.stageTimer || 0; - it.fertility = o.fertility || 0; - it.freshness = o.freshness ?? it.freshness; - it.growth = o.growth ?? it.growth; - it.health = o.health ?? it.health; - it.seedTimer = o.seedTimer ?? it.seedTimer; - it.eatenAmount = o.eatenAmount || 0; - it.fertilityBoost = o.fertilityBoost || 0; - it.lifeSpan = o.lifeSpan ?? it.lifeSpan; - it.wither = o.wither ?? it.wither ?? 0; - it.comfort = o.comfort ?? it.comfort; - it.wear = o.wear ?? it.wear; - it.crawlOpen = o.crawlOpen ?? it.crawlOpen; - it.vx = o.vx ?? it.vx ?? 0; - it.vy = o.vy ?? it.vy ?? 0; - it.prevX = o.prevX ?? it.prevX ?? it.x; - it.prevY = o.prevY ?? it.prevY ?? it.y; - it.spin = o.spin ?? it.spin ?? 0; - it.spinVelocity = o.spinVelocity ?? it.spinVelocity ?? 0; - it.lastKickedAt = o.lastKickedAt ?? it.lastKickedAt ?? -999; - it.lastKickerId = o.lastKickerId || it.lastKickerId || ""; - it.lastPokedAt = o.lastPokedAt ?? it.lastPokedAt ?? -999; - it.pokeCombo = o.pokeCombo ?? it.pokeCombo ?? 0; - it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2); - it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi"; - it.antCount = Number.isFinite(o.antCount) ? clamp(Math.round(o.antCount), 0, ANT_NEST_MAX_COUNT || 10) : it.antCount; - it.antSpawnTimer = o.antSpawnTimer ?? it.antSpawnTimer; - it.antSpawnCooldown = o.antSpawnCooldown ?? it.antSpawnCooldown; - if (type === "ant_nest") { - const legacyPool = Array.isArray(o.antWorkers) ? o.antWorkers : (Array.isArray(o.workerHpPool) ? o.workerHpPool : null); - if (Array.isArray(legacyPool)) { - it.antWorkers = legacyPool.map(v => clamp(Number(v) || 0, 0, ANT_WORKER_HP || 32)).filter(v => v > 0).slice(0, ANT_NEST_MAX_COUNT || 10); - } else if (!Array.isArray(it.antWorkers)) { - it.antWorkers = typeof buildAntWorkerPool === "function" - ? buildAntWorkerPool(it.antCount || (ANT_NEST_START_COUNT || 6)) - : Array.from({ length: it.antCount || (ANT_NEST_START_COUNT || 6) }, () => ANT_WORKER_HP || 32); - } - it.queenSpawnAt = Number.isFinite(o.queenSpawnAt) ? o.queenSpawnAt : (it.queenSpawnAt ?? -999); - } - it.workerSprite = o.workerSprite || it.workerSprite; - it.decayTimer = o.decayTimer ?? it.decayTimer; - it.toolSize = o.toolSize || it.toolSize || "medium"; - it.foodServingScale = o.foodServingScale || it.foodServingScale || ({ small: 0.68, medium: 1.0, large: 1.48 }[it.toolSize] || 1); - if (isServingFoodType(it.type)) { - const servingMax = foodServingsForSize(it.toolSize); - it.foodServingsMax = Number.isFinite(o.foodServingsMax) ? o.foodServingsMax : servingMax; - if (Number.isFinite(o.foodServingsRemaining)) { - it.foodServingsRemaining = clamp(o.foodServingsRemaining, 0, it.foodServingsMax); - } else { - it.foodServingsRemaining = Math.max(1, Math.min(it.foodServingsMax, Math.ceil(it.foodServingsMax * clamp((o.amount ?? it.amount) / 70, 0.05, 1)))); - } - it.amount = it.foodServingsRemaining; - } - it.blastScale = o.blastScale || it.blastScale || 1; - it.fuseTimer = o.fuseTimer ?? it.fuseTimer; - it.fuseMax = o.fuseMax ?? it.fuseMax; - it.dropTimer = 0; - it.dropMax = 0; - it.dropImpactDone = true; - return it; - } draw(ctx, t, lighting = null) { const lightState = lighting || getLightingState(world); const night = clamp(lightState.nightStrength * 1.35, 0, 1); @@ -618,7 +483,8 @@ class Item { const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null; const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1); const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; - const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0; + const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170; + const dropY = dropT > 0 ? -dropFallHeight * dropT : 0; const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18)); if (this.type !== "trace" && this.type !== "splat") { const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null; @@ -689,13 +555,8 @@ class Item { ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2); ctx.fill(); } else if (fightMochi) { - ctx.fillStyle = "#d55632"; - ctx.font = `${Math.round((this.r || 13) * 1.02)}px sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("\u2726", 0, 1); - ctx.strokeStyle = "rgba(168,76,42,0.72)"; - ctx.lineWidth = 1.4; + ctx.strokeStyle = "rgba(168,76,42,0.82)"; + ctx.lineWidth = 1.8; ctx.beginPath(); ctx.moveTo(-this.r * 0.44, -this.r * 0.42); ctx.lineTo(this.r * 0.44, this.r * 0.42); @@ -725,6 +586,42 @@ class Item { ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("Z", 0, this.r * 1.18); + } else if (this.type === "genkotsu") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); + const impact = clamp(this.impactFlash || 0, 0, 1); + ctx.save(); + ctx.shadowColor = "rgba(54,42,31,0.26)"; + ctx.shadowBlur = 5 + impact * 8; + ctx.shadowOffsetY = 4; + const squashY = 1 - impact * 0.08; + const stretchX = 1 + impact * 0.05; + ctx.scale(stretchX, squashY); + if (img) { + const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null; + const w = this.r * 2.55; + const h = w * (metrics?.ratio || 1.27); + ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h); + } else { + ctx.fillStyle = "rgba(255,255,255,0.94)"; + ctx.strokeStyle = "rgba(64,64,64,0.78)"; + ctx.lineWidth = 5; + roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(42,46,52,0.92)"; + ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("\u6b63", 0, -this.r * 0.74); + ctx.fillText("\u7fa9", 0, -this.r * 0.27); + } + if (impact > 0.01) { + ctx.globalAlpha = impact * 0.20; + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); } else if (this.type === "water_bowl") { ctx.fillStyle = "rgba(171, 120, 76, 0.72)"; ctx.strokeStyle = "rgba(94, 69, 48, 0.55)"; @@ -812,21 +709,26 @@ class Item { ctx.stroke(); } } else if (this.type === "nest_box") { - ctx.fillStyle = styleNight ? "#7d6040" : (styleWarm ? "#b98246" : "#9a6b3d"); - ctx.strokeStyle = styleNight ? "#4a3a2d" : "#5d4028"; - ctx.lineWidth = 2.4; + ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237"); + ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24"; + ctx.lineWidth = 2.6; roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9); ctx.fill(); ctx.stroke(); - ctx.fillStyle = styleNight ? "#3b2f27" : "#65432a"; - ctx.beginPath(); - ctx.arc(0, -this.r * 0.02, this.r * 0.42, 0, Math.PI * 2); + ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b"; + roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6); + ctx.fill(); + ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)"; + ctx.lineWidth = 2.0; + ctx.beginPath(); + ctx.moveTo(-this.r * 0.95, -this.r * 0.50); + ctx.lineTo(this.r * 0.95, -this.r * 0.50); + ctx.moveTo(-this.r * 0.95, this.r * 0.48); + ctx.lineTo(this.r * 0.95, this.r * 0.48); + ctx.stroke(); + ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f"; + ctx.beginPath(); + ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = "rgba(255,230,152,0.38)"; - for (let i = 0; i < 6; i++) { - ctx.beginPath(); - ctx.ellipse(randSeed(this.seed + i, -this.r * 0.84, this.r * 0.84), randSeed(this.seed + 20 + i, this.r * 0.32, this.r * 0.68), this.r * 0.26, this.r * 0.045, randSeed(this.seed + 40 + i, -0.7, 0.7), 0, Math.PI * 2); - ctx.fill(); - } } else if (this.type === "ant_nest") { const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); diff --git a/js/main.js b/js/main.js index d2e6542..3b9286f 100644 --- a/js/main.js +++ b/js/main.js @@ -6,6 +6,25 @@ let fpsFrames = 0; let perfResizeTimer = 0; let lastPerfLevel = 0; window.__tarinaiFps = 0; + +function setLoadingProgress(text = "", ratio = 0) { + const screen = document.getElementById("loadingScreen"); + if (!screen) return; + const pct = clamp(Number(ratio) || 0, 0, 1); + const label = document.getElementById("loadingText"); + const bar = document.getElementById("loadingBar"); + const percent = document.getElementById("loadingPercent"); + if (label && text) label.textContent = text; + if (bar) bar.style.width = `${Math.round(pct * 100)}%`; + if (percent) percent.textContent = `${Math.round(pct * 100)}%`; +} + +function hideLoadingScreen() { + setLoadingProgress("\u8d77\u52d5\u5b8c\u4e86", 1); + const screen = document.getElementById("loadingScreen"); + if (!screen) return; + window.setTimeout(() => screen.classList.add("hidden"), 120); +} function loop() { const t = nowSec(); const rawDt = t - last; @@ -38,16 +57,25 @@ function loop() { requestAnimationFrame(loop); } -loadImages().then(() => { +setLoadingProgress("\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d", 0.08); +loadImages(null, (done, total) => { + const p = total > 0 ? done / total : 1; + setLoadingProgress(`\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d ${done}/${total}`, 0.08 + p * 0.46); +}).then(() => { + setLoadingProgress("UI\u3092\u6e96\u5099\u4e2d", 0.62); bindUI(); + setLoadingProgress("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u69cb\u7bc9\u4e2d", 0.72); applyFieldLayout("garden"); world.reset(null, "garden"); selectTool("observe"); + setLoadingProgress("\u8868\u793a\u3092\u521d\u671f\u5316\u4e2d", 0.84); renderLog(world.logs); renderArchive(); render(); renderStats(); syncTopButtons(); if (typeof startBackgroundImageLoading === "function") startBackgroundImageLoading(); + setLoadingProgress("\u30b7\u30df\u30e5\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u958b\u59cb\u4e2d", 0.94); loop(); + requestAnimationFrame(hideLoadingScreen); }); diff --git a/js/sim_core.js b/js/sim_core.js index 43fb3bb..a8330e9 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -64,8 +64,6 @@ function stableUnit(seed, salt = "") { return ((h >>> 0) % 100000) / 100000; } -// Legacy named personalities were removed. Keep these stubs only so old save data -// and old UI fallback references cannot revive the pre-four-axis personality model. const PERSONALITIES = Object.freeze({}); const PERSONALITY_IDS = []; @@ -255,12 +253,14 @@ function relationDefaults() { function isParentChild(a, b) { if (!a || !b) return false; - return Boolean(a.parents?.includes(b.id) || b.parents?.includes(a.id)); + const aKey = a.familyKey || a.id; + const bKey = b.familyKey || b.id; + return Boolean(a.parents?.includes(bKey) || b.parents?.includes(aKey)); } function relationDisplayName(worldRef, id) { if (!id) return "\u306a\u3057"; - const live = worldRef?.tarinai?.find(t => t.id === id); + const live = worldRef?.tarinai?.find(t => t.id === id || t.familyKey === id); if (live) return live.name; const fam = worldRef?.family?.[id]; return fam?.name || "\u4e0d\u660e"; diff --git a/js/tarinai.js b/js/tarinai.js index 5cfaf88..65532f1 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -17,7 +17,9 @@ function tarinaiRoundRectPath(ctx, x, y, w, h, r) { class Tarinai { constructor(world, opts = {}) { this.world = world; - this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); + this.familyKey = opts.familyKey || opts.archiveKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); + this.id = opts.id || ""; + this.liveToken = opts.liveToken || 0; this.name = opts.name || makeName(world); // Legacy string personality is intentionally ignored. The game now uses only the four-axis personality model. this.personality = ""; @@ -25,7 +27,7 @@ class Tarinai { const typeMeta = SPRITES.find(s => s.id === this.type); if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId(); this.birthPersonality = normalizePersonality(opts.birthPersonality || opts.personalityBirth); - if (!opts.birthPersonality && !opts.personalityBirth) this.birthPersonality = randomBirthPersonality(this.id); + if (!opts.birthPersonality && !opts.personalityBirth) this.birthPersonality = randomBirthPersonality(this.familyKey); this.currentPersonality = normalizePersonality(opts.currentPersonality || opts.personalityCurrent || this.birthPersonality); this.personalityDaily = opts.personalityDaily && typeof opts.personalityDaily === "object" ? JSON.parse(JSON.stringify(opts.personalityDaily)) : null; ensurePersonality(this); @@ -76,8 +78,8 @@ class Tarinai { 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.goodMode = opts.goodMode ?? stableChoice(this.familyKey, "good-mode", displayNormalSprites()) ?? "smile"; + this.facing = opts.facing ?? (stableUnit(this.familyKey, "facing") < 0.5 ? -1 : 1); this.visualFacing = opts.visualFacing ?? this.facing; this.facingLockUntil = opts.facingLockUntil ?? 0; this.eatTimer = opts.eatTimer ?? 0; @@ -95,7 +97,7 @@ class Tarinai { this.birthRitualLeader = !!opts.birthRitualLeader; this.pokeFlashTimer = opts.pokeFlashTimer ?? 0; this.zunchiStain = opts.zunchiStain ?? 0; - this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain"); + this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.familyKey, "zunchi-stain"); this.zunchiDisease = Boolean(opts.zunchiDisease); this.zunchiDiseaseSeverity = opts.zunchiDiseaseSeverity ?? (this.zunchiDisease ? 1 : 0); this.zunchiDiseaseCooldown = opts.zunchiDiseaseCooldown ?? 0; @@ -120,7 +122,7 @@ class Tarinai { this.focusPulseTimer = opts.focusPulseTimer ?? 0; if (this.isZunchiSlave) this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; this.tempComfort = opts.tempComfort ?? 0.62; - this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55; + this.tempTimer = opts.tempTimer ?? stableUnit(this.familyKey, "temp-start") * 0.55; this.parents = opts.parents || []; this.parentNames = opts.parentNames || []; this.children = opts.children || []; @@ -143,60 +145,25 @@ class Tarinai { 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.fallDir = opts.fallDir ?? (stableUnit(this.familyKey, "fall-dir") < 0.5 ? -1 : 1); this.blastSpinTimer = opts.blastSpinTimer ?? 0; this.blastSpinMax = opts.blastSpinMax ?? 0; this.postBirthPeaceTimer = opts.postBirthPeaceTimer ?? 0; - this.sleepStart = 0.60 + stableUnit(this.id, "sleep-start") * 0.22; - this.sleepEnd = 0.15 + stableUnit(this.id, "sleep-end") * 0.14; - this.sleepFacing = stableUnit(this.id, "sleep-facing") < 0.5 ? -1 : 1; + this.sleepStart = 0.60 + stableUnit(this.familyKey, "sleep-start") * 0.22; + this.sleepEnd = 0.15 + stableUnit(this.familyKey, "sleep-end") * 0.14; + this.sleepFacing = stableUnit(this.familyKey, "sleep-facing") < 0.5 ? -1 : 1; this.nextBubbleAt = 0; this.nextSleepBubbleAt = 0; this.nextFearBubbleAt = 0; this.nextCursorHeartAt = opts.nextCursorHeartAt ?? 0; this.cursorPetting = opts.cursorPetting ?? 0; - this.aiTimer = opts.aiTimer ?? stableUnit(this.id, "ai-start") * 0.22; - this.envTimer = opts.envTimer ?? stableUnit(this.id, "env-start") * 0.55; + this.aiTimer = opts.aiTimer ?? stableUnit(this.familyKey, "ai-start") * 0.22; + this.envTimer = opts.envTimer ?? stableUnit(this.familyKey, "env-start") * 0.55; this.visibleSpriteId = opts.visibleSpriteId || null; this.spriteLockUntil = opts.spriteLockUntil ?? 0; this.entryTimer = opts.entryTimer ?? 0; } - serialize() { - return { - id: this.id, name: this.name, type: this.type, x: this.x, y: this.y, vx: this.vx, vy: this.vy, - scale: this.scale, age: this.age, lifeSpan: this.lifeSpan, generation: this.generation, hasPaired: this.hasPaired, - dead: this.dead, deathReason: this.deathReason, lastDamageCause: this.lastDamageCause, lastDamageAmount: this.lastDamageAmount, lastDamageAt: this.lastDamageAt, lastMajorDamageCause: this.lastMajorDamageCause, lastMajorDamageAmount: this.lastMajorDamageAmount, lastMajorDamageAt: this.lastMajorDamageAt, - hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer, stressBarTimer: this.stressBarTimer, - affection: this.affection, need: this.need, state: this.state, - goodMode: this.goodMode, facing: this.facing, visualFacing: this.visualFacing, facingLockUntil: this.facingLockUntil, eatTimer: this.eatTimer, - foodReactTimer: this.foodReactTimer, surpriseTimer: this.surpriseTimer, fearTimer: this.fearTimer, - intimidateTimer: this.intimidateTimer, intimidateTargetId: this.intimidateTargetId, intimidatedTimer: this.intimidatedTimer, - birthRitualTimer: this.birthRitualTimer, birthRitualMax: this.birthRitualMax, birthPartnerId: this.birthPartnerId, - birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader, - pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed, - totalFightLosses: this.totalFightLosses, totalFightWins: this.totalFightWins, isZunchiSlave: this.isZunchiSlave, formerName: this.formerName, loveMochiTimer: this.loveMochiTimer, fightMochiTimer: this.fightMochiTimer, - sleepDisease: this.sleepDisease, sleepDiseaseCooldown: this.sleepDiseaseCooldown, explosionDisease: this.explosionDisease, explosionDiseaseTimer: this.explosionDiseaseTimer, fightDisease: this.fightDisease, fightDiseaseCooldown: this.fightDiseaseCooldown, lastBleedAt: this.lastBleedAt, favorite: this.favorite, insideNestBoxId: this.insideNestBoxId || null, nestFade: this.nestFade || 0, nestBoxEnteredAt: this.nestBoxEnteredAt, nestBoxStayUntil: this.nestBoxStayUntil, focusPulseTimer: this.focusPulseTimer || 0, sleepDiseaseAnchorX: this.sleepDiseaseAnchorX, sleepDiseaseAnchorY: this.sleepDiseaseAnchorY, records: this.records ? this.records.slice(0, 42) : [], - zunchiDisease: this.zunchiDisease, zunchiDiseaseSeverity: this.zunchiDiseaseSeverity, zunchiDiseaseCooldown: this.zunchiDiseaseCooldown, - tempComfort: this.tempComfort, tempTimer: this.tempTimer, - parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime, - fightTimer: this.fightTimer, fightCooldown: this.fightCooldown, fightTargetId: this.fightTargetId, fightTargetIds: this.fightTargetIds, targetGiveupKey: this.targetGiveupKey, targetGiveupUntil: this.targetGiveupUntil, - panicTarget: this.panicTarget, panicTargetUntil: this.panicTargetUntil, panicStuckTimer: this.panicStuckTimer, - nextHeadbutt: this.nextHeadbutt, digest: this.digest, poopCount: this.poopCount, stretchTimer: this.stretchTimer, - grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer, - defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId, - relationships: this.relationships, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir, - birthPersonality: normalizePersonality(this.birthPersonality), currentPersonality: normalizePersonality(this.currentPersonality), personalityDaily: this.personalityDaily || null, - blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer, - lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer, nextCursorHeartAt: this.nextCursorHeartAt, cursorPetting: this.cursorPetting, - entryTimer: this.entryTimer || 0, - }; - } - - 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); } @@ -221,7 +188,7 @@ class Tarinai { 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); + const p = this.world.tarinai.find(t => (t.familyKey || t.id) === id && !t.dead); if (!p) continue; const d = dist(this, p); if (d < bestD) { best = p; bestD = d; } @@ -292,7 +259,6 @@ class Tarinai { const changedB = this.adjustPersonality?.("aggression", -0.015, "after being hurt") || 0; this.fearTimer = Math.max(this.fearTimer || 0, 1.2); this.stress = clamp((this.stress || 0) + 5, 0, 130); - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.35, "\u3053\u308f\u3044\u2026", "rgba(90,80,120,0.78)"); return Boolean(changedA || changedB); } @@ -313,10 +279,12 @@ class Tarinai { if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } } - strongestRelation(kind = "friend") { + strongestRelation(kind = "friend", liveOnly = true) { let bestId = null; let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD; + const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null; for (const [id, rel] of Object.entries(this.relationships || {})) { + if (liveIds && !liveIds.has(id)) continue; const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); if (score > bestScore) { bestId = id; bestScore = score; } } @@ -575,7 +543,7 @@ class Tarinai { 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"; + return stableChoice(this.familyKey || this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile"; } infectZunchiDisease(source = null, force = false) { @@ -625,7 +593,7 @@ class Tarinai { } if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) { this.infectZunchiDisease(null, true); - this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident"); + this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); } if (this.zunchiDisease) { this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); @@ -634,7 +602,7 @@ class Tarinai { if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); this.emitZunchiDiseaseEffect(dt); if (this.zunchiDiseaseSeverity <= 0.04) { - if (this.recoverZunchiDisease("\u4f53\u529b\u304c\u623b\u308a\u3001\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident"); + if (this.recoverZunchiDisease("\u4f53\u529b\u304c\u623b\u308a\u3001\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); } } } @@ -661,7 +629,7 @@ class Tarinai { this.fightTargetIds = []; this.thought = "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(72,76,120,0.76)"); - this.world?.log?.(`${this.name}\u306f\u306d\u3080\u308a\u75c5\u306b\u304b\u304b\u3063\u305f\u3002`, "accident"); + this.world?.log?.(`${this.name}\u306f\u306d\u3080\u308a\u75c5\u306b\u304b\u304b\u3063\u305f\u3002`, "accident", { participants: [this] }); return true; } @@ -676,7 +644,7 @@ class Tarinai { this.thought = reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"; this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "!", "rgba(72,76,120,0.76)"); - this.world?.log?.(`${this.name}\u306e\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident"); + this.world?.log?.(`${this.name}\u306e\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); return true; } @@ -687,7 +655,7 @@ class Tarinai { this.fearTimer = Math.max(this.fearTimer || 0, 0.55); this.thought = "\u7206\u767a\u75c5\u3067\u843d\u3061\u7740\u304b\u306a\u3044"; this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.2, "!", "rgba(178,78,42,0.82)"); - this.world?.log?.(`${this.name}\u306f\u7206\u767a\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident"); + this.world?.log?.(`${this.name}\u306f\u7206\u767a\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); return true; } @@ -698,7 +666,7 @@ class Tarinai { this.thought = reason || "\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"; this.stress = clamp(this.stress - 10, 0, 130); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); - this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident"); + this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); return true; } @@ -713,7 +681,7 @@ class Tarinai { this.fearTimer = Math.max(this.fearTimer || 0, 0.35); this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "?", "rgba(150,78,44,0.80)"); - this.world?.log?.(`${this.name}\u306f\u304d\u305a\u3064\u304d\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight"); + this.world?.log?.(`${this.name}\u306f\u304d\u305a\u3064\u304d\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight", { participants: [this] }); return true; } @@ -724,7 +692,7 @@ class Tarinai { this.thought = reason || "\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"; this.stress = clamp(this.stress - 12, 0, 130); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); - this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight"); + this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight", { participants: [this] }); return true; } @@ -866,7 +834,7 @@ class Tarinai { if (this.zunchiDisease) { this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - power * (this.energy > 70 ? 0.050 : 0.028)); if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) { - this.world?.log?.(`${this.name}\u306f\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident"); + this.world?.log?.(`${this.name}\u306f\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); } } if (this.explosionDisease) this.recoverExplosionDisease("\u6c34\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); @@ -895,7 +863,7 @@ class Tarinai { if (Math.random() < dt * 0.000375) this.infectFightDisease(); } if (this.zunchiDisease && Math.random() < dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0)) { - if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident"); + if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); } if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); @@ -1143,7 +1111,7 @@ class Tarinai { this.aiTimer += dt; const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02; const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0; - const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.id, "ai-interval") * 0.09 + popPerf * 0.055); + const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.familyKey || this.id, "ai-interval") * 0.09 + popPerf * 0.055); if (this.aiTimer >= aiInterval) { const aiDt = Math.min(this.aiTimer, 0.48); this.aiTimer = 0; @@ -1270,9 +1238,9 @@ class Tarinai { if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed); return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; } - const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2; + const angle = stableUnit(this.familyKey || 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; + const ring = baseRing + stableUnit(this.familyKey || 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), @@ -1659,7 +1627,7 @@ class Tarinai { 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`); + if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] }); } interactWithItems(dt) { @@ -1698,7 +1666,7 @@ class Tarinai { 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.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`, null, { participants: [this] }); this.lastLog = this.world.time; } break; @@ -1745,7 +1713,7 @@ class Tarinai { this.makePoop(eating * 0.20); 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.02) { - this.world.log(`${this.name}\u306f${it.type === "love_mochi" ? "\u3078\u3053\u9905" : (it.type === "fight_mochi" ? "\u3051\u3093\u304b\u9905" : "\u306d\u3080\u308a\u85ac")}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food"); + this.world.log(`${this.name}\u306f${it.type === "love_mochi" ? "\u3078\u3053\u9905" : (it.type === "fight_mochi" ? "\u3051\u3093\u304b\u9905" : "\u306d\u3080\u308a\u85ac")}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); this.lastLog = this.world.time; } break; @@ -1906,10 +1874,10 @@ class Tarinai { } if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident"); + if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); } if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident"); + if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); } const pairBond = this.world.areCoParents?.(this, o); @@ -1934,10 +1902,10 @@ class Tarinai { if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) { this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); this.affection = clamp(this.affection + dt * 0.18, 0, 80); - if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation"); + if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation", { participants: [o, this] }); } if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD && 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"); + this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation", { participants: [o, this] }); } if (this.type === "angry") this.stress += dt * 0.08; if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; @@ -2011,7 +1979,7 @@ class Tarinai { } 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`); + 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`, null, { participants: [this] }); } } @@ -2242,8 +2210,10 @@ class Tarinai { if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); } this.world.markDead(this, finalReason); + this.releasedLiveToken = this.liveToken || 0; + this.world.releaseTarinaiLiveId?.(this); audio.death(); - this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death"); + this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death", { participants: [this] }); if (this.world.selected === this) this.world.selected = null; } diff --git a/js/ui.js b/js/ui.js index c172696..993a464 100644 --- a/js/ui.js +++ b/js/ui.js @@ -24,6 +24,7 @@ const ui = { colonyChart: document.getElementById("colonyChart"), colonyChartLegend: document.getElementById("colonyChartLegend"), archiveContent: document.getElementById("archiveContent"), + archiveUpdateToggleBtn: document.getElementById("archiveUpdateToggleBtn"), archiveResetBtn: document.getElementById("archiveResetBtn"), archiveResetDialog: document.getElementById("archiveResetDialog"), archiveResetCancelBtn: document.getElementById("archiveResetCancelBtn"), @@ -39,6 +40,8 @@ const uiCache = { stats: {}, selectedSnapshot: "", selectedEmpty: false, + selectedCollapsedCategories: new Set(), + archiveUpdateEnabled: false, toolButtons: [], archiveVersion: "", archiveRows: [], @@ -124,7 +127,7 @@ function logKindLabel(kind) { } function tarinaiSpritePathForId(id) { - const live = world?.tarinai?.find(t => t.id === id && !t.dead); + const live = world?.tarinai?.find(t => (t.id === id || t.familyKey === id) && !t.dead); const sid = live?.spriteId ? live.spriteId() : live?.type; const fam = world?.family?.[id]; const fallback = fam?.type; @@ -163,7 +166,7 @@ function relationListHtml(t, kind = "friend") { } function focusTarinaiById(id) { - const t = world?.tarinai?.find(o => o && !o.dead && o.id === id); + const t = world?.tarinai?.find(o => o && !o.dead && (o.id === id || o.familyKey === id)); if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; } world.selected = t; t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25); @@ -230,6 +233,15 @@ function personalityRowsHtml(personality = {}) { return PERSONALITY_KEYS.map(key => `${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}`).join(""); } +function selectedDataCategoryHtml(id, label, bodyHtml) { + if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); + const collapsed = uiCache.selectedCollapsedCategories.has(id); + return `
+ +
${bodyHtml}
+
`; +} + function renderSelected() { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) { @@ -294,31 +306,45 @@ function renderSelected() { const recordText = quoteNameInRecordText(r.text || "", t.name); return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; - ui.selectedInfo.innerHTML = ` -
    - -
    - ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""} + const basicHtml = `
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    \u72b6\u614b${escapeHtml(stateLabel(t.state))}
    \u7406\u7531${escapeHtml(reasonLabel(t))}
    - ${personalityHtml}
    \u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    + `; + const healthHtml = `
    \u7a7a\u8179${fmt(t.hunger)}
    \u5bc2\u3057\u3055${fmt(t.loneliness)}
    \u4f53\u529b${fmt(t.energy)}
    \u30b9\u30c8\u30ec\u30b9${fmt(t.stress)}
    \u305a\u3093\u3061\u6c5a\u308c${fmt(t.zunchiStain || 0)}
    \u6c17\u5206${fmt(t.mood)}
    +
    \u305f\u308a\u306a\u3044${fmt(t.lack)}
    + `; + const relationHtml = `
    \u53cb\u9054${relationListHtml(t, "friend")}
    \u6575\u5bfe${relationListHtml(t, "enemy")}
    \u55a7\u5629\u8a18\u61b6${escapeHtml(fightRecord)}
    \u7d2f\u8a08\u6557\u5317${t.totalFightLosses || 0}
    + `; + const familyHtml = `
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    \u5b50${t.children?.length || 0}
    -
    \u305f\u308a\u306a\u3044${fmt(t.lack)}
    -

    \u500b\u4f53\u5225\u306e\u8a18\u9332

      ${recordHtml}
    + `; + ui.selectedInfo.innerHTML = ` +
    + +
    + ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""} +
    + ${selectedDataCategoryHtml("basic", "\u57fa\u672c", basicHtml)} + ${selectedDataCategoryHtml("health", "\u4f53\u8abf", healthHtml)} + ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} + ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} + ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} + ${selectedDataCategoryHtml("records", "\u8a18\u9332", `

    \u500b\u4f53\u5225\u306e\u8a18\u9332

      ${recordHtml}
    `)} +
    `; } @@ -371,7 +397,7 @@ function reasonLabel(t) { function targetLabel(target) { if (!target) return "\u306a\u3057"; if (target.name) return target.name; - if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; + if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", genkotsu: "\u3052\u3093\u3053\u3064", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", 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"; } @@ -611,6 +637,36 @@ 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"; + syncArchiveUpdateButton(); +} + +function archiveAutoUpdateEnabled() { + return Boolean(uiCache.archiveUpdateEnabled); +} + +function syncArchiveUpdateButton() { + const btn = ui.archiveUpdateToggleBtn; + if (!btn) return; + const on = archiveAutoUpdateEnabled(); + btn.textContent = on ? "\u66f4\u65b0 ON" : "\u66f4\u65b0 OFF"; + btn.classList.toggle("active", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); +} + +function setArchiveUpdateEnabled(on) { + uiCache.archiveUpdateEnabled = Boolean(on); + syncArchiveUpdateButton(); + if (uiCache.archiveUpdateEnabled) { + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + world.familyTreeDirty = true; + scheduleArchiveWindowRender?.(); + showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002"); + } else { + if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; } + uiCache.archiveScheduled = false; + showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002"); + } } const SCALABLE_TOOLS = new Set(scalableToolIds()); @@ -692,7 +748,7 @@ function bindUI() { const node = e.target.closest("[data-family-tarinai-id]"); if (!node) return; const id = node.getAttribute("data-family-tarinai-id"); - const t = world.tarinai.find(x => x.id === id && !x.dead); + const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead); if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; } world.selected = t; t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15); @@ -708,12 +764,25 @@ function bindUI() { if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") { uiCache.archiveAutoRefreshTimer = window.setInterval(() => { - if (!ui.archiveContent) return; + if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return; scheduleArchiveWindowRender(); }, 5000); } ui.selectedInfo?.addEventListener("click", (e) => { + const categoryToggle = e.target.closest(".selected-category-toggle"); + if (categoryToggle) { + const category = categoryToggle.closest(".selected-category"); + const id = category?.dataset?.selectedCategory || ""; + if (!category || !id) return; + if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); + category.classList.toggle("collapsed"); + const collapsed = category.classList.contains("collapsed"); + if (collapsed) uiCache.selectedCollapsedCategories.add(id); + else uiCache.selectedCollapsedCategories.delete(id); + categoryToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + return; + } const focusBtn = e.target.closest("[data-focus-tarinai-id]"); if (focusBtn) { e.preventDefault(); @@ -750,7 +819,7 @@ function bindUI() { ui.pauseBtn?.addEventListener("click", () => { world.paused = !world.paused; const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0); - if (world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { + if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { uiCache.archivePendingWhileRunning = false; scheduleArchiveWindowRender(); } @@ -787,6 +856,8 @@ function bindUI() { ui.ecologyDialog?.addEventListener("click", (e) => { if (e.target === ui.ecologyDialog) closeEcologyDialog(); }); + ui.archiveUpdateToggleBtn?.addEventListener("click", () => setArchiveUpdateEnabled(!archiveAutoUpdateEnabled())); + syncArchiveUpdateButton(); ui.archiveResetBtn?.addEventListener("click", openArchiveResetDialog); ui.archiveResetCancelBtn?.addEventListener("click", closeArchiveResetDialog); ui.archiveResetDialog?.addEventListener("click", (e) => { @@ -794,7 +865,7 @@ function bindUI() { }); ui.archiveResetConfirmBtn?.addEventListener("click", () => { world.resetFamilyTree?.(); - uiCache.archiveVersion = ""; + window.resetArchiveRenderState?.(); closeArchiveResetDialog(); renderArchive(); renderSelected(); diff --git a/js/ui_family.js b/js/ui_family.js index a6bff5d..b4a6d6a 100644 --- a/js/ui_family.js +++ b/js/ui_family.js @@ -45,8 +45,8 @@ function lineageMutualParentIds(n, family = world.family || {}, idSet = null) { if (!n?.id) return parents; const relationIndex = lineageRelationIndex(family); const candidates = new Set((n.parents || []).filter(Boolean)); - // Older saves may contain only one side of the parent/child edge. For - // rendering, recover the missing side from a cached relation index. + // Runtime updates can briefly touch one side first; recover the reciprocal + // edge from the relation index so the archive does not flicker. for (const parentId of relationIndex.parentsByChild.get(n.id) || []) candidates.add(parentId); for (const id of candidates) { if (!id || id === n.id || seen.has(id)) continue; @@ -69,8 +69,7 @@ function lineageMutualChildIds(n, family = world.family || {}, idSet = null) { if (!n?.id) return children; const relationIndex = lineageRelationIndex(family); const candidates = new Set((n.children || []).filter(Boolean)); - // Same recovery as above, in the opposite direction: child.parents is enough - // to draw a real parent/child edge even if parent.children was not persisted. + // Same recovery as above, in the opposite direction. for (const childId of relationIndex.childrenByParent.get(n.id) || []) candidates.add(childId); for (const id of candidates) { if (!id || id === n.id || seen.has(id)) continue; @@ -348,7 +347,7 @@ function lineageNodeSort(a, b) { } function lineageTreeNodeHtml(n, x, y) { - const entity = n?.id ? world.tarinai.find(t => t.id === n.id) : null; + const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null; const live = entity && !entity.dead ? entity : null; const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; const name = escapeHtml(n?.name || "\u4e0d\u660e"); @@ -1121,7 +1120,7 @@ function lineageSetArchiveStaleStatus(show) { return; } if (existing) return; - ui.archiveContent.insertAdjacentHTML("afterbegin", `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306f\u4e00\u6642\u505c\u6b62\u4e2d\u3067\u3059\u3002\u4e00\u6642\u505c\u6b62\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002
    `); + ui.archiveContent.insertAdjacentHTML("afterbegin", `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002
    `); } function lineageAssignPartnerAttachOffsets(linkGroups) { @@ -1418,6 +1417,15 @@ function lineageFamilyHtml(component, index, family, signature = "") { function renderArchive() { if (!ui.archiveContent) return; + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + uiCache.archiveScheduled = false; + if (!uiCache.archiveVersion) { + ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u63cf\u753b\u3057\u307e\u3059\u3002
    `; + } else if (world.familyTreeDirty) { + lineageSetArchiveStaleStatus(true); + } + return; + } if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return; uiCache.deferArchiveUntil = 0; uiCache.archiveScheduled = false; @@ -1553,6 +1561,27 @@ function renderArchive() { ); } +function resetArchiveRenderState() { + if (uiCache.archiveRenderTimer) { + clearTimeout(uiCache.archiveRenderTimer); + uiCache.archiveRenderTimer = 0; + } + uiCache.archiveRenderToken = ""; + uiCache.archiveRenderRunning = false; + uiCache.archiveScheduled = false; + uiCache.archivePendingOffscreen = false; + uiCache.archivePendingWhileRunning = false; + uiCache.archivePendingDirtyAfterRun = false; + uiCache.archiveLastHiddenCheckAt = -Infinity; + uiCache.archiveLastScheduleFamilyVersion = null; + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + uiCache.archiveRelationSignature = ""; + uiCache.archiveRows = []; + uiCache.lastArchiveWindow = ""; + uiCache.archiveHtmlCache?.clear?.(); +} + function validateFamilyTree() { const issues = world.validateFamily ? [...world.validateFamily()] : []; const rendered = new Map(); @@ -1595,6 +1624,11 @@ function renderArchiveWindow() { } function scheduleArchiveWindowRender() { + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + uiCache.archiveScheduled = false; + if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true); + return; + } const familyDirty = Boolean(world.familyTreeDirty); if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0) && !familyDirty) return; if (uiCache.archiveRenderRunning) { @@ -1617,6 +1651,7 @@ function scheduleArchiveWindowRender() { } function schedulePendingArchiveRender() { + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return; if (!uiCache.archivePendingOffscreen) return; if (!lineageArchiveNearViewport()) return; uiCache.archivePendingOffscreen = false; @@ -1625,3 +1660,4 @@ function schedulePendingArchiveRender() { window.validateFamilyTree = validateFamilyTree; window.familyTreeDiagnostics = () => ({ ...(uiCache.archiveDiagnostics || {}) }); +window.resetArchiveRenderState = resetArchiveRenderState; diff --git a/js/world.js b/js/world.js index 44c933a..ec04b10 100644 --- a/js/world.js +++ b/js/world.js @@ -25,6 +25,10 @@ class World { this.drawListDirty = true; this.selected = null; this.deadCount = 0; + this.liveIdNext = 1; + this.liveIdFree = []; + this.liveIdSerial = 0; + this.liveTarinai = new Map(); this.lastBirthAt = -999; this.maxGeneration = 1; this.family = {}; @@ -42,7 +46,8 @@ class World { this.cameraY = 0; this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); this.spatial = new SpatialGrid(96); - this.spatialScratch = []; + this.spatialItemScratch = []; + this.spatialTarinaiScratch = []; this.drawList = []; this.itemCounts = {}; this.effectCounts = {}; @@ -330,6 +335,10 @@ class World { this.drawListDirty = true; this.selected = null; this.deadCount = 0; + this.liveIdNext = 1; + this.liveIdFree = []; + this.liveIdSerial = 0; + this.liveTarinai = new Map(); this.lastBirthAt = -999; this.maxGeneration = 1; this.family = {}; @@ -367,9 +376,51 @@ class World { } + tarinaiFamilyKey(t) { + return t?.familyKey || t?.archiveKey || t?.id || ""; + } + + assignTarinaiLiveId(t) { + if (!t) return ""; + if (!this.liveTarinai) this.liveTarinai = new Map(); + // Live IDs are never recycled. Relationships and transient targets are keyed by + // live ID, so reuse can make dead friends/enemies leak onto new individuals. + this.liveIdFree = []; + if (!Number.isFinite(this.liveIdNext)) this.liveIdNext = 1; + if (!Number.isFinite(this.liveIdSerial)) this.liveIdSerial = 0; + const id = `t${this.liveIdNext++}`; + t.id = id; + t.liveToken = ++this.liveIdSerial; + this.liveTarinai.set(id, { token: t.liveToken, target: t }); + return id; + } + + releaseTarinaiLiveId(t) { + if (!t?.id || !this.liveTarinai) return false; + const current = this.liveTarinai.get(t.id); + if (!current || current.target !== t || current.token !== t.liveToken) return false; + const id = t.id; + this.liveTarinai.delete(id); + // Do not recycle live IDs. Relationships are keyed by live ID; recycling can + // make a dead friend's/enemy's relationship appear on a newly spawned individual. + t.releasedLiveId = id; + t.id = ""; + t.liveToken = 0; + return true; + } + + liveTarinaiById(id, token = null) { + if (!id || !this.liveTarinai) return null; + const entry = this.liveTarinai.get(id); + if (!entry || (token !== null && entry.token !== token)) return null; + const t = entry.target; + return t && !t.dead ? t : null; + } + recordFamily(t) { if (!this.family) this.family = {}; - if (!t?.id) return; + const familyKey = this.tarinaiFamilyKey(t); + if (!familyKey) return; const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean))); const sameList = (a, b) => { const aa = uniqueIds(a); @@ -380,9 +431,9 @@ class World { const nextParents = uniqueIds(t.parents || []); const nextChildren = uniqueIds(t.children || []); const nextPersonalityTags = getPersonalityTraitTags(t); - if (!this.family[t.id]) { - this.family[t.id] = { - id: t.id, + if (!this.family[familyKey]) { + this.family[familyKey] = { + id: familyKey, name: t.name, type: t.type, personalityTags: nextPersonalityTags, @@ -397,7 +448,7 @@ class World { }; changed = true; } else { - const entry = this.family[t.id]; + const entry = this.family[familyKey]; const nextAlive = !t.dead; if (entry.name !== t.name) changed = true; if (entry.type !== t.type) changed = true; @@ -436,12 +487,13 @@ class World { if (!this.family) this.family = {}; if (this.familyCleanVersion === (this.familyVersion || 0)) return false; const family = this.family; - const liveById = new Map((this.tarinai || []).map(t => [t.id, t])); + const liveById = new Map((this.tarinai || []).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id)); let changed = false; const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id))))); const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]); for (const t of this.tarinai || []) { - if (!this.family[t.id] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length)) continue; + const familyKey = this.tarinaiFamilyKey(t); + if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue; const before = this.familyVersion || 0; this.recordFamily(t); changed = changed || before !== (this.familyVersion || 0); @@ -571,14 +623,40 @@ class World { return issues; } + clearLiveReferencesTo(dead) { + const deadId = dead?.id || dead?.releasedLiveId || ""; + if (!deadId) return false; + let changed = false; + for (const other of this.tarinai || []) { + if (!other || other === dead || other.dead) continue; + if (other.target === dead || other.target?.id === deadId) { + other.target = null; + if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.state = "idle"; + changed = true; + } + if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; } + if (other.fightTargetId === deadId) { other.fightTargetId = null; changed = true; } + if (Array.isArray(other.fightTargetIds) && other.fightTargetIds.includes(deadId)) { + other.fightTargetIds = other.fightTargetIds.filter(id => id !== deadId); + changed = true; + } + for (const key of ["defeatedById", "fightWinnerId", "intimidateTargetId", "birthPartnerId"]) { + if (other[key] === deadId) { other[key] = null; changed = true; } + } + } + return changed; + } + markDead(t, reason) { + this.clearLiveReferencesTo(t); if (!this.family) this.family = {}; const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason; - if (this.family[t.id]) { - if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== finalReason) { - this.family[t.id].alive = false; - this.family[t.id].deathReason = finalReason; - delete this.family[t.id].personality; + const familyKey = this.tarinaiFamilyKey(t); + if (familyKey && this.family[familyKey]) { + if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) { + this.family[familyKey].alive = false; + this.family[familyKey].deathReason = finalReason; + delete this.family[familyKey].personality; this.familyVersion = (this.familyVersion || 0) + 1; this.markFamilyTreeDirty("death"); } @@ -632,6 +710,7 @@ class World { addTarinai(opts = {}) { const t = new Tarinai(this, opts); + this.assignTarinaiLiveId(t); this.tarinai.push(t); this.drawListDirty = true; if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t); @@ -642,6 +721,7 @@ class World { startBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return false; if (a.isZunchiSlave || b.isZunchiSlave) return false; + if (this.areParentChild(a, b)) return false; if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) return false; if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false; const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length); @@ -696,25 +776,28 @@ class World { if (child) { child.postBirthPeaceTimer = 18; children.push(child); } } if (children.length > 0) this.lastBirthAt = this.time; - if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth"); + if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [a, b, ...children] }); return children[0] || null; } areParentChild(a, b) { if (!a || !b) return false; - const aId = a.id || a; - const bId = b.id || b; + const aId = this.tarinaiFamilyKey(a) || a; + const bId = this.tarinaiFamilyKey(b) || b; if (!aId || !bId) return false; return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId)); } areCoParents(a, b) { if (!a || !b) return false; - const aId = a.id || a; - const bId = b.id || b; + const aLiveId = a.id || a; + const bLiveId = b.id || b; + if (!aLiveId || !bLiveId || aLiveId === bLiveId) return false; + if (a.birthPartnerId && a.birthPartnerId === bLiveId) return true; + if (b.birthPartnerId && b.birthPartnerId === aLiveId) return true; + const aId = this.tarinaiFamilyKey(a) || aLiveId; + const bId = this.tarinaiFamilyKey(b) || bLiveId; if (!aId || !bId || aId === bId) return false; - if (a.birthPartnerId && a.birthPartnerId === bId) return true; - if (b.birthPartnerId && b.birthPartnerId === aId) return true; const aChildren = new Set((a.children || []).filter(Boolean)); if (!aChildren.size) return false; for (const id of (b.children || [])) { @@ -726,10 +809,12 @@ class World { canStartFight(t) { if (!t || t.dead) return false; if (t.sleepDisease) return false; - // Disease fight rule: explosion disease and zunchi disease can lash out; + // Disease fight rule: explosion, zunchi, and fight disease can lash out; // sleep disease cannot fight or be challenged, and other diseases cannot start fights. - if (t.explosionDisease || t.zunchiDisease) return true; - if (t.fightDisease) return false; + if (t.explosionDisease || t.zunchiDisease || t.fightDisease) return true; + const flag = (value) => typeof value === "function" ? Boolean(value.call(t)) : Boolean(value); + const hasOtherDisease = flag(t.disease) || flag(t.sick) || flag(t.infection) || flag(t.hasDisease); + if (hasOtherDisease) return false; return true; } @@ -761,7 +846,7 @@ class World { need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS), generation: Math.max(a.generation, b.generation) + 1, lifeSpan: rand(1500, 2400), - parents: [a.id, b.id], + parents: [this.tarinaiFamilyKey(a), this.tarinaiFamilyKey(b)], parentNames: [a.name, b.name], birthTime: this.time, }); @@ -774,17 +859,20 @@ class World { b.adjustRelation?.(a, 0.65, -0.20, "co_parent"); a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28); b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28); - a.children.push(child.id); - b.children.push(child.id); + const childKey = this.tarinaiFamilyKey(child); + const aKey = this.tarinaiFamilyKey(a); + const bKey = this.tarinaiFamilyKey(b); + a.children.push(childKey); + b.children.push(childKey); 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); + if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey); + if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey); this.familyCleanVersion = null; audio.birth(); this.lastBirthAt = this.time; - if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth"); + if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] }); return child; } @@ -901,7 +989,7 @@ class World { 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"); + this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] }); actor.lastLog = target.lastLog = this.time; } return true; @@ -953,7 +1041,7 @@ class World { })); 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"); + this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b] }); a.lastLog = b.lastLog = this.time; } } @@ -967,7 +1055,10 @@ class World { const p = clamp(1 - d / Math.max(1, radius), 0, 1); if (p <= 0 && d > radius) continue; const damage = typeof damageFn === "function" ? damageFn(p, ant, d) : Number(damageFn || 0); - if (damage > 0) ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage); + if (damage > 0) { + ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage); + ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.25); + } if (opts.push) { let nx = (ant.x - x) / d; let ny = (ant.y - y) / d; @@ -1124,7 +1215,7 @@ class World { } t.die("\u7206\u767a\u75c5"); audio.explode(); - this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident"); + this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident", { participants: [t] }); } applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) { @@ -1377,47 +1468,63 @@ class World { if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) { const oldName = loser.formerName || loser.name; this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)"); - this.log(`${oldName}\u306f5\u56de\u55a7\u5629\u306b\u8ca0\u3051\u3001\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight"); + this.log(`${oldName}\u306f5\u56de\u55a7\u5629\u306b\u8ca0\u3051\u3001\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: [loser] }); } loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); winner.affection = clamp(winner.affection + 0.8, 0, 80); winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { - this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight"); + this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); } } itemDropImpact(it) { if (!it || it.dead || it.type === "water") return; const isStone = it.type === "stone"; - const radius = isStone ? 64 : Math.max(34, (it.r || 14) * 1.75); - const damage = isStone ? rand(20, 34) : rand(3, 7); - const push = isStone ? rand(145, 210) : rand(54, 92); + const isGenkotsu = it.type === "genkotsu"; + const radius = isGenkotsu ? Math.max(118, (it.r || 72) * 1.72) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75)); + const damage = isGenkotsu ? rand(34, 54) : (isStone ? rand(20, 34) : rand(3, 7)); + const push = isGenkotsu ? rand(330, 470) : (isStone ? rand(145, 210) : rand(54, 92)); + const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064\u304c\u843d\u3061\u305f" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); + if (isGenkotsu) it.impactFlash = 1; let hit = 0; - for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) { + for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) { if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); const p = clamp(1 - d / (radius + t.radius), 0, 1); if (p <= 0) continue; - const nx = (t.x - it.x) / d; - const ny = (t.y - it.y) / d; + let nx = (t.x - it.x) / d; + let ny = (t.y - it.y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } t.vx += nx * push * (0.35 + p); - t.vy += ny * push * (0.35 + p) - (isStone ? 42 : 18) * p; - t.damage(damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); - t.hurtTimer = Math.max(t.hurtTimer, isStone ? 2.7 : 1.25); - t.fallTimer = Math.max(t.fallTimer, isStone ? 0.62 : 0.26); + t.vy += ny * push * (0.35 + p) - (isGenkotsu ? 92 : (isStone ? 42 : 18)) * p; + t.damage(damage * (0.35 + p * 0.65), reason); + t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); + t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); t.fallDir = nx >= 0 ? 1 : -1; hit += 1; } - const antHit = this.damageAntsInRadius(it.x, it.y, radius + 48, p => damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f", { + const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { push: p => push * (0.35 + p), }); - this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); - if (hit > 0 || antHit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45); - if (isStone && (hit > 0 || antHit > 0)) audio.poke(); - if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); + if (isGenkotsu) { + for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); + for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" })); + } else { + this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); + } + if (hit > 0 || antHit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.22 : (isStone ? 0.9 : 0.45)); + if ((isStone || isGenkotsu) && (hit > 0 || antHit > 0)) audio.poke(); + if (hit > 0 || antHit > 0) { + const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177"); + this.log(`${label}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u3068${antHit}\u5339\u306e\u30a2\u30ea\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); + } } countBallChasers(ball, except = null) { @@ -1640,7 +1747,7 @@ class World { t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8)); this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" })); - if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident"); + if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] }); ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); @@ -1676,7 +1783,7 @@ class World { t.nextPlayBubbleAt = now + rand(2.0, 3.8); this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); } - if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe"); + if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); } } @@ -1752,7 +1859,7 @@ class World { t.fearTimer = Math.max(t.fearTimer || 0, 0.55); } this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" })); - if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident"); + if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] }); } } } @@ -2107,11 +2214,11 @@ class World { } nearbyItems(x, y, radius) { - return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialScratch); + return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch); } nearbyTarinai(x, y, radius) { - return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialScratch); + return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch); } rebuildSpatial() { @@ -2344,8 +2451,9 @@ class World { observeHighlightKind(t) { const s = this.selected; if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; - if ((s.parents || []).includes(t.id)) return "parent"; - if ((s.children || []).includes(t.id)) return "child"; + const tFamilyKey = this.tarinaiFamilyKey(t); + if ((s.parents || []).includes(tFamilyKey)) return "parent"; + if ((s.children || []).includes(tFamilyKey)) return "child"; const rel = s.relationTo ? s.relationTo(t.id) : null; if (rel && (rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) return "friend"; if (rel && (rel.fear || 0) >= 8) return "enemy"; @@ -2440,6 +2548,7 @@ class World { for (const t of this.tarinai) { t.update(dt); } + this.rebuildSpatial(); this.limitBallChasers(); this.resolveBallInteractions(dt); this.resolveTarinaiHighSpeedCollisions(dt); @@ -2573,6 +2682,28 @@ class World { t.entryTimer = 0; } } + let changedAnts = false; + for (const ant of this.ants || []) { + if (!ant || ant.dead) continue; + const result = repairPoint(ant, ant.kind === "queen" ? 18 : 10); + if (result === "delete") { + ant.dead = true; + changedAnts = true; + continue; + } + if (result === "moved") { + ant.vx = 0; + ant.vy = 0; + if (ant.state === "drag") { + ant.state = "return"; + ant.targetId = ""; + ant.targetToken = 0; + ant.targetRef = null; + } + changedAnts = true; + } + } + if (changedAnts) this.compactAnts?.(); if (changedItems) { this.compactItems(); this.updateItemCounts(); @@ -2589,9 +2720,10 @@ class World { : current === "cloudy" ? ["sunny", "cloudy", "light_rain"] : ["sunny", "cloudy"]; - this.weather = pick(choices); + const next = pick(choices); + this.weather = next; this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); + if (next !== current) this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); } fencePlacementBlocked(item) { @@ -2611,8 +2743,49 @@ class World { return false; } + importantPlacementOverlapBlocked(item) { + if (!item || item.dead) return true; + const important = new Set(["grass", "fence_v", "fence_h", "nest_box", "water_bowl"]); + if (!important.has(item.type)) return false; + const placingGrass = item.type === "grass"; + const itemRects = this.solidObstacleRects(item); + const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback); + const itemRadius = Math.max(14, item.r || radiusFor(item.type, 12) || 12); + const rectCircleOverlap = (rect, cx, cy, radius, margin = 0) => { + if (!rect) return false; + const px = clamp(cx, rect.left - margin, rect.right + margin); + const py = clamp(cy, rect.top - margin, rect.bottom + margin); + return distXY(cx, cy, px, py) < radius + margin; + }; + const rectsOverlap = (a, b, margin = 0) => Boolean(a && b && !(a.right + margin < b.left || a.left - margin > b.right || a.bottom + margin < b.top || a.top - margin > b.bottom)); + for (const other of this.nearbyItems(item.x, item.y, Math.max(132, itemRadius * 4))) { + if (!other || other === item || other.dead || !important.has(other.type)) continue; + // Grass is a ground layer: objects may be placed on top of existing grass. + // Grass placement itself still avoids objects and other grass via grassSpotOpen(). + if (!placingGrass && other.type === "grass") continue; + const otherRects = this.solidObstacleRects(other); + const otherRadius = Math.max(14, other.r || radiusFor(other.type, 12) || 12); + const margin = Math.max(4, Math.min(itemRadius, otherRadius) * 0.18); + if (itemRects.length && otherRects.length) { + if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true; + continue; + } + if (itemRects.length) { + if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true; + continue; + } + if (otherRects.length) { + if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true; + continue; + } + if (distXY(item.x, item.y, other.x, other.y) < itemRadius + otherRadius + margin) return true; + } + return false; + } + placementBlocked(item) { if (!item || item.dead) return true; + if (item.type === "genkotsu") return false; const pad = CONFIG.worldPadding || 30; const rects = this.solidObstacleRects(item); if (rects.length) { @@ -2625,6 +2798,7 @@ class World { } if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true; if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true; + if (this.importantPlacementOverlapBlocked(item)) return true; if (item.type === "nest_box") { const margin = Math.max(8, (item.r || 42) * 0.18); for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) { @@ -2745,7 +2919,7 @@ class World { return null; } if (dropped) { - item.dropMax = item.type === "stone" ? 0.72 : 0.58; + item.dropMax = item.type === "genkotsu" ? 0.78 : (item.type === "stone" ? 0.72 : 0.58); item.dropTimer = item.dropMax; item.dropImpactDone = false; } @@ -2763,7 +2937,6 @@ class World { if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && 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; } @@ -2782,9 +2955,9 @@ class World { 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`); + if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] }); } else { - this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002"); + this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002", "observe", { hiddenFromObservation: true, countInStats: false }); } return; } @@ -2826,14 +2999,14 @@ class World { const placed = this.placeItem(rawItem, true); if (!placed) return; const label = toolLabel(itemType); - this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); + this.log(itemType === "genkotsu" ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); } } inferLogKind(text) { const s = String(text || "").toLowerCase(); if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident"; - if (s.includes("fight") || s.includes("lost to") || s.includes("poked") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051") || s.includes("\u3064\u3064\u304b")) return "fight"; + if (s.includes("fight") || s.includes("lost to") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051")) 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:") || s.includes("\u6b7b\u56e0:")) 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"; @@ -2856,12 +3029,32 @@ class World { kind: kind || this.inferLogKind(text), eventType: options.eventType || "", hiddenFromObservation: Boolean(options.hiddenFromObservation), + participants: (options.participants || []).filter(Boolean).map(t => ({ + id: t.id || t.releasedLiveId || "", + liveId: t.id || t.releasedLiveId || "", + liveToken: t.liveToken || t.releasedLiveToken || 0, + familyKey: this.tarinaiFamilyKey(t), + name: t.name || "", + type: t.type || "", + generation: t.generation || 1, + })), + participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id), + participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({ + liveId: t.id || t.releasedLiveId || "", + liveToken: t.liveToken || t.releasedLiveToken || 0, + familyKey: this.tarinaiFamilyKey(t), + name: t.name || "", + type: t.type || "", + generation: t.generation || 1, + })), }; if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true; + const countInStats = options.countInStats ?? !entry.hiddenFromObservation; + entry.countInStats = Boolean(countInStats); if (!this.eventCounters) this.eventCounters = {}; - this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; - for (const t of this.tarinai || []) { - if (!t || !t.name || !text || !String(text).includes(t.name)) continue; + if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; + for (const t of options.participants || []) { + if (!t) continue; if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind); } this.logs.unshift(entry); @@ -2869,65 +3062,6 @@ class World { renderLog(this.logs); } - serialize() { - return { - w: this.w, h: this.h, time: this.time, day: this.day, deadCount: this.deadCount, - maxGeneration: this.maxGeneration, lastBirthAt: this.lastBirthAt || -999, - tarinai: this.tarinai.map(t => t.serialize()), - items: this.items.map(i => i.serialize()), - ants: (this.ants || []).map(a => a.serialize ? a.serialize() : a), - logs: this.logs.slice(0, 30), - family: this.family || {}, - weather: this.weather || "sunny", - fieldType: this.fieldType || "garden", - fieldZoom: this.fieldZoom || 1, - cameraX: this.cameraX || 0, - cameraY: this.cameraY || 0, - eventCounters: this.eventCounters || {}, - toolSize: this.toolSize || "medium", - toolSizes: this.toolSizes || {}, - nextWeatherChange: this.nextWeatherChange || CONFIG.weatherChangeMin, - }; - } - - load(data) { - this.time = data.time || 0; - this.day = data.day || 1; - this.deadCount = data.deadCount || 0; - this.eventCounters = data.eventCounters || {}; - this.toolSize = data.toolSize || this.toolSize || "medium"; - this.toolSizes = data.toolSizes || this.toolSizes || {}; - this.maxGeneration = data.maxGeneration || 1; - this.lastBirthAt = Number.isFinite(data.lastBirthAt) ? data.lastBirthAt : -999; - this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden"; - this.fieldZoom = Number(data.fieldZoom) || 1; - this.cameraX = Number(data.cameraX) || 0; - this.cameraY = Number(data.cameraY) || 0; - this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o)); - this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food" && o?.type !== "cardboard").map(o => Item.from(o)); - this.ants = (data.ants || []).map(o => AntActor.from(this, o)); - this.effects = []; - this.compactItems(); - this.compactEffects(); - this.logs = (data.logs || []).map(e => typeof e === "string" ? { time: this.time, text: e, kind: this.inferLogKind(e) } : { time: e.time || this.time, text: e.text || "", kind: e.kind || this.inferLogKind(e.text || "") }); - this.family = data.family || {}; - this.familyCleanVersion = null; - this.familyVersion = (this.familyVersion || 0) + 1; - this.markFamilyTreeDirty("load"); - this.weather = data.weather || "sunny"; - this.nextWeatherChange = data.nextWeatherChange || rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - this.relationNotices = {}; - this.resolvedFightIds = {}; - for (const t of this.tarinai) this.recordFamily(t); - this.normalizeFamily(); - this.pruneExtinctFamilies({ normalize: false }); - this.lastPhase = this.phaseName(); - this.selected = null; - this.updateItemCounts(); - this.updateEffectCounts(); - this.rebuildSpatial(); - this.log("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002"); - } } let world;