This commit is contained in:
33333-33333 2026-06-20 02:07:01 +09:00
commit 46258a83eb
15 changed files with 1303 additions and 446 deletions

246
README.md
View file

@ -1,210 +1,74 @@
# たりないコロニー観察所
# Tarinai Colony Observation Game
ブラウザで動く、観察主体の小さなコロニーゲームです。
外部ライブラリやサーバーは不要です。
Single-page, dependency-free browser simulation. Open `index.html`; no build step, package manager, server API, transpiler, or module loader is required. Runtime is plain HTML/CSS/JS loaded by ordered `<script>` tags.
## &#x8D77;&#x52D5;&#x65B9;&#x6CD5;
## Codex operating model
1. ZIP&#x3092;&#x5C55;&#x958B;
2. `index.html` &#x3092;&#x30D6;&#x30E9;&#x30A6;&#x30B6;&#x3067;&#x958B;&#x304F;
Read only these first: `README.md`, `docs/AI_CONTEXT.md`, `docs/code_map.md`. Then open the smallest implementation file named by the map. Avoid broad grep unless a symbol is not mapped. Keep Japanese UI strings escaped in source (`\uXXXX` in JS, entities in HTML/Markdown/CSS where applicable). After edits run `node --check js/*.js` and verify `index.html` script order.
Chrome / Edge / Firefox / Safari &#x306E;&#x8FD1;&#x5E74;&#x7248;&#x3092;&#x60F3;&#x5B9A;&#x3057;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;
## Core simulation
## &#x30B2;&#x30FC;&#x30E0;&#x5185;&#x5BB9;
- 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.
- 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.
&#x300C;&#x305F;&#x308A;&#x306A;&#x3044;&#x300D;&#x305F;&#x3061;&#x304C;&#x89B3;&#x5BDF;&#x76BF;&#x306E;&#x4E2D;&#x3067;&#x52DD;&#x624B;&#x306B;&#x66AE;&#x3089;&#x3057;&#x307E;&#x3059;&#x3002;
&#x57FA;&#x672C;&#x306F;&#x773A;&#x3081;&#x308B;&#x3060;&#x3051;&#x3067;&#x3059;&#x304C;&#x3001;&#x990C;&#x30FB;&#x6C34;&#x30FB;&#x8349;&#x30FB;&#x77F3;&#x30FB;&#x5BDD;&#x5E8A;&#x30FB;&#x306A;&#x3069;&#x3092;&#x914D;&#x7F6E;&#x3057;&#x3066;&#x8EFD;&#x304F;&#x4ECB;&#x5165;&#x3067;&#x304D;&#x307E;&#x3059;&#x3002;
## Important mechanics
## &#x4E3B;&#x306A;&#x8981;&#x7D20;
- Personality affects fear, fighting, social pull, sleep priority, zunda preference, playfulness; children inherit one parent's personality.
- Relationships track affinity/fear/fight outcomes and influence approach, avoidance, intimidation, conflict, co-parent behavior, and UI summaries.
- Death causes are normalized by `js/health.js`: fight, hunger, disease, stress overload, over-poking, firecracker, accident, lifespan. Major damage within 10 seconds can become `<cause> de suyaku` in UI text (`...\u3067\u8870\u5f31`).
- Water-curable diseases force high-priority water seeking. Zunda-curable diseases force high-priority zunda seeking.
- Favorite agents show a star above the sprite and can be toggled from the observation panel.
- 9&#x7A2E;&#x985E;&#x306E;&#x305F;&#x308A;&#x306A;&#x3044;&#x753B;&#x50CF;&#x3092;&#x4F7F;&#x7528;
- &#x7A7A;&#x8179;&#x30FB;&#x5B64;&#x72EC;&#x30FB;&#x7720;&#x6C17;&#x30FB;&#x4E0D;&#x5B89;&#x30FB;&#x305F;&#x308A;&#x306A;&#x3044;&#x5EA6;
- &#x990C;&#x63A2;&#x3057;&#x3001;&#x7761;&#x7720;&#x3001;&#x7FA4;&#x308C;&#x5BC4;&#x308A;&#x3001;&#x30D1;&#x30CB;&#x30C3;&#x30AF;&#x3001;&#x7E41;&#x6B96;&#x3001;&#x6B7B;&#x4EA1;
- &#x6B7B;&#x5F8C;&#x306E;&#x300C;&#x305F;&#x308A;&#x306A;&#x3044;&#x8DE1;&#x300D;&#x3068;&#x8349;&#x306E;&#x767A;&#x751F;
- &#x500B;&#x4F53;&#x9078;&#x629E;&#x306B;&#x3088;&#x308B;&#x8A73;&#x7D30;&#x89B3;&#x5BDF;
- &#x89B3;&#x5BDF;&#x30ED;&#x30B0;
- &#x4E00;&#x6642;&#x505C;&#x6B62; / &#x901F;&#x5EA6;&#x5909;&#x66F4; / &#x4FDD;&#x5B58; / &#x8AAD;&#x8FBC;
- localStorage&#x306B;&#x3088;&#x308B;&#x30D6;&#x30E9;&#x30A6;&#x30B6;&#x5185;&#x30BB;&#x30FC;&#x30D6;
## Diseases
## &#x64CD;&#x4F5C;
- Zunchi disease: zunchi-derived contamination; slows movement, increases stress, emits miasma effect, cured by water.
- Sleep disease: caused by sleep drug; stops all behavior and sleeps indefinitely; cured by damage.
- Explosion disease: low-probability result of explosion knockback; explodes after half a day; cured by water or zunda mochi.
- Fight-wound disease: low-probability result after bleeding for five in-game hours; spins and wanders aimlessly; cured by zunda mochi.
- &#x89B3;&#x5BDF;&#xFF1A;&#x500B;&#x4F53;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x3067;&#x8A73;&#x7D30;&#x8868;&#x793A;
- &#x3075;&#x3064;&#x3046;&#x306E;&#x990C; / &#x3042;&#x307E;&#x3044;&#x990C; / &#x6C34;&#x6EF4; / &#x8349; / &#x306C;&#x304F;&#x3044;&#x77F3; / &#x3075;&#x308F;&#x3075;&#x308F;&#xFF1A;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x3067;&#x914D;&#x7F6E;
- &#x3064;&#x3064;&#x304F;&#xFF1A;&#x500B;&#x4F53;&#x3092;&#x30AF;&#x30EA;&#x30C3;&#x30AF;&#x3057;&#x3066;&#x4E00;&#x6642;&#x7684;&#x306B;&#x4E0D;&#x5B89;&#x3092;&#x4E0A;&#x3052;&#x308B;
- &#x8FF7;&#x3044;&#x8FBC;&#x307F;&#xFF1A;&#x6307;&#x5B9A;&#x4F4D;&#x7F6E;&#x306B;&#x65B0;&#x3057;&#x3044;&#x305F;&#x308A;&#x306A;&#x3044;&#x3092;&#x8FFD;&#x52A0;
## Tools and items
## &#x7D20;&#x6750;&#x306B;&#x3064;&#x3044;&#x3066;
- Direct tools: observe, delete, poke, pinch, spawn.
- Food/medicine: zunchi, zunda mochi, heko mochi, kenka mochi, water, water bowl, sleep drug, grass.
- Environment/objects: stone, hay bed, nest box, cleaning tool, ball, firecracker, vertical fence, horizontal fence.
- Cleaning removes zunchi/blood/traces while pushing agents and applying water effects. Water bowl is persistent water. Nest box lowers stress nearby and improves sleep quality. Rain increases falling water items.
&#x540C;&#x68B1;&#x753B;&#x50CF;&#x306F;&#x672C;&#x30D7;&#x30ED;&#x30C8;&#x30BF;&#x30A4;&#x30D7;&#x7528;&#x306B;&#x52A0;&#x5DE5;&#x3057;&#x305F;&#x300C;&#x305F;&#x308A;&#x306A;&#x3044;&#x300D;&#x7D20;&#x6750;&#x3067;&#x3059;&#x3002;
&#x516C;&#x958B;&#x30FB;&#x914D;&#x5E03;&#x30FB;&#x5546;&#x7528;&#x5229;&#x7528;&#x3092;&#x884C;&#x3046;&#x5834;&#x5408;&#x306F;&#x3001;&#x5143;&#x753B;&#x50CF;&#x4F5C;&#x6210;&#x8005;&#x30FB;&#x6A29;&#x5229;&#x8005;&#x306E;&#x8A31;&#x8AFE;&#x7BC4;&#x56F2;&#x306B;&#x5F93;&#x3063;&#x3066;&#x304F;&#x3060;&#x3055;&#x3044;&#x3002;
## &#x30D5;&#x30A1;&#x30A4;&#x30EB;&#x69CB;&#x6210;
## File map
```text
tarinai_colony_game/
&#x251C;&#x2500; index.html
&#x251C;&#x2500; css/
&#x2502; &#x2514;&#x2500; styles.css
&#x251C;&#x2500; js/
&#x2502; &#x2514;&#x2500; game.js
&#x251C;&#x2500; assets/
&#x2502; &#x2514;&#x2500; sprites/
&#x2502; &#x251C;&#x2500; tarinai_01_smile.png
&#x2502; &#x251C;&#x2500; ...
&#x2502; &#x2514;&#x2500; _contact_sheet.png
&#x2514;&#x2500; docs/
&#x2514;&#x2500; design_notes.md
index.html Static DOM shell; ordered script tags; no long ecology/body text.
css/styles.css Layout, HUD, dialogs, tooltips, responsive rules.
js/data.js Sprite/object assets, metrics, config, field presets.
js/text_catalog.js Escaped Japanese UI catalog: tooltips and ecology cards.
js/math.js Generic math helpers.
js/assets.js Lazy image loading, caches, fallback render images.
js/audio.js WebAudio/sample effects.
js/render.js Background, weather, lighting, HUD, global canvas render.
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/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.
docs/AI_CONTEXT.md Minimal implementation context for AI edits.
docs/code_map.md Symbol-to-file lookup table.
```
## &#x4ECA;&#x5F8C;&#x306E;&#x62E1;&#x5F35;&#x5019;&#x88DC;
## Edit rules
- &#x500B;&#x4F53;&#x3054;&#x3068;&#x306E;&#x5BB6;&#x7CFB;&#x56F3;
- &#x56F3;&#x9451;&#x30FB;&#x5B9F;&#x7E3E;
- &#x3088;&#x308A;&#x7CBE;&#x5BC6;&#x306A;&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x5206;&#x88C2;
- &#x8349;&#x30FB;&#x6B7B;&#x8DE1;&#x30FB;&#x990C;&#x306E;&#x751F;&#x614B;&#x5FAA;&#x74B0;
- &#x97F3;&#x3068;&#x74B0;&#x5883;&#x5909;&#x5316;
- &#x753B;&#x50CF;&#x7D20;&#x6750;&#x306E;&#x8FFD;&#x52A0;&#x5DEE;&#x5206;
- Preserve zero-build script order. Add new JS files explicitly in `index.html` before dependents.
- Keep global symbols intentional; this codebase is not ESM.
- Prefer catalog/config/helper extraction over adding large inline blocks to `index.html` or `tarinai.js`.
- Do not reintroduce removed compatibility files (`sim.js`, `ui_exports.js`) or broad JS/CSS caching for active code.
- For public uploads, delete old server files before replacing the directory, then upload the full new ZIP contents.
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;&#x6700;&#x65B0;&#x7248;&#xFF09;
- &#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x8868;&#x793A;&#x30B5;&#x30A4;&#x30BA;&#x3092;&#x5927;&#x5E45;&#x306B;&#x7E2E;&#x5C0F;
- &#x53F3;&#x79FB;&#x52D5;&#x6642;&#x306B;&#x5DE6;&#x53F3;&#x53CD;&#x8EE2;
- &#x98DF;&#x4E8B;&#x4E2D;&#x306E;&#x5C02;&#x7528;&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;
- &#x3064;&#x3064;&#x304D;&#x3059;&#x304E;&#x306B;&#x3088;&#x308B;&#x6B7B;&#x4EA1;&#x6F14;&#x51FA;&#xFF08;06&#x2192;09&#x2192;&#x6B7B;&#x4EA1;&#xFF09;
- &#x6B7B;&#x4EA1;&#x6642;&#x306E;&#x7DD1;&#x306E;&#x3057;&#x3076;&#x304D;&#x8DE1;
- &#x611F;&#x60C5;&#x306B;&#x5FDC;&#x3058;&#x305F;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;&#x81EA;&#x52D5;&#x5207;&#x66FF;
- &#x663C;&#x591C;&#x30B5;&#x30A4;&#x30AF;&#x30EB;&#x3068;&#x6642;&#x9593;HUD
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v3&#xFF09;
- &#x51FA;&#x751F;&#x30FB;&#x6B7B;&#x4EA1;&#x30D0;&#x30E9;&#x30F3;&#x30B9;&#x3092;&#x518D;&#x8ABF;&#x6574;&#xFF1A;&#x98E2;&#x9913;&#x901F;&#x5EA6;&#x3092;&#x4E0B;&#x3052;&#x3001;&#x5BFF;&#x547D;&#x3068;&#x4E0A;&#x9650;&#x500B;&#x4F53;&#x6570;&#x3092;&#x5897;&#x3084;&#x3057;&#x3001;&#x89B3;&#x5BDF;&#x304C;&#x9577;&#x304F;&#x7D9A;&#x304F;&#x3088;&#x3046;&#x306B;&#x8ABF;&#x6574;
- &#x56F3;&#x9451;&#x3092;&#x8FFD;&#x52A0;&#xFF1A;&#x89B3;&#x6E2C;&#x6E08;&#x307F;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;&#x3001;&#x30A4;&#x30D9;&#x30F3;&#x30C8;&#x3001;&#x6B7B;&#x56E0;&#x3092;&#x8A18;&#x9332;
- &#x7CFB;&#x8B5C;&#x3092;&#x8FFD;&#x52A0;&#xFF1A;&#x7E41;&#x6B96;&#x6642;&#x306B;&#x89AA;&#x5B50;&#x95A2;&#x4FC2;&#x30FB;&#x4E16;&#x4EE3;&#x30FB;&#x51FA;&#x751F;&#x6642;&#x523B;&#x3092;&#x4FDD;&#x5B58;
- &#x9078;&#x629E;&#x500B;&#x4F53;&#x306B;&#x89AA;&#x30FB;&#x5B50;&#x6570;&#x30FB;&#x6C17;&#x5206;&#x30FB;&#x3064;&#x3064;&#x304D;&#x8010;&#x4E45;&#x3092;&#x8868;&#x793A;
- Web Audio&#x306B;&#x3088;&#x308B;&#x7C21;&#x6613;&#x52B9;&#x679C;&#x97F3;&#x3092;&#x8FFD;&#x52A0;&#xFF08;&#x97F3; ON &#x30DC;&#x30BF;&#x30F3;&#x3067;&#x6709;&#x52B9;&#x5316;&#xFF09;
- &#x663C;&#x591C;&#x30B5;&#x30A4;&#x30AF;&#x30EB;&#x3092;&#x30ED;&#x30B0;&#x30FB;&#x56F3;&#x9451;&#x30A4;&#x30D9;&#x30F3;&#x30C8;&#x306B;&#x3082;&#x53CD;&#x6620;
- &#x98DF;&#x4E8B;&#x3001;&#x7E41;&#x6B96;&#x3001;&#x6B7B;&#x4EA1;&#x3001;&#x3064;&#x3064;&#x304D;&#x3001;&#x6642;&#x9593;&#x5E2F;&#x5207;&#x66FF;&#x306E;&#x30A4;&#x30D9;&#x30F3;&#x30C8;&#x8A18;&#x9332;&#x3092;&#x8FFD;&#x52A0;
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v4&#xFF09;
- &#x9078;&#x629E;&#x500B;&#x4F53;&#x30D1;&#x30CD;&#x30EB;&#x304B;&#x3089;&#x300C;&#x8DB3;&#x308A;&#x306A;&#x3044;&#x3082;&#x306E;&#x300D;&#x300C;&#x8003;&#x3048;&#x3066;&#x3044;&#x308B;&#x3053;&#x3068;&#x300D;&#x3092;&#x524A;&#x9664;
- &#x7CFB;&#x8B5C;&#x3092;&#x30EA;&#x30B9;&#x30C8;&#x5F62;&#x5F0F;&#x304B;&#x3089;&#x6A39;&#x72B6;&#x56F3;&#x5F62;&#x5F0F;&#x3078;&#x5909;&#x66F4;
- &#x5B50;&#x500B;&#x4F53;&#x304C;&#x6210;&#x9577;&#x3059;&#x308B;&#x307E;&#x3067;&#x89AA;&#x306B;&#x8FFD;&#x5F93;&#x3059;&#x308B;&#x6319;&#x52D5;&#x3092;&#x8FFD;&#x52A0;
- &#x98DF;&#x4E8B;&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;&#x91CF;&#x3092;&#x6291;&#x5236;&#x3057;&#x3001;&#x990C;&#x30FB;&#x3042;&#x307E;&#x3044;&#x990C;&#x30FB;&#x8349;&#x306E;&#x8272;&#x3092;&#x53CD;&#x6620;
- &#x663C;&#x591C;&#x30B5;&#x30A4;&#x30AF;&#x30EB;&#x306E;&#x5149;&#x3092;&#x5730;&#x9762;&#x30FB;&#x30A2;&#x30A4;&#x30C6;&#x30E0;&#x30FB;&#x305F;&#x308A;&#x306A;&#x3044;&#x672C;&#x4F53;&#x3078;&#x53CD;&#x6620;
- &#x8FD1;&#x8DDD;&#x96E2;&#x30FB;&#x4F4E;&#x6A5F;&#x5ACC;&#x30FB;&#x904E;&#x5BC6;&#x6642;&#x306B;&#x767A;&#x751F;&#x3059;&#x308B;&#x55A7;&#x5629;&#x3092;&#x8FFD;&#x52A0;
- &#x55A7;&#x5629;&#x30A4;&#x30D9;&#x30F3;&#x30C8;&#x3092;&#x56F3;&#x9451;&#x30A4;&#x30D9;&#x30F3;&#x30C8;&#x3078;&#x8A18;&#x9332;
- &#x4FDD;&#x5B58;&#x30AD;&#x30FC;&#x306F; `tarinai-colony-save-v4`
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v5&#xFF09;
- &#x8349;&#x306F;&#x81EA;&#x7136;&#x767A;&#x751F;&#x3059;&#x308B;&#x3088;&#x3046;&#x306B;&#x5909;&#x66F4;
- &#x8349;&#x306F;&#x81EA;&#x7136;&#x306B;&#x306F;&#x6D88;&#x3048;&#x305A;&#x3001;&#x5C11;&#x3057;&#x305A;&#x3064;&#x4F38;&#x3073;&#x308B;
- &#x8349;&#x3092;&#x98DF;&#x3079;&#x308B;&#x3068;&#x6B8B;&#x91CF;&#x304C;&#x6E1B;&#x308A;&#x3001;&#x8868;&#x793A;&#x3055;&#x308C;&#x308B;&#x672C;&#x6570;&#x3082;&#x6E1B;&#x308B;
- &#x6697;&#x3044;&#x6642;&#x9593;&#x5E2F;&#x306B;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;&#x306E;&#x900F;&#x904E;&#x90E8;&#x5206;&#x3078;&#x77E9;&#x5F62;&#x72B6;&#x306E;&#x8272;&#x304C;&#x4E57;&#x308B;&#x554F;&#x984C;&#x3092;&#x4FEE;&#x6B63;
- HUD&#x306E;&#x89B3;&#x5BDF;&#x65E5;&#x3092;&#x300C;1&#x65E5;&#x76EE;&#x300D;&#x8868;&#x8A18;&#x3078;&#x5909;&#x66F4;
- HUD&#x306E;&#x300C;&#x660E;&#x308B;&#x3055;&#x300D;&#x8868;&#x793A;&#x3092;&#x6642;&#x523B;&#x8868;&#x8A18;&#x3078;&#x5909;&#x66F4;
- &#x56F3;&#x9451;UI&#x3092;&#x5B8C;&#x5168;&#x306B;&#x524A;&#x9664;
- &#x7CFB;&#x8B5C;&#x3092;&#x53F3;&#x30D1;&#x30CD;&#x30EB;&#x304B;&#x3089;&#x30E1;&#x30A4;&#x30F3;&#x753B;&#x9762;&#x4E0B;&#x90E8;&#x3078;&#x79FB;&#x52D5;
- &#x4FDD;&#x5B58;&#x30AD;&#x30FC;&#x306F; `tarinai-colony-save-v5`
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v6&#xFF09;
- &#x55A7;&#x5629;&#x4E2D;&#x306B;&#x982D;&#x7A81;&#x304D;&#x653B;&#x6483;&#x3092;&#x884C;&#x3046;&#x3088;&#x3046;&#x306B;&#x5909;&#x66F4;
- &#x8349;&#x3068;&#x77F3;&#x304C;&#x663C;&#x591C;&#x30B5;&#x30A4;&#x30AF;&#x30EB;&#x306E;&#x5149;&#x306B;&#x53CD;&#x5FDC;&#x3059;&#x308B;&#x3088;&#x3046;&#x306B;&#x5909;&#x66F4;
- &#x7761;&#x7720;&#x4E2D;&#x306F;&#x5B8C;&#x5168;&#x306B;&#x79FB;&#x52D5;&#x505C;&#x6B62;
- 09_stretch &#x304C;&#x898B;&#x3048;&#x308B;&#x3088;&#x3046;&#x3001;&#x3064;&#x3064;&#x304D;&#x8010;&#x4E45;&#x3092; 9 &#x6BB5;&#x968E;&#x306B;&#x8ABF;&#x6574;
- 3 &#x56DE;&#x76EE;&#x4EE5;&#x964D;: 06_cry
- 6 &#x56DE;&#x76EE;&#x4EE5;&#x964D;: 09_stretch
- 9 &#x56DE;&#x76EE;: &#x6B7B;&#x4EA1;
- &#x305F;&#x308A;&#x306A;&#x3044;&#x304C;&#x30AB;&#x30FC;&#x30BD;&#x30EB;&#x306B;&#x53CD;&#x5FDC;
- &#x597D;&#x610F;&#x7684;&#x306A;&#x500B;&#x4F53;&#x306F; 01_smile &#x3067;&#x5BC4;&#x3063;&#x3066;&#x304F;&#x308B;
- &#x6575;&#x5BFE;&#x7684;&#x30FB;&#x4E0D;&#x5B89;&#x5B9A;&#x306A;&#x500B;&#x4F53;&#x306F;&#x9003;&#x3052;&#x308B;
- &#x4FDD;&#x5B58;&#x30AD;&#x30FC;&#x306F; `tarinai-colony-save-v6`
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v7&#xFF09;
- &#x30E1;&#x30A4;&#x30F3;&#x753B;&#x9762;&#x306E;&#x5916;&#x89B3;&#x3092;&#x300C;&#x5EAD;&#x306E;&#x89B3;&#x5BDF;&#x300D;&#x30C6;&#x30A4;&#x30B9;&#x30C8;&#x3078;&#x5237;&#x65B0;
- &#x7A7A;&#x30FB;&#x65E5;&#x5DEE;&#x3057;&#x30FB;&#x5EAD;&#x67A0;&#x30FB;&#x571F;&#x306E;&#x8CEA;&#x611F;&#x30FB;&#x5468;&#x8FBA;&#x306E;&#x8349;&#x82B1;&#x3092;&#x8FFD;&#x52A0;
- &#x305F;&#x308A;&#x306A;&#x3044;&#x306B;&#x63A5;&#x5730;&#x5F71;&#x3092;&#x8FFD;&#x52A0;
- &#x77F3;&#x30FB;&#x8349;&#x30FB;&#x5404;&#x30A2;&#x30A4;&#x30C6;&#x30E0;&#x306B;&#x3082;&#x5F71;&#x3068;&#x5468;&#x56F2;&#x3068;&#x306E;&#x306A;&#x3058;&#x307F;&#x3092;&#x8FFD;&#x52A0;
- &#x9078;&#x629E;&#x30EA;&#x30F3;&#x30B0;&#x3092;&#x89B3;&#x5BDF;&#x30EC;&#x30F3;&#x30BA;&#x98A8;&#x306E;&#x4E8C;&#x91CD;&#x30EA;&#x30F3;&#x30B0;&#x306B;&#x5909;&#x66F4;
- &#x5B50;&#x500B;&#x4F53;&#x3068;&#x89AA;&#x500B;&#x4F53;&#x306E;&#x95A2;&#x4FC2;&#x304C;&#x898B;&#x3048;&#x3084;&#x3059;&#x3044;&#x3088;&#x3046;&#x3001;&#x8FD1;&#x8DDD;&#x96E2;&#x6642;&#x306B;&#x8584;&#x3044;&#x8FFD;&#x5F93;&#x30AC;&#x30A4;&#x30C9;&#x7DDA;&#x3092;&#x8FFD;&#x52A0;
- &#x53F3;&#x4E0A;HUD&#x3068;&#x5168;&#x4F53;UI&#x3092;&#x5EAD;&#x306E;&#x89B3;&#x5BDF;&#x30CE;&#x30FC;&#x30C8;&#x98A8;&#x306B;&#x8ABF;&#x6574;
- &#x4FDD;&#x5B58;&#x30AD;&#x30FC;&#x306F; `tarinai-colony-save-v7`
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v8&#xFF09;
- &#x30A2;&#x30CB;&#x30E1;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x3092;&#x8ABF;&#x6574;&#x3057;&#x3001;&#x547C;&#x5438;&#x611F;&#x30FB;&#x79FB;&#x52D5;&#x611F;&#x30FB;&#x55A7;&#x5629;&#x6642;&#x306E;&#x52E2;&#x3044;&#x3092;&#x5F37;&#x5316;
- &#x5EAD;&#x7D20;&#x6750;&#x3092;&#x8FFD;&#x52A0;&#xFF08;&#x5C0F;&#x77F3;&#x30FB;&#x82D4;&#x30FB;&#x30AF;&#x30ED;&#x30FC;&#x30D0;&#x30FC;&#x30FB;&#x843D;&#x3061;&#x8449;&#x30FB;&#x5468;&#x8FBA;&#x306E;&#x8349;&#x82B1;&#xFF09;
- &#x592A;&#x967D;&#x3084;&#x6708;&#x305D;&#x306E;&#x3082;&#x306E;&#x306F;&#x63CF;&#x304B;&#x305A;&#x3001;&#x6642;&#x9593;&#x5E2F;&#x306B;&#x5FDC;&#x3058;&#x305F;&#x6295;&#x5F71;&#x5F71;&#x3092;&#x8FFD;&#x52A0;
- &#x305A;&#x3093;&#x3061;&#x3092;&#x8FFD;&#x52A0;&#x3002;&#x98DF;&#x4E8B;&#x3092;&#x91CD;&#x306D;&#x308B;&#x3068;&#x6392;&#x6CC4;&#x3057;&#x3001;&#x5EAD;&#x306B;&#x6B8B;&#x308B;
- 09_stretch &#x304C;&#x901A;&#x5E38;&#x500B;&#x4F53;&#x3067;&#x3082;&#x306F;&#x3063;&#x304D;&#x308A;&#x8868;&#x793A;&#x3055;&#x308C;&#x308B;&#x3088;&#x3046;&#x88DC;&#x5F37;
- &#x7CFB;&#x8B5C;&#x306E;&#x63A5;&#x7D9A;&#x7DDA;&#x3092;&#x592A;&#x304F;&#x30FB;&#x9023;&#x7D9A;&#x7684;&#x306B;&#x3057;&#x3066;&#x898B;&#x3084;&#x3059;&#x304F;&#x4FEE;&#x6B63;
- &#x672A;&#x4F7F;&#x7528;&#x3060;&#x3063;&#x305F;&#x56F3;&#x9451;&#x7CFB;&#x306E;&#x72B6;&#x614B;&#x7BA1;&#x7406;&#x3084;&#x95A2;&#x9023;&#x30B9;&#x30BF;&#x30A4;&#x30EB;&#x3092;&#x524A;&#x9664;
- &#x4FDD;&#x5B58;&#x30AD;&#x30FC;&#x306F; `tarinai-colony-save-v8`
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v9&#xFF09;
- &#x6295;&#x5F71;&#x5F71;&#x304C;&#x898B;&#x3048;&#x306B;&#x304F;&#x3044;&#x554F;&#x984C;&#x3092;&#x4FEE;&#x6B63;
- blur &#x30D5;&#x30A3;&#x30EB;&#x30BF;&#x4F9D;&#x5B58;&#x3092;&#x3084;&#x3081;&#x3001;&#x8EFD;&#x91CF;&#x306A;&#x6955;&#x5186;&#x5F71;&#x3078;&#x5909;&#x66F4;
- &#x305F;&#x308A;&#x306A;&#x3044;&#x30FB;&#x77F3;&#x30FB;&#x8349;&#x30FB;&#x30A2;&#x30A4;&#x30C6;&#x30E0;&#x306E;&#x5F71;&#x3092;&#x6FC3;&#x304F;&#x8ABF;&#x6574;
- &#x91CD;&#x3059;&#x304E;&#x308B;&#x554F;&#x984C;&#x3092;&#x8EFD;&#x91CF;&#x5316;
- &#x5EAD;&#x80CC;&#x666F;&#x306E;&#x7C92;&#x30FB;&#x8349;&#x82B1;&#x30FB;&#x843D;&#x3061;&#x8449;&#x63CF;&#x753B;&#x6570;&#x3092;&#x524A;&#x6E1B;
- filter blur &#x3092;&#x5EC3;&#x6B62;
- &#x8349;&#x306E;&#x81EA;&#x52D5;&#x767A;&#x751F;&#x4E0A;&#x9650;&#x3068;&#x767A;&#x751F;&#x983B;&#x5EA6;&#x3092;&#x6291;&#x5236;
- &#x30A2;&#x30A4;&#x30C6;&#x30E0;&#x4E0A;&#x9650;&#x3092;&#x524A;&#x6E1B;
- &#x98DF;&#x4E8B;&#x30D1;&#x30FC;&#x30C6;&#x30A3;&#x30AF;&#x30EB;&#x983B;&#x5EA6;&#x3092;&#x6291;&#x5236;
- &#x6B7B;&#x4EA1;&#x5F8C;&#x306E;&#x7DD1;&#x306E;&#x3057;&#x3076;&#x304D;&#x304C;&#x6642;&#x9593;&#x7D4C;&#x904E;&#x3067;&#x3086;&#x3063;&#x304F;&#x308A;&#x8584;&#x304F;&#x6D88;&#x3048;&#x308B;&#x3088;&#x3046;&#x306B;&#x5909;&#x66F4;
- &#x4FDD;&#x5B58;&#x30AD;&#x30FC;&#x306F; `tarinai-colony-save-v9`
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v10&#xFF09;
- &#x65B0;&#x898F;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;5&#x679A;&#x3092;&#x8FFD;&#x52A0;
- `10_hurt`: &#x75DB;&#x3044;&#x6642;&#x30FB;&#x55A7;&#x5629;&#x4E2D;&#x306B;&#x6253;&#x305F;&#x308C;&#x305F;&#x6642;
- `11_weak`: &#x4F4E;&#x4F53;&#x529B;&#x6642;
- `12_back`: &#x5965;&#x65B9;&#x5411;&#x3078;&#x9032;&#x3080;&#x6642;
- `13_flee`: &#x55A7;&#x5629;&#x306B;&#x8CA0;&#x3051;&#x3066;&#x6557;&#x8D70;&#x3059;&#x308B;&#x6642;
- `14_zunda_eat`: &#x305A;&#x3093;&#x3060;&#x9905;&#x3092;&#x98DF;&#x3079;&#x308B;&#x6642;
- &#x8FFD;&#x52A0;&#x753B;&#x50CF;&#x306F;&#x3059;&#x3079;&#x3066;&#x900F;&#x660E;PNG&#x5316;&#x3057;&#x3001;`512x468` &#x306E;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;&#x3078;&#x6B63;&#x898F;&#x5316;
- &#x8FFD;&#x52A0;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;&#x306F;&#x884C;&#x52D5;&#x5C02;&#x7528;&#x6271;&#x3044;&#x306B;&#x3057;&#x3001;&#x901A;&#x5E38;&#x500B;&#x4F53;&#x306E;&#x57FA;&#x672C;&#x30BF;&#x30A4;&#x30D7;&#x3068;&#x3057;&#x3066;&#x306F;&#x30E9;&#x30F3;&#x30C0;&#x30E0;&#x51FA;&#x73FE;&#x3057;&#x306A;&#x3044;&#x3088;&#x3046;&#x306B;&#x8ABF;&#x6574;
## &#x66F4;&#x65B0;&#x5185;&#x5BB9;&#xFF08;v11&#xFF09;
- &#x500B;&#x4F53;&#x3054;&#x3068;&#x306E;&#x6027;&#x683C;&#x3092;&#x8FFD;&#x52A0;
- &#x81C6;&#x75C5;&#x3001;&#x98DF;&#x3044;&#x3057;&#x3093;&#x574A;&#x3001;&#x7720;&#x305F;&#x304C;&#x308A;&#x3001;&#x5BC2;&#x3057;&#x304C;&#x308A;&#x3001;&#x6012;&#x308A;&#x3063;&#x307D;&#x3044;&#x3001;&#x30DE;&#x30A4;&#x30DA;&#x30FC;&#x30B9;
- &#x6027;&#x683C;&#x306F;&#x7761;&#x7720;&#x3001;&#x55A7;&#x5629;&#x3001;&#x305A;&#x3093;&#x3060;&#x9905;&#x53CD;&#x5FDC;&#x3001;&#x6050;&#x6016;&#x3001;&#x5BFE;&#x500B;&#x4F53;&#x884C;&#x52D5;&#x3078;&#x5F71;&#x97FF;
- &#x9078;&#x629E;&#x30D1;&#x30CD;&#x30EB;&#x306B;&#x89B3;&#x5BDF;&#x7406;&#x7531;&#x3092;&#x8FFD;&#x52A0;
- &#x73FE;&#x5728;&#x306E;&#x884C;&#x52D5;&#x7406;&#x7531;
- &#x5BFE;&#x8C61;
- &#x4EF2;&#x306E;&#x826F;&#x3044;&#x500B;&#x4F53;
- &#x907F;&#x3051;&#x3066;&#x3044;&#x308B;&#x500B;&#x4F53;
- &#x55A7;&#x5629;&#x8A18;&#x61B6;
- &#x95A2;&#x4FC2;&#x6027;&#x3068;&#x55A7;&#x5629;&#x8A18;&#x61B6;&#x3092;&#x8FFD;&#x52A0;
- &#x8FD1;&#x304F;&#x3067;&#x904E;&#x3054;&#x3059;&#x500B;&#x4F53;&#x540C;&#x58EB;&#x306F;&#x89AA;&#x548C;&#x5EA6;&#x304C;&#x4E0A;&#x304C;&#x308B;
- &#x55A7;&#x5629;&#x306B;&#x8CA0;&#x3051;&#x305F;&#x76F8;&#x624B;&#x3092;&#x8A18;&#x61B6;&#x3057;&#x3001;&#x3057;&#x3070;&#x3089;&#x304F;&#x907F;&#x3051;&#x3084;&#x3059;&#x304F;&#x306A;&#x308B;
- &#x52DD;&#x6557;&#x306F;&#x30ED;&#x30B0;&#x3068;&#x9078;&#x629E;&#x30D1;&#x30CD;&#x30EB;&#x306B;&#x53CD;&#x6620;
- hay bed &#x306B;&#x5FEB;&#x9069;&#x5EA6;&#x30FB;&#x6DF7;&#x96D1;&#x30DA;&#x30CA;&#x30EB;&#x30C6;&#x30A3;&#x3092;&#x8FFD;&#x52A0;
- &#x7A7A;&#x3044;&#x3066;&#x3044;&#x308B;&#x30D9;&#x30C3;&#x30C9;&#x3092;&#x9078;&#x3073;&#x3084;&#x3059;&#x304F;&#x306A;&#x308B;
- &#x6DF7;&#x96D1;&#x6642;&#x306F;&#x56DE;&#x5FA9;&#x52B9;&#x7387;&#x304C;&#x4E0B;&#x304C;&#x308A;&#x3001;&#x30B9;&#x30C8;&#x30EC;&#x30B9;&#x304C;&#x4E0A;&#x304C;&#x308B;
- &#x8349;&#x306E;&#x751F;&#x614B;&#x7CFB;&#x3092;&#x5F37;&#x5316;
- &#x96E8;&#x3001;&#x6C34;&#x3001;&#x305A;&#x3093;&#x3061;&#x7531;&#x6765;&#x306E;&#x80A5;&#x6C83;&#x5730;&#x3067;&#x80B2;&#x3061;&#x3084;&#x3059;&#x304F;&#x306A;&#x308B;
- &#x5F37;&#x3044;&#x6674;&#x5929;&#x3067;&#x6C34;&#x5206;&#x304C;&#x306A;&#x3044;&#x3068;&#x5F31;&#x308A;&#x3084;&#x3059;&#x304F;&#x306A;&#x308B;
- &#x8349;&#x306E;&#x5468;&#x8FBA;&#x3067;&#x306F;&#x30B9;&#x30C8;&#x30EC;&#x30B9;&#x30FB;&#x6050;&#x6016;&#x304C;&#x5C11;&#x3057;&#x4E0B;&#x304C;&#x308B;
- &#x8868;&#x60C5;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;&#x306E;&#x512A;&#x5148;&#x9806;&#x4F4D;&#x3092;&#x6574;&#x7406;
- &#x305A;&#x3093;&#x3060;&#x9905;&#x3001;&#x88AB;&#x30C0;&#x30E1;&#x30FC;&#x30B8;&#x3001;&#x6557;&#x8D70;&#x3001;&#x6050;&#x6016;&#x3001;&#x4F4E;&#x4F53;&#x529B;&#x3001;&#x7761;&#x7720;&#x3001;&#x55A7;&#x5629;&#x3001;&#x5965;&#x79FB;&#x52D5;&#x3001;&#x901A;&#x5E38;&#x306E;&#x9806;&#x306B;&#x7AF6;&#x5408;&#x89E3;&#x6C7A;
- &#x30ED;&#x30B0;&#x3092;&#x7A2E;&#x985E;&#x5225;&#x306B;&#x8272;&#x5206;&#x3051;
- &#x8A95;&#x751F;&#x3001;&#x6B7B;&#x4EA1;&#x3001;&#x55A7;&#x5629;&#x3001;&#x98DF;&#x4E8B;&#x3001;&#x8349;&#x3001;&#x74B0;&#x5883;&#x3001;&#x95A2;&#x4FC2;&#x3001;&#x89B3;&#x5BDF;
- &#x55A7;&#x5629;&#x3067;&#x8EE2;&#x3076;&#x6F14;&#x51FA;&#x3092;&#x8FFD;&#x52A0;
- &#x6253;&#x6483;&#x6642;&#x306B;&#x4F4E;&#x78BA;&#x7387;&#x3067;&#x8EE2;&#x3076;
- &#x6557;&#x5317;&#x6642;&#x306B;&#x8EE2;&#x5012;&#x3057;&#x3001;&#x571F;&#x7159;&#x304C;&#x51FA;&#x308B;
- &#x4FDD;&#x5B58;&#x30AD;&#x30FC;&#x306F; `tarinai-colony-save-v11`
## 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.
- 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.

View file

@ -1,3 +1,4 @@
@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 {
@ -1025,3 +1026,131 @@ body::before {
max-height: none;
}
}
.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;
z-index: 2147483000;
width: 220px;
max-width: min(220px, calc(100vw - 16px));
padding: 7px 9px;
border-radius: 10px;
background: rgba(46, 38, 30, 0.20);
color: #fff8e6;
font-family: "Yomogi", "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
font-size: 12px;
line-height: 1.45;
box-shadow: 0 10px 28px rgba(40, 32, 22, 0.26);
backdrop-filter: blur(2px);
text-shadow: 0 1px 3px rgba(25, 18, 12, 0.55);
pointer-events: none;
}
.tool-tip-popover.hidden { display: none; }
.selected-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 8px 0 4px;
}
.favorite-toggle {
border: 1px solid rgba(128, 102, 56, 0.32);
border-radius: 999px;
background: rgba(255, 248, 221, 0.92);
color: #5a4630;
font-weight: 700;
padding: 5px 10px;
cursor: pointer;
}
.favorite-toggle.active {
background: rgba(255, 229, 122, 0.95);
border-color: rgba(180, 132, 34, 0.55);
}
.selected-records {
margin-top: 8px;
padding-top: 7px;
border-top: 1px dashed rgba(126, 96, 64, 0.24);
}
.selected-records h3 {
margin: 0 0 5px;
font-size: 12px;
color: var(--muted);
}
.selected-records ol {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
}
.selected-records li {
display: grid;
grid-template-columns: 38px 1fr;
gap: 6px;
align-items: start;
font-size: 11px;
line-height: 1.35;
}
.selected-records li span {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.selected-records li strong {
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;
}

21
docs/AI_CONTEXT.md Normal file
View file

@ -0,0 +1,21 @@
# AI_CONTEXT
Purpose: dense orientation for code-editing agents. Browser-only simulation; no build system. Source uses globals and strict script order, not ESM.
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.
- `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.
- `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.
Behavior hotspots:
- Need selection / target priority: `Tarinai.resolveNeeds` and related target helpers.
- Disease state: constructor fields plus `infect*`, `recover*`, `updateZunchiDisease`, `updateSpecialDiseases`.
- Conflict: intimidation/fight fields in `Tarinai`, global fight resolution in `World`.
- Cause of death: never duplicate regexes in `tarinai.js`; update `HEALTH`.
- Public load performance: avoid eager image loads; use `assets.js` lazy/fallback path.
Validation checklist: `node --check js/*.js`; search for accidental raw Japanese in `.js/.html/.css/.md/.json`; load `index.html` after cache-busting version changes.

View file

@ -1,27 +1,25 @@
# Code Map
# Code map
Use this map to open the smallest relevant file first.
Use this as the first lookup table before opening large files.
## Simulation
- `js/sim_core.js`: shared simulation helpers, personality definitions, `Effect`, `SpatialGrid`, and small drawing helpers used by simulation classes.
- `js/items.js`: `Item` lifecycle, item save/load restore, grass and zunchi behavior, and item drawing.
- `js/tarinai.js`: `Tarinai` state, AI, movement, eating, fighting, panic, reproduction-adjacent behavior, and drawing.
- `js/world.js`: `World` state, save/load, spawning, family registry normalization, spatial queries, tools, weather, and update loop coordination.
| Need | File / symbol |
|---|---|
| Script order / DOM shell | `index.html` |
| Global config, sprites, object metrics | `js/data.js` / `CONFIG`, `SPRITES`, `IMAGE_METRICS` |
| Lazy image loading | `js/assets.js` / `loadInitialImages`, `loadDeferredImages`, `getRenderableImage` |
| UI Japanese text catalog | `js/text_catalog.js` / `TEXT_CATALOG` |
| Core agent state | `js/tarinai.js` / `class Tarinai` constructor |
| Agent AI target choice | `js/tarinai.js` / `resolveNeeds`, target helpers |
| Disease state transitions | `js/tarinai.js` / `infect*`, `recover*`, `update*Disease*` |
| Damage/death cause rules | `js/health.js` / `HEALTH` |
| 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` |
| Main renderer | `js/render.js` / `render` helpers |
| Agent drawing | `js/tarinai.js` / `draw` |
| Input/tools/dialogs | `js/ui.js` |
| Statistics UI | `js/ui_charts.js` |
| Family tree UI | `js/ui_family.js` |
## UI And Rendering
- `js/render.js`: canvas scene rendering, lighting, weather visuals, HUD, and selected-card overlay.
- `js/ui.js`: DOM references, selected/log panels, labels, save/load buttons, tool controls, canvas input, and UI event binding.
- `js/ui_charts.js`: colony history aggregation and chart rendering.
- `js/ui_family.js`: family tree components, layout, archive rendering, and validation helper.
## Common Tasks
- Change hunger, sleep, fighting, panic, eating, or movement: start with `js/tarinai.js`.
- Change item growth, zunchi lifecycle, grass spread, or item visuals: start with `js/items.js`.
- Change save/load, population, spawning, weather, tools, or family data correctness: start with `js/world.js`.
- Change family tree display only: start with `js/ui_family.js`.
- Change colony charts: start with `js/ui_charts.js`.
- Change buttons, selected info, logs, or input handling: start with `js/ui.js`.
- Change the garden canvas visuals: start with `js/render.js`.
## Loading Order
`index.html` loads plain browser scripts in dependency order. Do not convert to ES modules or add a build step unless the project intentionally adopts one later.
Refactor guidance: extract isolated rule sets into small helper files, then delegate from `Tarinai`/`World`. Keep shared domain terms centralized (`HEALTH`, `TEXT_CATALOG`, `CONFIG`) to reduce repeated prompt context.

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="stylesheet" href="css/styles.css?v=9.0.0" />
<link rel="stylesheet" href="css/styles.css?v=15.0.0" />
</head>
<body>
<div id="app">
@ -44,6 +44,7 @@
<button class="tool" data-tool="poke"><span>P</span>&#x3064;&#x3064;&#x304F;</button>
<button class="tool" data-tool="pinch"><span>&#x1F90F;</span>&#x3064;&#x307E;&#x3080;</button>
<button class="tool" data-tool="new"><span>N</span>&#x8FFD;&#x52A0;</button>
<button class="tool" data-tool="water_hose"><span>Q</span>&#x6D17;&#x6D44;</button>
</div>
</section>
<section class="tool-category" data-tool-category="food">
@ -54,6 +55,8 @@
<button class="tool" data-tool="love_mochi"><span>H</span>&#x3078;&#x3053;&#x9905;</button>
<button class="tool" data-tool="fight_mochi"><span>K</span>&#x3051;&#x3093;&#x304B;&#x9905;</button>
<button class="tool" data-tool="water"><span>W</span>&#x6C34;</button>
<button class="tool" data-tool="water_bowl"><span>U</span>&#x6C34;&#x306E;&#x76BF;</button>
<button class="tool" data-tool="sleep_drug"><span>M</span>&#x306D;&#x3080;&#x308A;&#x85AC;</button>
<button class="tool" data-tool="grass"><span>G</span>&#x8349;</button>
</div>
</section>
@ -62,6 +65,7 @@
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="stone"><span>R</span>&#x77F3;</button>
<button class="tool" data-tool="bed"><span>B</span>&#x5E72;&#x8349;&#x5BDD;&#x5E8A;</button>
<button class="tool" data-tool="nest_box"><span>X</span>&#x5DE3;&#x7BB1;</button>
<button class="tool" data-tool="ball"><span>L</span>&#x30DC;&#x30FC;&#x30EB;</button>
<button class="tool" data-tool="firecracker"><span>F</span>&#x7206;&#x7AF9;</button>
<button class="tool" data-tool="fence_v"><span>V</span>&#x7E26;&#x306E;&#x67F5;</button>
@ -79,7 +83,7 @@
<div class="stat"><span>&#x500B;&#x4F53;&#x6570;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x7D2F;&#x8A08;&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div>
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">&#x751F;&#x6D3B;</button><button data-chart="events">&#x30A4;&#x30D9;&#x30F3;&#x30C8;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">&#x751F;&#x6D3B;</button><button data-chart="environment">&#x74B0;&#x5883;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div>
<div id="colonyChart" class="colony-chart" aria-label="&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x306E;&#x63A8;&#x79FB;&#x30B0;&#x30E9;&#x30D5;" role="img"></div>
</section>
@ -149,50 +153,27 @@
<div id="ecologyDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="ecologyDialogTitle">
<div class="field-dialog-panel ecology-dialog-panel">
<h2 id="ecologyDialogTitle">&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x751F;&#x614B;</h2>
<div class="ecology-grid">
<section class="ecology-card">
<img data-src="assets/sprites/tarinai_05_drool.png" loading="lazy" decoding="async" alt="">
<div><strong>&#x98DF;&#x3079;&#x308B;</strong><p>&#x304A;&#x8179;&#x304C;&#x3059;&#x304F;&#x3068;&#x98DF;&#x3079;&#x7269;&#x3084;&#x8349;&#x3092;&#x63A2;&#x3057;&#x307E;&#x3059;&#x3002;&#x98DF;&#x3079;&#x3089;&#x308C;&#x306A;&#x3044;&#x6642;&#x9593;&#x304C;&#x9577;&#x3044;&#x3068;&#x5F31;&#x3063;&#x3066;&#x3044;&#x304D;&#x307E;&#x3059;&#x3002;</p></div>
</section>
<section class="ecology-card">
<img data-src="assets/sprites/tarinai_07_sleep.png" loading="lazy" decoding="async" alt="">
<div><strong>&#x7720;&#x308B;</strong><p>&#x75B2;&#x308C;&#x308B;&#x3068;&#x305D;&#x306E;&#x5834;&#x3084;&#x5BDD;&#x5E8A;&#x3067;&#x7720;&#x308A;&#x307E;&#x3059;&#x3002;&#x4F11;&#x3081;&#x308B;&#x3068;&#x5143;&#x6C17;&#x3092;&#x53D6;&#x308A;&#x623B;&#x3057;&#x307E;&#x3059;&#x3002;</p></div>
</section>
<section class="ecology-card">
<img data-src="assets/sprites/tarinai_21_normal_happy.png" loading="lazy" decoding="async" alt="">
<div><strong>&#x306A;&#x3064;&#x304F;</strong><p>&#x6C17;&#x5206;&#x304C;&#x3088;&#x3044;&#x500B;&#x4F53;&#x306F;&#x30AB;&#x30FC;&#x30BD;&#x30EB;&#x3084;&#x4EF2;&#x9593;&#x306B;&#x5BC4;&#x3063;&#x3066;&#x304D;&#x307E;&#x3059;&#x3002;&#x3084;&#x3055;&#x3057;&#x304F;&#x69CB;&#x3046;&#x3068;&#x843D;&#x3061;&#x7740;&#x304D;&#x3084;&#x3059;&#x304F;&#x306A;&#x308A;&#x307E;&#x3059;&#x3002;</p></div>
</section>
<section class="ecology-card">
<img data-src="assets/sprites/tarinai_24_stress_dizzy.png" loading="lazy" decoding="async" alt="">
<div><strong>&#x95A2;&#x4FC2;&#x304C;&#x5909;&#x308F;&#x308B;</strong><p>&#x8FD1;&#x304F;&#x3067;&#x904E;&#x3054;&#x3059;&#x3046;&#x3061;&#x306B;&#x4EF2;&#x826F;&#x3057;&#x3084;&#x82E6;&#x624B;&#x304C;&#x751F;&#x307E;&#x308C;&#x307E;&#x3059;&#x3002;&#x5F37;&#x3044;&#x30B9;&#x30C8;&#x30EC;&#x30B9;&#x306F;&#x884C;&#x52D5;&#x3084;&#x8868;&#x60C5;&#x306B;&#x51FA;&#x307E;&#x3059;&#x3002;</p></div>
</section>
<section class="ecology-card">
<img data-src="assets/sprites/tarinai_27_birth_ritual.png" loading="lazy" decoding="async" alt="">
<div><strong>&#x5897;&#x3048;&#x308B;</strong><p>&#x843D;&#x3061;&#x7740;&#x3044;&#x305F;&#x76F8;&#x624B;&#x3069;&#x3046;&#x3057;&#x304B;&#x3089;&#x65B0;&#x3057;&#x3044;&#x500B;&#x4F53;&#x304C;&#x751F;&#x307E;&#x308C;&#x3001;&#x5BB6;&#x7CFB;&#x56F3;&#x306B;&#x89AA;&#x5B50;&#x95A2;&#x4FC2;&#x304C;&#x6B8B;&#x308A;&#x307E;&#x3059;&#x3002;</p></div>
</section>
<section class="ecology-card">
<img data-src="assets/sprites/tarinai_18_fear.png" loading="lazy" decoding="async" alt="">
<div><strong>&#x74B0;&#x5883;&#x306E;&#x5F71;&#x97FF;</strong><p>&#x96E8;&#x3084;&#x98DF;&#x3079;&#x7269;&#x4E0D;&#x8DB3;&#x3001;&#x3064;&#x3064;&#x304D;&#x3001;&#x55A7;&#x5629;&#x306A;&#x3069;&#x3067;&#x66AE;&#x3089;&#x3057;&#x3076;&#x308A;&#x304C;&#x5909;&#x308F;&#x308A;&#x307E;&#x3059;&#x3002;&#x5EAD;&#x306E;&#x914D;&#x7F6E;&#x3082;&#x5927;&#x5207;&#x3067;&#x3059;&#x3002;</p></div>
</section>
</div>
<div id="ecologyGrid" class="ecology-grid"></div>
<div class="field-dialog-actions">
<button id="ecologyCloseBtn" class="btn" type="button">&#x9589;&#x3058;&#x308B;</button>
</div>
</div>
</div>
<script src="js/data.js?v=9.0.0"></script>
<script src="js/math.js?v=9.0.0"></script>
<script src="js/assets.js?v=9.0.0"></script>
<script src="js/audio.js?v=9.0.0"></script>
<script src="js/render.js?v=9.0.0"></script>
<script src="js/sim_core.js?v=9.0.0"></script>
<script src="js/items.js?v=9.0.0"></script>
<script src="js/tarinai.js?v=9.0.0"></script>
<script src="js/world.js?v=9.0.0"></script>
<script src="js/ui.js?v=9.0.0"></script>
<script src="js/ui_charts.js?v=9.0.0"></script>
<script src="js/ui_family.js?v=9.0.0"></script>
<script src="js/main.js?v=9.0.0"></script>
<script src="js/data.js?v=15.0.0"></script>
<script src="js/math.js?v=15.0.0"></script>
<script src="js/assets.js?v=15.0.0"></script>
<script src="js/audio.js?v=15.0.0"></script>
<script src="js/render.js?v=15.0.0"></script>
<script src="js/sim_core.js?v=15.0.0"></script>
<script src="js/items.js?v=15.0.0"></script>
<script src="js/health.js?v=15.0.0"></script>
<script src="js/tarinai.js?v=15.0.0"></script>
<script src="js/world.js?v=15.0.0"></script>
<script src="js/text_catalog.js?v=15.0.0"></script>
<script src="js/ui.js?v=15.0.0"></script>
<script src="js/ui_charts.js?v=15.0.0"></script>
<script src="js/ui_family.js?v=15.0.0"></script>
<script src="js/main.js?v=15.0.0"></script>
</body>
</html>

View file

@ -66,7 +66,6 @@ const ALPHA_BOUNDS = {
};
const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"];
const NAMES = ["ta", "ri", "na", "i", "po", "yo", "nu", "ma", "fu", "he", "shi", "ro", "mu", "a"];
const CONFIG = {
initialPopulation: 18,
@ -108,4 +107,3 @@ const LIGHTING_TUNING = {
bloomWashAlpha: 0.040,
};
window.TarinaiData = { SPRITES, DECOR_ASSETS, ALPHA_BOUNDS, NEEDS, NAMES, CONFIG, FIELD_TYPES, LIGHTING_TUNING };

148
js/health.js Normal file
View file

@ -0,0 +1,148 @@
"use strict";
const HEALTH = (() => {
const MAJOR_DAMAGE_WINDOW = 10;
const WEAKENED_SUFFIX = "\u3067\u8870\u5f31";
const CAUSES = Object.freeze({
fight: "\u55a7\u5629",
hunger: "\u98e2\u9913",
stress: "\u30b9\u30c8\u30ec\u30b9\u904e\u591a",
poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f",
firecracker: "\u7206\u7af9",
accident: "\u4e8b\u6545",
lifespan: "\u5bff\u547d",
sleepDisease: "\u306d\u3080\u308a\u75c5",
explosionDisease: "\u7206\u767a\u75c5",
fightDisease: "\u55a7\u5629\u50b7\u75c5",
zunchiDisease: "\u305a\u3093\u3061\u75c5",
});
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
function causeLabel(reason = "") {
const text = String(reason || "");
if (!text) return "";
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight;
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker;
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke;
if (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease;
if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease;
if (/\u3051\u3093\u304b\u75c5|\u55a7\u5629\u50b7\u75c5|fight.*disease/.test(text)) return CAUSES.fightDisease;
if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease;
if (/\u75c5/.test(text)) {
const disease = text.match(DISEASE_RE);
return disease ? disease[1] : CAUSES.zunchiDisease;
}
if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger;
if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress;
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return CAUSES.accident;
if (/\u5bff\u547d/.test(text)) return CAUSES.lifespan;
return "";
}
function recentDamageCause(t, maxAge = MAJOR_DAMAGE_WINDOW) {
const now = t.world?.time || 0;
if (t.lastDamageCause && now - (t.lastDamageAt || -999) <= maxAge) return t.lastDamageCause;
return "";
}
function rememberDamage(t, amount, reason = "") {
const cause = causeLabel(reason) || CAUSES.accident;
const now = t.world?.time || 0;
t.lastDamageCause = cause;
t.lastDamageAmount = Math.max(0, amount || 0);
t.lastDamageAt = now;
if (t.lastDamageAmount >= 10 || t.lastDamageAmount >= Math.max(6, (t.energy || 0) * 0.36)) {
t.lastMajorDamageCause = cause;
t.lastMajorDamageAmount = t.lastDamageAmount;
t.lastMajorDamageAt = now;
}
return cause;
}
function weakenedDeathReasonFor(cause = "") {
const text = String(cause || "").trim();
if (!text) return "";
return text.includes(WEAKENED_SUFFIX) ? text : `${text}${WEAKENED_SUFFIX}`;
}
function hasRecentMajorDamage(t) {
const now = t.world?.time || 0;
return Boolean(t.lastMajorDamageCause && now - (t.lastMajorDamageAt || -999) <= MAJOR_DAMAGE_WINDOW);
}
function isFightingContext(t, recent) {
return Boolean(t.fightTimer > 0.04 || t.defeatedTimer > 0.04 || t.defeatedById || t.fightWinnerId || recent === CAUSES.fight);
}
function dominantDeathCause(t, reason = "", opts = {}) {
const text = String(reason || "").trim();
const direct = causeLabel(text);
const recentMajor = hasRecentMajorDamage(t);
const recent = recentDamageCause(t, 20);
const fighting = isFightingContext(t, recent);
if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 22 || t.mood <= 20 || t.stress >= 118)) {
return weakenedDeathReasonFor(t.lastMajorDamageCause);
}
if (direct && direct !== CAUSES.stress) return direct;
if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease;
if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger;
if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight;
if (recent && recent !== CAUSES.stress && (t.energy <= 34 || t.mood <= 20 || t.stress >= 118 || opts.fromDamage)) return recent;
if (text.includes(CAUSES.lifespan)) return CAUSES.lifespan;
if (direct) return direct;
if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress;
if (t.energy <= 0.5) return CAUSES.accident;
return "";
}
function normalizeDeathReason(t, reason = "", opts = {}) {
const text = String(reason || "").trim();
if (text.includes(WEAKENED_SUFFIX)) return text;
const cause = dominantDeathCause(t, text, opts) || CAUSES.accident;
if (cause.includes(WEAKENED_SUFFIX)) return cause;
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan;
if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause);
return cause;
}
function applyDamage(t, amount, reason = "") {
const before = t.energy;
const loss = Math.max(0, amount || 0);
t.energy = clamp(t.energy - loss, 0, 100);
const actualLoss = Math.max(0, before - t.energy);
let cause = "";
if (actualLoss > 0.01) {
cause = rememberDamage(t, actualLoss, reason);
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure();
if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f");
t.showHpBar();
}
if (t.energy <= 0.5) {
const majorNow = actualLoss >= Math.max(10, before * 0.42);
t.die(normalizeDeathReason(t, reason || cause, { fromDamage: true, weakened: majorNow }));
}
}
function briefDeathReason(t, reason = t.deathReason) {
const text = String(reason || "");
if (!text) return "";
const normalized = normalizeDeathReason(t, text);
return normalized.length > 12 ? `${normalized.slice(0, 12)}\u2026` : normalized;
}
return Object.freeze({
MAJOR_DAMAGE_WINDOW,
CAUSES,
causeLabel,
recentDamageCause,
rememberDamage,
weakenedDeathReasonFor,
dominantDeathCause,
normalizeDeathReason,
applyDamage,
briefDeathReason,
});
})();
window.HEALTH = HEALTH;

View file

@ -7,10 +7,10 @@ class Item {
this.x = x;
this.y = y;
this.r = {
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
this.amount = {
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, water: 64, water_bowl: 999, grass: 120, stone: 999, bed: 999, nest_box: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80;
this.age = 0;
this.seed = Math.random() * 1000;
@ -68,7 +68,7 @@ class Item {
}
}
if (this.type === "water") this.amount -= dt * 0.9;
if (["sweet", "love_mochi", "fight_mochi"].includes(this.type)) this.amount -= dt * 0.12;
if (["sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(this.type)) this.amount -= dt * 0.12;
if (this.type === "trace") this.amount -= dt * 1.35;
if (this.type === "splat") this.amount -= dt * 1.05;
if (this.type === "firecracker") {
@ -79,6 +79,7 @@ class Item {
}
}
if (this.type === "ball") this.updateBall(dt, worldRef);
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
if (this.type === "grass") {
this.lifecycleTimer += dt;
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
@ -98,6 +99,26 @@ class Item {
}
}
}
updateZunchiMotion(dt, worldRef) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
this.prevX = this.x;
this.prevY = this.y;
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; }
const slow = Math.pow(0.68, dt * 2.4);
this.vx *= slow;
this.vy *= slow;
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
worldRef.drawListDirty = true;
}
updateBall(dt, worldRef) {
this.prevX = this.x;
this.prevY = this.y;
@ -379,10 +400,10 @@ class Item {
it.x = o.x;
it.y = o.y;
it.r = {
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
it.amount = o.amount ?? ({
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, water: 64, water_bowl: 999, grass: 120, stone: 999, bed: 999, nest_box: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80);
it.age = o.age || 0;
it.seed = o.seed || Math.random() * 1000;
@ -561,6 +582,36 @@ class Item {
ctx.fill();
}
}
} else if (this.type === "sleep_drug") {
ctx.fillStyle = "#f5f0ff";
ctx.strokeStyle = "rgba(112, 83, 170, 0.55)";
ctx.lineWidth = 2;
ctx.rotate(-0.45);
roundedRect(ctx, -this.r * 0.95, -this.r * 0.45, this.r * 1.90, this.r * 0.90, this.r * 0.45);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(152, 116, 218, 0.58)";
ctx.fillRect(-1, -this.r * 0.42, 2, this.r * 0.84);
ctx.rotate(0.45);
ctx.fillStyle = "rgba(74,58,104,0.72)";
ctx.font = `${Math.round((this.r || 13) * 0.78)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Z", 0, this.r * 1.18);
} else if (this.type === "water_bowl") {
ctx.fillStyle = "rgba(171, 120, 76, 0.72)";
ctx.strokeStyle = "rgba(94, 69, 48, 0.55)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(86, 157, 224, 0.58)";
ctx.strokeStyle = "rgba(62, 113, 178, 0.36)";
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.54)";
ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill();
} else if (this.type === "water") {
ctx.fillStyle = "rgba(86, 157, 224, 0.50)";
ctx.strokeStyle = "rgba(62, 113, 178, 0.42)";
@ -632,6 +683,22 @@ class Item {
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "nest_box") {
ctx.fillStyle = styleNight ? "#7d6040" : (styleWarm ? "#b98246" : "#9a6b3d");
ctx.strokeStyle = styleNight ? "#4a3a2d" : "#5d4028";
ctx.lineWidth = 2.4;
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.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 === "ball") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);

View file

@ -633,6 +633,7 @@ function render() {
}
};
drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
drawNestBoxTooltip(ctx, world);
// Time HUD
ctx.save();
@ -675,6 +676,33 @@ function render() {
}
}
function drawNestBoxTooltip(ctx, world) {
const info = world.pointerNestBoxInfo?.();
if (!info) return;
const box = info.box;
const names = info.occupants.length ? info.occupants.map(t => t.name).join("\u3001") : "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044";
const title = "\u5de3\u7bb1";
const text = `${title}: ${names}`;
ctx.save();
ctx.font = "12px Yomogi, sans-serif";
const w = Math.min(240, Math.max(92, ctx.measureText(text).width + 22));
const h = 38;
const pos = world.worldToScreen ? world.worldToScreen(box.x, box.y) : { x: box.x, y: box.y };
const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8);
const y = clamp(pos.y - box.r * 1.8 - h, 8, ctx.canvas.height - h - 8);
ctx.fillStyle = "rgba(46,38,30,0.62)";
ctx.strokeStyle = "rgba(255,248,220,0.72)";
ctx.lineWidth = 1.2;
roundedRect(ctx, x, y, w, h, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#fff8e6";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(text, x + 11, y + h / 2);
ctx.restore();
}
function drawSelectedCard(ctx, world, lighting) {
const t = world.selected;
if (!t || t.dead || !world.tarinai.includes(t)) return;

View file

@ -138,6 +138,10 @@ class Effect {
ctx.beginPath();
ctx.ellipse(0, 0, this.size * 1.25, this.size * 0.72, this.seed, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "heart") {
const s = this.size * (0.80 + (1 - alpha) * 0.36);
ctx.rotate(Math.sin(this.seed + (1 - alpha) * 3.2) * 0.18);
drawHeartShape(ctx, 0, 0, s, { fill: this.color || "rgba(240,91,135,0.92)", stroke: "rgba(255,252,246,0.88)", alpha: 1 });
} else if (this.type === "ring") {
ctx.strokeStyle = this.color;
ctx.lineWidth = 2;

View file

@ -1,5 +1,19 @@
"use strict";
function tarinaiRoundRectPath(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r || 0, Math.abs(w) / 2, Math.abs(h) / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
ctx.lineTo(x + w, y + h - rr);
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
}
class Tarinai {
constructor(world, opts = {}) {
this.world = world;
@ -86,6 +100,14 @@ class Tarinai {
this.formerName = opts.formerName || null;
this.loveMochiTimer = opts.loveMochiTimer ?? 0;
this.fightMochiTimer = opts.fightMochiTimer ?? 0;
this.sleepDisease = Boolean(opts.sleepDisease);
this.sleepDiseaseCooldown = opts.sleepDiseaseCooldown ?? 0;
this.explosionDisease = Boolean(opts.explosionDisease);
this.explosionDiseaseTimer = opts.explosionDiseaseTimer ?? (this.explosionDisease ? (CONFIG.dayLength || 120) * 0.5 : 0);
this.fightDisease = Boolean(opts.fightDisease);
this.fightDiseaseCooldown = opts.fightDiseaseCooldown ?? 0;
this.lastBleedAt = opts.lastBleedAt ?? -999;
this.favorite = Boolean(opts.favorite);
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;
@ -144,6 +166,7 @@ class Tarinai {
birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader,
pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed,
totalFightLosses: this.totalFightLosses, 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, 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,
@ -200,6 +223,17 @@ class Tarinai {
return sp ? sp.label : this.type;
}
addRecord(text, kind = "note") {
if (!text) return;
if (!Array.isArray(this.records)) this.records = [];
const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" };
const head = this.records[0];
if (head && head.text === entry.text && Math.abs((head.time || 0) - entry.time) < 0.05) return;
this.records.unshift(entry);
if (this.records.length > 42) this.records.length = 42;
}
personalityProfile() {
return PERSONALITIES[this.personality] || PERSONALITIES.calm;
}
@ -303,100 +337,13 @@ class Tarinai {
return gained;
}
damageCauseLabel(reason = "") {
const text = String(reason || "");
if (!text) return "";
if (text.includes("\u3067\u8870\u5f31")) return text.replace(/\u3067\u8870\u5f31.*$/, "");
if (/\u55a7\u5629|fight|headbutt/.test(text)) return "\u55a7\u5629";
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9";
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e\u305f";
if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5";
if (/\u75c5/.test(text)) {
const disease = text.match(/([^\u3001\u3002\s]+\u75c5)/);
return disease ? disease[1] : "\u305a\u3093\u3061\u75c5";
}
if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913";
if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a";
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545";
if (/\u5bff\u547d/.test(text)) return "\u5bff\u547d";
return "";
}
recentDamageCause(maxAge = 10) {
const now = this.world?.time || 0;
if (this.lastDamageCause && now - (this.lastDamageAt || -999) <= maxAge) return this.lastDamageCause;
return "";
}
rememberDamage(amount, reason = "") {
const cause = this.damageCauseLabel(reason) || "\u4e8b\u6545";
const now = this.world?.time || 0;
this.lastDamageCause = cause;
this.lastDamageAmount = Math.max(0, amount || 0);
this.lastDamageAt = now;
if (this.lastDamageAmount >= 10 || this.lastDamageAmount >= Math.max(6, (this.energy || 0) * 0.36)) {
this.lastMajorDamageCause = cause;
this.lastMajorDamageAmount = this.lastDamageAmount;
this.lastMajorDamageAt = now;
}
return cause;
}
weakenedDeathReasonFor(cause = "") {
const text = String(cause || "").trim();
if (!text) return "";
return text.includes("\u3067\u8870\u5f31") ? text : `${text}\u3067\u8870\u5f31`;
}
dominantDeathCause(reason = "", opts = {}) {
const text = String(reason || "").trim();
const direct = this.damageCauseLabel(text);
const now = this.world?.time || 0;
const recentMajor = this.lastMajorDamageCause && now - (this.lastMajorDamageAt || -999) <= 10;
const recent = this.recentDamageCause ? this.recentDamageCause(20) : "";
const fighting = this.fightTimer > 0.04 || this.defeatedTimer > 0.04 || this.defeatedById || this.fightWinnerId || recent === "\u55a7\u5629";
if (recentMajor && (!direct || direct === this.lastMajorDamageCause || opts.fromDamage || this.energy <= 22 || this.mood <= 20 || this.stress >= 118)) {
return this.weakenedDeathReasonFor(this.lastMajorDamageCause);
}
if (direct && direct !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a") return direct;
if (this.zunchiDisease && ((this.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return "\u305a\u3093\u3061\u75c5";
if (this.hunger >= 108 || (this.hunger >= 94 && this.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return "\u98e2\u9913";
if (fighting && (this.energy <= 44 || this.stress >= 112 || this.mood <= 22 || opts.fromDamage)) return "\u55a7\u5629";
if (recent && recent !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a" && (this.energy <= 34 || this.mood <= 20 || this.stress >= 118 || opts.fromDamage)) return recent;
if (text.includes("\u5bff\u547d")) return "\u5bff\u547d";
if (direct) return direct;
if (this.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a";
if (this.energy <= 0.5) return "\u4e8b\u6545";
return "";
}
normalizeDeathReason(reason = "", opts = {}) {
const text = String(reason || "").trim();
if (text.includes("\u3067\u8870\u5f31")) return text;
const cause = this.dominantDeathCause(text, opts) || "\u4e8b\u6545";
if (cause.includes("\u3067\u8870\u5f31")) return cause;
const now = this.world?.time || 0;
const recentMajor = this.lastMajorDamageCause && now - (this.lastMajorDamageAt || -999) <= 10;
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== "\u98e2\u9913" && cause !== "\u30b9\u30c8\u30ec\u30b9\u904e\u591a" && cause !== "\u5bff\u547d";
if (canWeaken && (opts.weakened || (recentMajor && cause === this.lastMajorDamageCause))) return this.weakenedDeathReasonFor(cause);
return cause;
}
damage(amount, reason = "") {
const before = this.energy;
const loss = Math.max(0, amount || 0);
this.energy = clamp(this.energy - loss, 0, 100);
const actualLoss = Math.max(0, before - this.energy);
let cause = "";
if (actualLoss > 0.01) {
cause = this.rememberDamage(actualLoss, reason);
this.showHpBar();
}
if (this.energy <= 0.5) {
const majorNow = actualLoss >= Math.max(10, before * 0.42);
this.die(this.normalizeDeathReason(reason || cause, { fromDamage: true, weakened: majorNow }));
}
}
damageCauseLabel(reason = "") { return HEALTH.causeLabel(reason); }
recentDamageCause(maxAge = 10) { return HEALTH.recentDamageCause(this, maxAge); }
rememberDamage(amount, reason = "") { return HEALTH.rememberDamage(this, amount, reason); }
weakenedDeathReasonFor(cause = "") { return HEALTH.weakenedDeathReasonFor(cause); }
dominantDeathCause(reason = "", opts = {}) { return HEALTH.dominantDeathCause(this, reason, opts); }
normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); }
damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); }
recoverHealth(amount) {
if (this.dead) return;
@ -526,12 +473,7 @@ class Tarinai {
return null;
}
briefDeathReason(reason = this.deathReason) {
const text = String(reason || "");
if (!text) return "";
const normalized = this.normalizeDeathReason ? this.normalizeDeathReason(text) : text;
return normalized.length > 12 ? `${normalized.slice(0, 12)}\u2026` : normalized;
}
briefDeathReason(reason = this.deathReason) { return HEALTH.briefDeathReason(this, reason); }
variantSprite(category) {
const pools = {
@ -609,6 +551,192 @@ class Tarinai {
}
}
hasWaterCurableDisease() {
return Boolean(this.zunchiDisease || this.explosionDisease);
}
hasZundaCurableDisease() {
return Boolean(this.explosionDisease || this.fightDisease);
}
infectSleepDisease(source = null) {
if (this.dead || this.sleepDisease) return false;
this.sleepDisease = true;
this.sleepDiseaseCooldown = 8;
this.sleepDiseaseAnchorX = this.x;
this.sleepDiseaseAnchorY = this.y;
this.state = "sleep";
this.target = null;
this.sleeping = true;
this.fightTimer = 0;
this.intimidateTimer = 0;
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");
return true;
}
recoverSleepDisease(reason = "") {
if (!this.sleepDisease) return false;
this.sleepDisease = false;
this.sleepDiseaseCooldown = 14;
this.sleepDiseaseAnchorX = null;
this.sleepDiseaseAnchorY = null;
this.state = "idle";
this.sleeping = false;
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");
return true;
}
infectExplosionDisease(source = null) {
if (this.dead || this.explosionDisease) return false;
this.explosionDisease = true;
this.explosionDiseaseTimer = Math.max(this.explosionDiseaseTimer || 0, (CONFIG.dayLength || 120) * 0.5);
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");
return true;
}
recoverExplosionDisease(reason = "") {
if (!this.explosionDisease) return false;
this.explosionDisease = false;
this.explosionDiseaseTimer = 0;
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");
return true;
}
recordBleedExposure() {
this.lastBleedAt = this.world?.time || 0;
}
infectFightDisease(source = null) {
if (this.dead || this.fightDisease) return false;
this.fightDisease = true;
this.fightDiseaseCooldown = 18;
this.fearTimer = Math.max(this.fearTimer || 0, 0.35);
this.thought = "\u55a7\u5629\u50b7\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\u55a7\u5629\u50b7\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight");
return true;
}
recoverFightDisease(reason = "") {
if (!this.fightDisease) return false;
this.fightDisease = false;
this.fightDiseaseCooldown = 20;
this.thought = reason || "\u55a7\u5629\u50b7\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\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight");
return true;
}
freezeSleepDiseaseMotion(dt = 0) {
if (!this.sleepDisease) return false;
if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x;
if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y;
this.state = "sleep";
this.sleeping = true;
this.target = null;
this.vx = 0;
this.vy = 0;
this.x = this.sleepDiseaseAnchorX;
this.y = this.sleepDiseaseAnchorY;
this.wanderAngle = this.wanderAngle || 0;
this.thought = "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b";
if (this.world && this.world.time > (this.nextSleepBubbleAt || 0)) {
this.nextSleepBubbleAt = this.world.time + 5.0;
this.world.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
}
return true;
}
updateNestBoxPresence(dt = 0) {
const previous = this.insideNestBoxId || null;
let box = null;
if (previous) box = this.world?.items?.find?.(it => it && !it.dead && it.id === previous && it.type === "nest_box") || null;
if (!box && this.target && !this.target.dead && this.target.type === "nest_box") box = this.target;
if (!box) {
this.insideNestBoxId = null;
this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1);
return null;
}
const d = distXY(this.x, this.y, box.x, box.y);
const canEnter = (this.state === "sleep" || this.state === "seek_bed") && d <= Math.max(18, box.r * 0.58);
if (canEnter) {
this.insideNestBoxId = box.id;
this.nestFade = clamp((this.nestFade || 0) + dt * 2.4, 0, 1);
const pull = clamp(dt * 4.2, 0, 1);
this.x = lerp(this.x, box.x, pull);
this.y = lerp(this.y, box.y, pull);
this.vx *= Math.pow(0.45, dt * 60);
this.vy *= Math.pow(0.45, dt * 60);
return box;
}
this.insideNestBoxId = null;
this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1);
return null;
}
applyWaterEffect(dt, source = "water") {
const power = Math.max(0, dt || 0);
this.stress = clamp(this.stress - power * 2.4, 0, 130);
this.energy = clamp(this.energy + power * 0.5, 0, 100);
this.hunger = clamp(this.hunger - power * 0.2, 0, 115);
this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100);
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");
}
}
if (this.explosionDisease) this.recoverExplosionDisease("\u6c34\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
}
updateSpecialDiseases(dt) {
this.sleepDiseaseCooldown = Math.max(0, (this.sleepDiseaseCooldown || 0) - dt);
this.fightDiseaseCooldown = Math.max(0, (this.fightDiseaseCooldown || 0) - dt);
if (this.explosionDisease) {
this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt);
this.addStress ? this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }) : (this.stress = clamp(this.stress + dt * 0.22, 0, 130));
if (Math.random() < dt * 2.1) {
const a = rand(0, Math.PI * 2);
this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), {
vx: Math.cos(a) * rand(10, 38), vy: Math.sin(a) * rand(10, 38) - rand(8, 22), size: rand(5, 12), life: rand(0.18, 0.42), color: "rgba(255,64,48,0.86)"
}));
}
if (this.explosionDiseaseTimer <= 0.01) {
this.world?.explodeDiseaseTarinai?.(this);
return;
}
if (this.explosionDiseaseTimer < 8 && Math.random() < dt * 0.45) this.bubble("!", 1.0, "rgba(178,78,42,0.82)");
}
const bleedAge = (this.world?.time || 0) - (this.lastBleedAt || -999);
if (!this.fightDisease && this.fightDiseaseCooldown <= 0 && bleedAge >= (CONFIG.dayLength || 120) * 5 / 24 && bleedAge < (CONFIG.dayLength || 120) * 0.85) {
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.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) {
this.recoverFightDisease("\u81ea\u7136\u306b\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f");
}
if (this.fightDisease) {
this.stress = clamp(this.stress + dt * 0.16, 0, 130);
if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8);
if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)");
}
}
canEatItemType(type) {
return this.isZunchiSlave ? type === "zunchi" : true;
}
@ -642,6 +770,9 @@ class Tarinai {
rawSpriteId() {
if (this.dead) return this.variantSprite("fear");
if (this.sleepDisease) return this.variantSprite("sleep");
if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan";
if (this.fightDisease) return "stress_dizzy";
if (this.isZunchiSlave) return "zunchi_slave";
if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry";
if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat";
@ -684,6 +815,37 @@ class Tarinai {
return this.visibleSpriteId;
}
isAwakeCursorFriendly() {
if (this.dead || this.sleepDisease || this.sleeping || this.state === "sleep" || this.state === "seek_bed") return false;
if (["fight", "panic", "cursor_enemy", "hurt", "defeated", "fight_sick"].includes(this.state)) return false;
if ((this.fearTimer || 0) > 0.18 || (this.intimidatedTimer || 0) > 0.04 || (this.intimidateTimer || 0) > 0.04) return false;
if (this.mood < 46 || this.stress > 64) return false;
return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || ["lonely", "calm", "playful", "teary", "cry"].includes(this.personality);
}
applyCursorContactCare(dt) {
const pointer = this.world?.pointer;
if (!pointer?.inside || !this.isAwakeCursorFriendly()) return;
const d = distXY(this.x, this.y, pointer.x, pointer.y);
const touch = this.radius * 1.35;
if (d > touch) return;
const p = clamp(1 - d / touch, 0, 1);
const relief = dt * (1.35 + 2.65 * p);
this.stress = clamp((this.stress || 0) - relief, 0, 130);
this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5);
this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75);
this.goodMode = "smile";
if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) {
this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10);
for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) {
this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), {
vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)"
}));
}
}
}
update(dt) {
if (this.dead) return;
@ -703,6 +865,8 @@ class Tarinai {
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt);
this.updateZunchiDisease(dt);
this.updateSpecialDiseases(dt);
if (this.dead) return;
this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt);
this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt);
if (this.fightMochiTimer > 0.04) {
@ -721,6 +885,11 @@ class Tarinai {
}
this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt);
this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2);
if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt);
return;
}
this.applyCursorContactCare(dt);
const wasFighting = this.fightTimer > 0.04;
this.fightTimer = Math.max(0, this.fightTimer - dt);
this.fightCooldown = Math.max(0, this.fightCooldown - dt);
@ -792,6 +961,11 @@ class Tarinai {
this.energy = clamp(this.energy, 0, 100);
this.stress = clamp(this.stress, 0, 130);
if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt);
return;
}
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;
@ -821,6 +995,7 @@ class Tarinai {
}
this.maintainTargetProgress(dt);
this.move(dt);
this.updateNestBoxPresence(dt);
if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this);
this.checkLife(dt);
}
@ -840,6 +1015,12 @@ class Tarinai {
const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2);
grassComfort += clamp(1 - d / 105, 0, 1) * lush;
}
let nestComfort = 0;
for (const it of this.world.nearbyItems(this.x, this.y, 120)) {
if (!it || it.dead || it.type !== "nest_box") continue;
nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1);
}
if (nestComfort > 0) this.stress = clamp(this.stress - dt * Math.min(1.15, nestComfort * 0.62), 0, 130);
if (grassComfort > 0) {
this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130);
if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06));
@ -862,15 +1043,18 @@ class Tarinai {
}
isSleepFurniture(target) {
return Boolean(target && target.type === "bed");
return Boolean(target && (target.type === "bed" || target.type === "nest_box"));
}
sleepFurnitureLabel(target) {
return "\u5e72\u8349\u5bdd\u5e8a";
return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a";
}
sleepSpotFor(bed) {
if (!bed) return { x: this.x, y: this.y };
if (bed.type === "nest_box") {
return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) };
}
const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2;
const baseRing = bed.r * 0.52;
const ring = baseRing + stableUnit(this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95;
@ -1012,28 +1196,49 @@ class Tarinai {
}
}
if (this.zunchiDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
const water = this.world.nearest(this, ["water"], 360);
if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
const water = this.world.nearest(this, ["water", "water_bowl"], 760);
if (water && !water.dead) {
this.state = "seek_water";
this.target = water;
this.thought = "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
this.thought = this.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
return;
}
this.state = "zunchi_sick";
if (this.zunchiDisease) {
this.state = "zunchi_sick";
this.target = null;
this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4);
return;
}
}
if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
const cureFood = this.world.nearest(this, ["sweet"], 700);
if (cureFood && cureFood.amount > 1.2 && !this.shouldAvoidTarget(cureFood)) {
this.state = "seek_food";
this.target = cureFood;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.42);
this.thought = this.fightDisease ? "\u55a7\u5629\u50b7\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u7206\u767a\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b";
return;
}
}
if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
this.state = "fight_sick";
this.target = null;
this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4);
this.thought = "\u55a7\u5629\u50b7\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4);
return;
}
if (!this.isZunchiSlave) {
const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi"], wasSleeping ? 520 : 390);
const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi", "sleep_drug"], wasSleeping ? 520 : 390);
if (specialMochi && specialMochi.amount > 1.2 && this.hunger > 10 && !this.shouldAvoidTarget(specialMochi)) {
this.state = "seek_food";
this.target = specialMochi;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.34);
this.thought = specialMochi.type === "love_mochi" ? "\u3078\u3053\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u3051\u3093\u304b\u9905\u3092\u63a2\u3057\u3066\u3044\u308b";
this.thought = specialMochi.type === "love_mochi" ? "\u3078\u3053\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : (specialMochi.type === "fight_mochi" ? "\u3051\u3093\u304b\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u306d\u3080\u308a\u85ac\u3092\u63a2\u3057\u3066\u3044\u308b");
return;
}
} else if (this.hunger > 20) {
@ -1097,7 +1302,7 @@ class Tarinai {
const pd = distXY(this.x, this.y, pointer.x, pointer.y);
if (pd < 170) {
const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry";
const friendly = !hostile && (this.mood > 52 || this.affection > 12 || this.goodMode === "smile");
const friendly = !hostile && this.isAwakeCursorFriendly();
if (hostile) {
this.state = "cursor_enemy";
this.target = { x: pointer.x, y: pointer.y, dead: false };
@ -1149,7 +1354,7 @@ class Tarinai {
}
}
if (friendNearby && this.hunger > 44 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "grass"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
if (friendNearby && this.hunger > 44 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
this.state = "seek_food";
this.target = friendNearby.target;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.24);
@ -1158,7 +1363,7 @@ class Tarinai {
}
if (this.hunger > 62) {
const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "grass"];
const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass"];
const item = this.world.nearest(this, itemTypes, 420);
if (item) {
this.state = "seek_food";
@ -1225,6 +1430,8 @@ class Tarinai {
if (it.type === "sweet") {
this.stress = clamp(this.stress - eating * 0.42, 0, 130);
this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035);
if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f");
}
this.eatTimer = Math.max(this.eatTimer, 0.32);
this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78);
@ -1245,7 +1452,7 @@ class Tarinai {
}
}
if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi") && it.amount > 1.2) {
if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug") && it.amount > 1.2) {
const eating = Math.min(it.amount, 6.8);
this.state = "eat";
this.target = it;
@ -1272,12 +1479,12 @@ class Tarinai {
this.foodReactTimer = Math.max(this.foodReactTimer, 0.16);
if (Math.random() < dt * 2.2) {
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "love_mochi" ? "#ff7bab" : "#e07c43");
this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "love_mochi" ? "#ff7bab" : (it.type === "fight_mochi" ? "#e07c43" : "#b89cff"));
}
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" : "\u3051\u3093\u304b\u9905"}\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");
this.lastLog = this.world.time;
}
break;
@ -1335,18 +1542,9 @@ class Tarinai {
}
}
if (it.type === "water") {
this.stress -= dt * 2.4;
this.energy += dt * 0.5;
this.hunger -= dt * 0.2;
this.zunchiStain = clamp((this.zunchiStain || 0) - dt * 10.5, 0, 100);
if (this.zunchiDisease) {
this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * (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");
}
}
it.amount -= dt * 2.4;
if (it.type === "water" || it.type === "water_bowl") {
this.applyWaterEffect(dt, it.type);
if (it.type === "water") it.amount -= dt * 2.4;
if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18;
}
@ -1516,11 +1714,12 @@ class Tarinai {
}
move(dt) {
if (this.freezeSleepDiseaseMotion(dt)) return;
let ax = 0, ay = 0;
const baseSpeed = 46.5 * 1.5 * this.trait.speed * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1);
if (this.state === "sleep") {
const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) : 0.72;
const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72;
const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0;
const crowdedSleep = bedCrowd > 5;
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100);
@ -1540,6 +1739,7 @@ class Tarinai {
this.y = spot.y;
}
}
this.updateNestBoxPresence(dt);
this.vx *= Math.pow(0.55, dt * 60);
this.vy *= Math.pow(0.55, dt * 60);
return;
@ -1639,6 +1839,10 @@ class Tarinai {
this.wanderAngle += rand(-2.4, 2.4) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8;
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72;
} else if (this.state === "fight_sick") {
this.wanderAngle += rand(-4.2, 4.2) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6;
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.88;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.88;
} else {
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34;
@ -1649,7 +1853,7 @@ class Tarinai {
this.vx *= Math.pow(0.84, dt * 8);
this.vy *= Math.pow(0.84, dt * 8);
const maxV = this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : 76);
const maxV = this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 76));
const v = Math.hypot(this.vx, this.vy);
if (v > maxV) {
this.vx = this.vx / v * maxV;
@ -1734,7 +1938,8 @@ class Tarinai {
const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25;
const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0;
const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0);
const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin;
const diseaseSpin = this.fightDisease ? (t * 3.6 + this.age) : 0;
const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin + diseaseSpin;
const insideCardboard = false;
let drawW = w * this.scale * 0.30;
let drawH = h * this.scale * 0.30;
@ -1765,18 +1970,19 @@ class Tarinai {
? this.sleepFacing > 0
: this.facingDir() > 0;
const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt;
const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade * 0.92, 0.08, 1) : 1;
const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8);
const shadowContactY = this.y + bob + (drawH / squish) * 0.30;
if (img) {
drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, {
faceRight,
alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92),
alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92) * nestHideAlpha,
widthScale: this.state === "sleep" ? 1.12 : 1,
heightScale: this.state === "sleep" ? 0.88 : 1,
});
} else {
drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 });
drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha });
}
drawCreatureGlow(ctx, this, drawW, drawH, lightState);
if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) {
@ -1793,6 +1999,49 @@ class Tarinai {
ctx.rotate(angle);
if (insideCardboard) ctx.globalAlpha *= 0.58;
const observeKind = this.world.observeHighlightKind ? this.world.observeHighlightKind(this) : "";
if (observeKind && this.world.selected !== this) {
const style = {
parent: { color: "rgba(86,142,232,0.86)", label: "\u89aa" },
child: { color: "rgba(93,170,92,0.88)", label: "\u5b50" },
friend: { color: "rgba(240,91,135,0.90)", label: "\u4ef2\u826f\u3057" },
enemy: { color: "rgba(214,82,66,0.90)", label: "\u6575\u5bfe" },
}[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" };
ctx.save();
ctx.globalAlpha = 0.96;
ctx.shadowColor = style.color;
ctx.shadowBlur = 16;
ctx.strokeStyle = style.color;
ctx.lineWidth = 5.2;
ctx.beginPath();
ctx.ellipse(0, drawH * 0.14, drawW * 0.78, drawH * 0.60, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.lineWidth = 2.0;
ctx.setLineDash([8, 5]);
ctx.beginPath();
ctx.ellipse(0, drawH * 0.14, drawW * 0.86, drawH * 0.66, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
if (style.label) {
const labelY = -drawH * 0.62;
ctx.font = `${Math.max(11, this.radius * 0.52)}px Yomogi, ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const labelW = ctx.measureText(style.label).width + 16;
ctx.fillStyle = "rgba(255,252,238,0.92)";
ctx.strokeStyle = style.color;
ctx.lineWidth = 2.2;
tarinaiRoundRectPath(ctx, -labelW / 2, labelY - 10, labelW, 20, 10);
ctx.fill();
ctx.stroke();
ctx.fillStyle = style.color;
ctx.fillText(style.label, 0, labelY);
}
ctx.restore();
}
if (this.world.selected === this) {
ctx.save();
ctx.globalAlpha = 0.85;
@ -1833,7 +2082,7 @@ class Tarinai {
if (this.pokeFlashTimer > 0.03) {
ctx.save();
ctx.globalAlpha = this.pokeFlashTimer * 0.9;
ctx.globalAlpha = this.pokeFlashTimer * 0.9 * nestHideAlpha;
ctx.fillStyle = "rgba(255, 120, 120, 0.22)";
ctx.beginPath();
ctx.ellipse(0, 0, drawW * 0.60, drawH * 0.42, 0, 0, Math.PI * 2);
@ -1846,7 +2095,7 @@ class Tarinai {
const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0;
ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34;
ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9"));
const count = this.state === "cursor_friend" ? (cursorLove > 0.18 ? 5 : 3) : 2;
const count = this.state === "cursor_friend" ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2;
for (let i = 0; i < count; i++) {
const orbit = 0.24 + (i % 3) * 0.08 + cursorLove * 0.05;
const px = Math.cos(t * 2.2 + this.age + i * 1.42) * drawW * orbit;
@ -1863,6 +2112,7 @@ class Tarinai {
ctx.restore();
}
ctx.globalAlpha *= nestHideAlpha;
if (faceRight) ctx.scale(-1, 1);
const dw = drawW * squish;
const dh = drawH / squish;
@ -1910,6 +2160,25 @@ class Tarinai {
ctx.restore();
if (this.favorite) {
ctx.save();
ctx.globalAlpha = 1;
const starSize = Math.max(18, this.radius * 0.88);
const sx = this.x;
const sy = this.y - drawH * 0.64 - 20;
ctx.font = `${starSize}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(255, 198, 28, 0.82)";
ctx.shadowBlur = 14;
ctx.lineWidth = Math.max(2, starSize * 0.12);
ctx.strokeStyle = "rgba(92, 62, 0, 0.86)";
ctx.fillStyle = "rgba(255, 218, 46, 1)";
ctx.strokeText("\u2605", sx, sy);
ctx.fillText("\u2605", sx, sy);
ctx.restore();
}
const hpActive = (this.hpBarTimer || 0) > 0;
const stressActive = (this.stressBarTimer || 0) > 0;
if (hpActive || stressActive) {

71
js/text_catalog.js Normal file
View file

@ -0,0 +1,71 @@
"use strict";
const TEXT_CATALOG = {
toolTips: {
observe: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002",
delete: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002",
poke: "\u3064\u3064\u3044\u3066\u53cd\u5fdc\u3068\u5c0f\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002",
pinch: "\u30c9\u30e9\u30c3\u30b0\u3067\u500b\u4f53\u3084\u9053\u5177\u3092\u3064\u307e\u3080\u3002",
new: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002",
zunchi: "\u6c5a\u308c\u3068\u611f\u67d3\u6e90\u3002\u305a\u3093\u3061\u75c5\u306e\u539f\u56e0\u306b\u306a\u308b\u3002",
sweet: "\u305a\u3093\u3060\u9905\u3002\u6c17\u5206\u3092\u6574\u3048\u3001\u7206\u767a\u75c5\u3068\u55a7\u5629\u50b7\u75c5\u3092\u6cbb\u3059\u3002",
love_mochi: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002",
fight_mochi: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u5a01\u5687\u3068\u55a7\u5629\u306e\u5236\u9650\u3092\u5f31\u3081\u308b\u3002",
water: "\u98f2\u3080\u3068\u6c17\u5206\u56de\u5fa9\u3002\u305a\u3093\u3061\u75c5\u3068\u7206\u767a\u75c5\u3092\u6cbb\u3059\u3002",
water_bowl: "\u6d88\u3048\u306a\u3044\u6c34\u5834\u3002\u6c34\u3092\u63a2\u3059\u500b\u4f53\u306e\u6cbb\u7642\u5834\u6240\u306b\u306a\u308b\u3002",
sleep_drug: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002",
grass: "\u98df\u3079\u7269\u3002\u5468\u56f2\u306e\u30b9\u30c8\u30ec\u30b9\u3082\u5c11\u3057\u4e0b\u3052\u308b\u3002",
stone: "\u843d\u4e0b\u3059\u308b\u3068\u4e8b\u6545\u30c0\u30e1\u30fc\u30b8\u3002\u7f6e\u304b\u308c\u305f\u5f8c\u306f\u4f11\u307f\u5834\u306b\u3082\u306a\u308b\u3002",
bed: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002",
nest_box: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002",
water_hose: "\u30c9\u30e9\u30c3\u30b0\u3067\u305a\u3093\u3061\u3084\u8840\u75d5\u3092\u6d17\u3044\u6d41\u3057\u3001\u305f\u308a\u306a\u3044\u3082\u6c34\u3067\u6d41\u3059\u3002",
ball: "\u8ee2\u304c\u3063\u3066\u62bc\u3059\u3002\u4e8b\u6545\u306e\u539f\u56e0\u306b\u3082\u306a\u308b\u3002",
firecracker: "\u7206\u767a\u3067\u5468\u56f2\u3092\u5439\u304d\u98db\u3070\u3059\u3002\u7206\u767a\u75c5\u306e\u539f\u56e0\u306b\u306a\u308b\u3002",
fence_v: "\u7e26\u5411\u304d\u306e\u79fb\u52d5\u5236\u9650\u3002\u5bc6\u96c6\u7f6e\u304d\u306f\u5236\u9650\u3055\u308c\u308b\u3002",
fence_h: "\u6a2a\u5411\u304d\u306e\u79fb\u52d5\u5236\u9650\u3002\u5bc6\u96c6\u7f6e\u304d\u306f\u5236\u9650\u3055\u308c\u308b\u3002"
},
ecologyCards: [
{
"title": "\u305f\u308a\u306a\u3044\u3068\u306f",
"image": "assets/sprites/tarinai_01_smile.png",
"text": "\u5ead\u3067\u52dd\u624b\u306b\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u304a\u306a\u304b\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\u308b\u3002"
},
{
"title": "\u66ae\u3089\u3057\u3068\u6b32\u6c42",
"image": "assets/sprites/tarinai_05_drool.png",
"text": "\u98df\u3079\u7269\u3001\u6c34\u3001\u5bdd\u5e8a\u3001\u5de3\u7bb1\u3001\u4ef2\u9593\u3092\u63a2\u3057\u3066\u52d5\u304f\u3002\u96e8\u3084\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u65e5\u3005\u306e\u72b6\u614b\u306b\u5f71\u97ff\u3059\u308b\u3002"
},
{
"title": "\u6027\u683c\u3068\u500b\u6027",
"image": "assets/sprites/tarinai_21_normal_happy.png",
"text": "\u81c6\u75c5\u3001\u98df\u3044\u3057\u3093\u574a\u3001\u7720\u305f\u304c\u308a\u3001\u5bc2\u3057\u304c\u308a\u306a\u3069\u306e\u6027\u683c\u304c\u3042\u308b\u3002\u6027\u683c\u306f\u6050\u6016\u3001\u55a7\u5629\u3001\u7761\u7720\u3001\u4ef2\u9593\u3078\u306e\u5bc4\u308a\u65b9\u306b\u73fe\u308c\u308b\u3002"
},
{
"title": "\u5bb6\u65cf\u3068\u95a2\u4fc2",
"image": "assets/sprites/tarinai_27_birth_ritual.png",
"text": "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u4ef2\u826f\u3057\u3001\u82e6\u624b\u3001\u6050\u6016\u3092\u8a18\u9332\u3059\u308b\u3002\u5b50\u306f\u89aa\u306e\u7247\u65b9\u306e\u6027\u683c\u3092\u7d99\u304e\u3001\u5bb6\u7cfb\u56f3\u306b\u4e16\u4ee3\u304c\u6b8b\u308b\u3002"
},
{
"title": "\u75c5\u6c17\u3068\u6cbb\u7642",
"image": "assets/sprites/tarinai_24_stress_dizzy.png",
"text": "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001\u55a7\u5629\u50b7\u75c5\u304c\u3042\u308b\u3002\u6c34\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u6c34\u3092\u3001\u305a\u3093\u3060\u9905\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3059\u308b\u3002"
},
{
"title": "\u55a7\u5629\u3068\u4e8b\u6545",
"image": "assets/sprites/tarinai_26_intimidate.png",
"text": "\u5a01\u5687\u3001\u6d41\u8840\u3001\u7206\u7af9\u3001\u30dc\u30fc\u30eb\u3001\u843d\u4e0b\u7269\u3067\u50b7\u3064\u304f\u3002\u6b7b\u56e0\u306f\u55a7\u5629\u3001\u98e2\u9913\u3001\u75c5\u6c17\u3001\u30b9\u30c8\u30ec\u30b9\u904e\u591a\u3001\u7206\u7af9\u3001\u4e8b\u6545\u306a\u3069\u306b\u5206\u304b\u308c\u308b\u3002"
},
{
"title": "\u9053\u5177\u3068\u4ecb\u5165",
"image": "assets/sprites/tarinai_18_fear.png",
"text": "\u3064\u3064\u304f\u3001\u3064\u307e\u3080\u3001\u9905\u3001\u6c34\u3001\u6d17\u6d44\u3001\u5de3\u7bb1\u3001\u67f5\u3001\u7206\u7af9\u306a\u3069\u3067\u74b0\u5883\u3092\u4f5c\u308b\u3002\u9053\u5177\u306f\u52a9\u3051\u306b\u3082\u5371\u967a\u306b\u3082\u306a\u308b\u3002"
},
{
"title": "\u89b3\u5bdf\u306e\u65b9\u6cd5",
"image": "assets/sprites/tarinai_11_weak.png",
"text": "\u305f\u308a\u306a\u3044\u9054\u306e\u72b6\u6cc1\u3092\u89b3\u5bdf\u30c4\u30fc\u30eb\u3001\u661f\u30de\u30fc\u30af\u3001\u89b3\u5bdf\u30ed\u30b0\u3001\u5bb6\u7cfb\u56f3\u3001\u6b7b\u56e0\u7b49\u304b\u3089\u77e5\u308b\u3053\u3068\u304c\u51fa\u6765\u308b\u3002"
}
]
};
window.TEXT_CATALOG = TEXT_CATALOG;

127
js/ui.js
View file

@ -29,6 +29,7 @@ const ui = {
fieldDialog: document.getElementById("fieldDialog"),
fieldCancelBtn: document.getElementById("fieldCancelBtn"),
ecologyDialog: document.getElementById("ecologyDialog"),
ecologyGrid: document.getElementById("ecologyGrid"),
ecologyCloseBtn: document.getElementById("ecologyCloseBtn"),
};
@ -57,6 +58,7 @@ const uiCache = {
chartBucketStart: -Infinity,
chartBucketEnd: -Infinity,
chartBucketAccum: null,
environmentInitialRow: null,
lastChartSample: -Infinity,
lastChartDraw: "",
lastChartDrawAt: 0,
@ -150,7 +152,9 @@ function renderSelected() {
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord,
t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0),
t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0)
t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0),
t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0,
(t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~")
].join("|");
if (uiCache.selectedSnapshot === snapshot) return;
uiCache.selectedSnapshot = snapshot;
@ -159,9 +163,14 @@ function renderSelected() {
const effectBadges = [];
effectBadges.push(`<span class="badge">\u6027\u683c ${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</span>`);
if (t.isZunchiSlave) effectBadges.push(`<span class="badge">\u305a\u3093\u3061\u3069\u308c\u3044</span>`);
if (t.favorite) effectBadges.push(`<span class="badge">\u2605</span>`);
if (t.zunchiDisease) effectBadges.push(`<span class="badge">\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}</span>`);
if (t.sleepDisease) effectBadges.push(`<span class="badge">\u306d\u3080\u308a\u75c5</span>`);
if (t.explosionDisease) effectBadges.push(`<span class="badge">\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}</span>`);
if (t.fightDisease) effectBadges.push(`<span class="badge">\u55a7\u5629\u50b7\u75c5</span>`);
if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}</span>`);
if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}</span>`);
const recordHtml = (t.records || []).slice(0, 8).map(r => `<li><span>${escapeHtml(world.clockString ? world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0) : formatRecordTime(r.time || 0))}</span><strong>${escapeHtml(r.text || "")}</strong></li>`).join("") || `<li><span>-</span><strong>\u8a18\u9332\u306a\u3057</strong></li>`;
ui.selectedInfo.innerHTML = `
<div><span class="badge">${escapeHtml(t.label())}</span><span class="badge">${escapeHtml(t.name)}</span>${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `<span class="badge">\u4e16\u4ee3 ${t.generation}</span>` : ""}${effectBadges.join("")}</div>
<div class="info-row"><span>\u6027\u683c</span><strong>${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</strong></div>
@ -182,9 +191,18 @@ function renderSelected() {
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
<div class="info-row"><span>\u305f\u308a\u306a\u3044</span><strong>${fmt(t.lack)}</strong></div>
<div class="selected-records"><h3>\u500b\u4f53\u5225\u306e\u8a18\u9332</h3><ol>${recordHtml}</ol></div>
<div class="selected-actions"><button type="button" class="favorite-toggle ${t.favorite ? "active" : ""}" data-selected-action="favorite">${t.favorite ? "\u2605 \u304a\u6c17\u306b\u5165\u308a\u89e3\u9664" : "\u2606 \u304a\u6c17\u306b\u5165\u308a"}</button></div>
`;
}
function formatRecordTime(time) {
const totalMinutes = Math.floor(((time || 0) % (CONFIG.dayLength || 120)) / (CONFIG.dayLength || 120) * 24 * 60);
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
const mm = String(totalMinutes % 60).padStart(2, "0");
return `${hh}:${mm}`;
}
function stateLabel(s) {
return {
idle: "\u5f85\u6a5f",
@ -193,6 +211,7 @@ function stateLabel(s) {
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
seek_water: "\u6c34\u3092\u63a2\u3059",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u55a7\u5629\u50b7\u75c5",
system: "\u305a\u3093\u3061\u75c5",
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
@ -216,7 +235,8 @@ function reasonLabel(t) {
if (t.state === "intimidate") return "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b";
if (t.state === "seek_water") return "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "fight_sick") return "\u55a7\u5629\u50b7\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5";
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
}
@ -224,7 +244,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", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\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", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u55a7\u5629\u50b7\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";
}
@ -327,7 +347,54 @@ function hydrateLazyImages(root) {
}
}
function applyToolTips() {
const tips = window.TEXT_CATALOG?.toolTips || {};
if (!ui.toolPalette) return;
let pop = document.getElementById("toolTipPopover");
if (!pop) {
pop = document.createElement("div");
pop.id = "toolTipPopover";
pop.className = "tool-tip-popover hidden";
document.body.appendChild(pop);
}
const showTip = (btn) => {
const tip = btn?.dataset?.tip || "";
if (!tip) return;
pop.textContent = tip;
pop.classList.remove("hidden");
const rect = btn.getBoundingClientRect();
const maxLeft = Math.max(8, window.innerWidth - 236);
pop.style.left = `${Math.min(maxLeft, Math.max(8, rect.left + rect.width / 2 - 110))}px`;
pop.style.top = `${Math.max(8, rect.top - pop.offsetHeight - 10)}px`;
};
const hideTip = () => pop.classList.add("hidden");
for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) {
const tip = tips[btn.dataset.tool];
if (!tip) continue;
btn.dataset.tip = tip;
if (btn.dataset.tipBound === "1") continue;
btn.dataset.tipBound = "1";
btn.addEventListener("mouseenter", () => showTip(btn));
btn.addEventListener("focus", () => showTip(btn));
btn.addEventListener("mouseleave", hideTip);
btn.addEventListener("blur", hideTip);
}
}
function renderEcologyCards() {
if (!ui.ecologyGrid || ui.ecologyGrid.dataset.ready === "1") return;
const cards = window.TEXT_CATALOG?.ecologyCards || [];
ui.ecologyGrid.innerHTML = cards.map(card => `
<section class="ecology-card">
<img data-src="${escapeHtml(card.image || "assets/sprites/tarinai_01_smile.png")}" loading="lazy" decoding="async" alt="">
<div><strong>${escapeHtml(card.title || "")}</strong><p>${escapeHtml(card.text || "")}</p></div>
</section>`).join("");
ui.ecologyGrid.dataset.ready = "1";
}
function openEcologyDialog() {
renderEcologyCards();
hydrateLazyImages(ui.ecologyDialog);
ui.ecologyDialog?.classList.remove("hidden");
}
@ -411,7 +478,7 @@ function syncTopButtons() {
if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF";
}
const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "grass", "stone", "firecracker", "fence_v", "fence_h"]);
const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "stone", "firecracker", "fence_v", "fence_h"]);
const SIZE_ORDER = ["small", "medium", "large"];
const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
@ -455,7 +522,9 @@ function syncToolSizeBadges() {
}
function bindUI() {
uiCache.toolButtons = Array.from(ui.toolPalette.querySelectorAll(".tool"));
applyToolTips();
renderEcologyCards();
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
syncToolSizeBadges();
ui.colonyChartTabs?.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-chart]");
@ -496,6 +565,16 @@ function bindUI() {
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
});
ui.selectedInfo?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-selected-action=\"favorite\"]");
if (!btn || !world.selected || world.selected.dead) return;
world.selected.favorite = !world.selected.favorite;
uiCache.selectedSnapshot = "";
renderSelected();
render();
showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002");
});
ui.pauseBtn?.addEventListener("click", () => {
world.paused = !world.paused;
const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0);
@ -599,6 +678,20 @@ function bindUI() {
canvas?.addEventListener("contextmenu", (e) => e.preventDefault());
canvas?.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "water_hose") {
const p = screenToWorld(e);
if (!p.inside) return;
e.preventDefault();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = e.clientX;
uiCache.hoseStartY = e.clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
render();
return;
}
if (e.button === 0 && world.tool === "pinch") {
const p = screenToWorld(e);
if (!p.inside) return;
@ -641,6 +734,13 @@ function bindUI() {
uiCache.panLastY = e.clientY;
});
window.addEventListener("mouseup", (e) => {
if (uiCache.hosing) {
uiCache.hosing = false;
canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")));
world.rebuildSpatial?.();
renderStats();
return;
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (target) {
@ -669,6 +769,19 @@ function bindUI() {
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
});
window.addEventListener("mousemove", (e) => {
if (uiCache.hosing) {
const p = screenToWorld(e);
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY));
if (totalMove > 3) uiCache.hoseMoved = true;
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
canvas.style.cursor = "crosshair";
render();
return;
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
@ -722,6 +835,10 @@ function bindUI() {
});
canvas?.addEventListener("click", (e) => {
if (uiCache.hoseMoved) {
uiCache.hoseMoved = false;
return;
}
if (uiCache.grabMoved) {
uiCache.grabMoved = false;
return;

View file

@ -3,6 +3,7 @@
function renderStats() {
const alive = world.tarinai;
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
const environment = world.environmentScores ? world.environmentScores() : { security: 100, hygiene: 100, happiness: 100 };
const values = {
pop: alive.length,
dead: world.deadCount,
@ -10,7 +11,9 @@ function renderStats() {
lonely: avg(t => t.loneliness),
stress: avg(t => t.stress),
lack: avg(t => t.lack),
events: { ...(world.eventCounters || {}) },
security: environment.security,
hygiene: environment.hygiene,
happiness: environment.happiness,
objects: { ...(world.itemCounts || {}) },
};
setTextIfChanged(ui.statPop, "pop", values.pop);
@ -35,18 +38,16 @@ function updateColonyHistory(values) {
uiCache.chartBucketStart = -Infinity;
uiCache.chartBucketEnd = -Infinity;
uiCache.chartBucketAccum = null;
uiCache.environmentInitialRow = null;
uiCache.lastChartDraw = "";
}
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
const objectKeys = ["grass", "zunchi"];
const makeBucket = (start) => ({
start,
end: start + interval,
count: 0,
sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0 },
sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0, security: 0, hygiene: 0, happiness: 0 },
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
eventsStart: Object.fromEntries(eventKeys.map(k => [k, (values.events || {})[k] || 0])),
events: Object.fromEntries(eventKeys.map(k => [k, 0])),
});
if (!uiCache.chartBucketAccum) {
const start = Math.floor(t / interval) * interval;
@ -63,15 +64,16 @@ function updateColonyHistory(values) {
lonely: bucket.sums.lonely / bucket.count,
stress: bucket.sums.stress / bucket.count,
lack: bucket.sums.lack / bucket.count,
security: bucket.sums.security / bucket.count,
hygiene: bucket.sums.hygiene / bucket.count,
happiness: bucket.sums.happiness / bucket.count,
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
};
history.push(row);
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
};
while (t >= uiCache.chartBucketEnd) {
const b = uiCache.chartBucketAccum;
for (const k of eventKeys) b.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (b.eventsStart[k] || 0));
pushBucket(b);
uiCache.chartBucketStart = uiCache.chartBucketEnd;
uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
@ -84,13 +86,14 @@ function updateColonyHistory(values) {
bucket.sums.lonely += values.lonely || 0;
bucket.sums.stress += values.stress || 0;
bucket.sums.lack += values.lack || 0;
bucket.sums.security += values.security || 0;
bucket.sums.hygiene += values.hygiene || 0;
bucket.sums.happiness += values.happiness || 0;
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
for (const k of eventKeys) bucket.events[k] = Math.max(0, ((values.events || {})[k] || 0) - (bucket.eventsStart[k] || 0));
}
function chartRows(values) {
const bucket = uiCache.chartBucketAccum;
const eventKeys = ["birth", "death", "fight", "accident", "food", "grass"];
const objectKeys = ["grass", "zunchi"];
const preview = bucket && bucket.count > 0 ? {
t: Math.max(world.time || 0, bucket.start || 0),
@ -99,8 +102,10 @@ function chartRows(values) {
lonely: bucket.sums.lonely / bucket.count,
stress: bucket.sums.stress / bucket.count,
lack: bucket.sums.lack / bucket.count,
security: bucket.sums.security / bucket.count,
hygiene: bucket.sums.hygiene / bucket.count,
happiness: bucket.sums.happiness / bucket.count,
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
...Object.fromEntries(eventKeys.map(k => [`ev_${k}`, bucket.events[k] || 0])),
} : null;
return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }];
}
@ -112,8 +117,13 @@ function drawColonyChart(values) {
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0;
if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return;
const rows = chartRows(values);
const mode = uiCache.chartMode || "population";
if (mode === "environment" && !uiCache.chartHistory.length && !uiCache.environmentInitialRow) {
uiCache.environmentInitialRow = { t: world.time || 0, ...values };
}
const rows = mode === "environment"
? (uiCache.chartHistory.length ? uiCache.chartHistory : [uiCache.environmentInitialRow || { t: world.time || 0, ...values }])
: chartRows(values);
const last = rows[rows.length - 1] || values;
const series = chartSeries(mode);
const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
@ -123,7 +133,7 @@ function drawColonyChart(values) {
uiCache.lastChartDrawAt = now;
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
const cssH = Math.max(160, Math.floor(chart.clientHeight || chart.offsetHeight || 188));
if (mode === "events") return drawEventsChart(chart, rows, cssW, cssH);
if (mode === "environment") return drawEnvironmentChart(chart, rows, cssW, cssH);
const pad = { l: 30, r: 12, t: 12, b: 24 };
const w = cssW - pad.l - pad.r;
const h = cssH - pad.t - pad.b;
@ -150,22 +160,20 @@ function drawColonyChart(values) {
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span>`).join("");
}
function drawEventsChart(chart, rows, cssW, cssH) {
const series = chartSeries("events");
const weekRows = rows.slice(-7 * 12);
const counts = series.map(s => ({ ...s, value: weekRows.reduce((sum, row) => sum + Math.max(0, Number(row[s.key] || 0)), 0) }));
const total = Math.max(1, counts.reduce((a, b) => a + b.value, 0));
const pad = { l: 68, r: 16, t: 12, b: 16 };
function drawEnvironmentChart(chart, rows, cssW, cssH) {
const series = chartSeries("environment");
const row = rows[rows.length - 1] || {};
const pad = { l: 58, r: 18, t: 16, b: 18 };
const barW = cssW - pad.l - pad.r;
const rowH = Math.max(18, (cssH - pad.t - pad.b) / counts.length);
const bars = counts.map((s, i) => {
const y = pad.t + i * rowH + 4;
const pct = s.value / total;
const bw = Math.max(2, barW * pct);
return `<text x="${pad.l - 8}" y="${(y + 11).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(84,68,48,0.76)">${s.label}</text><rect x="${pad.l}" y="${y.toFixed(1)}" width="${barW}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="rgba(84,68,48,0.08)"/><rect x="${pad.l}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(8, rowH - 8).toFixed(1)}" rx="6" fill="${s.color}"/><text x="${(pad.l + Math.min(barW - 4, bw + 6)).toFixed(1)}" y="${(y + 11).toFixed(1)}" font-size="10" fill="rgba(42,36,29,0.82)">${s.value}\u4ef6 / ${Math.round(pct * 100)}%</text>`;
const rowH = Math.max(30, (cssH - pad.t - pad.b) / series.length);
const bars = series.map((s, i) => {
const y = pad.t + i * rowH + 6;
const v = clamp(Number(row[s.key] || 0), 0, 100);
const bw = barW * v / 100;
return `<text x="${pad.l - 8}" y="${(y + 13).toFixed(1)}" text-anchor="end" font-size="11" fill="rgba(84,68,48,0.80)">${s.label}</text><rect x="${pad.l}" y="${y.toFixed(1)}" width="${barW}" height="${Math.max(12, rowH - 14).toFixed(1)}" rx="7" fill="rgba(84,68,48,0.08)"/><rect x="${pad.l}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(12, rowH - 14).toFixed(1)}" rx="7" fill="${s.color}"/><text x="${(pad.l + Math.min(barW - 26, Math.max(6, bw + 7))).toFixed(1)}" y="${(y + 13).toFixed(1)}" font-size="11" fill="rgba(42,36,29,0.82)">${Math.round(v)}</text>`;
}).join("");
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${bars}</svg>`;
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = `<span>1\u9031\u9593\u306e\u5272\u5408 / \u4ef6\u6570</span>`;
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${bars}<text x="${pad.l}" y="${cssH - 5}" font-size="10" fill="rgba(117,106,94,0.76)">0-100</text></svg>`;
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, row)}</span>`).join("");
}
function chartSeries(mode = "population") {
@ -179,13 +187,10 @@ function chartSeries(mode = "population") {
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
];
if (mode === "events") return [
{ key: "ev_birth", label: "\u8a95\u751f", color: "#73b94b" },
{ key: "ev_death", label: "\u6b7b\u4ea1", color: "#5b534b" },
{ key: "ev_fight", label: "\u55a7\u5629", color: "#d65e46" },
{ key: "ev_accident", label: "\u4e8b\u6545", color: "#e28836" },
{ key: "ev_food", label: "\u98df\u4e8b", color: "#76b94d" },
{ key: "ev_grass", label: "\u8349", color: "#489443" },
if (mode === "environment") return [
{ key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true },
{ key: "hygiene", label: "\u885b\u751f", color: "#6f8a3f", percent: true },
{ key: "happiness", label: "\u5e78\u798f", color: "#d98bc0", percent: true },
];
return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
}

View file

@ -274,12 +274,12 @@ class World {
}
toolSizeFor(type = "") {
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return "medium";
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return "medium";
return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
}
toolSizeScale(type = "") {
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return 1;
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return 1;
return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1;
}
@ -290,7 +290,7 @@ class World {
item.toolSize = size;
this.toolSize = size;
item.r *= scale;
if (["sweet", "love_mochi", "fight_mochi", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
if (item.type === "firecracker") item.blastScale = scale;
return item;
}
@ -905,6 +905,7 @@ class World {
for (let r = 0; r < 4; r++) {
this.effects.push(new Effect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" }));
}
this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1);
for (let i = 0; i < 34; i++) {
const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10);
const speed = rand(90, 260);
@ -950,6 +951,7 @@ class World {
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70);
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p);
if (!t.dead && p > 0.22 && Math.random() < 0.035 * p) t.infectExplosionDisease?.(it);
if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
}
let blastedBalls = 0;
@ -983,6 +985,79 @@ class World {
this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident");
}
explodeDiseaseTarinai(t) {
if (!t || t.dead) return;
const x = t.x, y = t.y;
const blastRadius = 190;
t.explosionDisease = false;
t.explosionDiseaseTimer = 0;
this.effects.push(new Effect("explosion", x, y, { size: 72, life: 0.82, color: "rgba(255,176,68,0.88)" }));
this.effects.push(new Effect("ring", x, y, { size: 58, life: 0.52, color: "rgba(255,238,132,0.82)" }));
this.blastZunchiFrom(x, y, blastRadius * 0.92, 0.82);
for (const o of this.tarinai) {
if (!o || o.dead || o === t) continue;
const dx = o.x - x;
const dy = o.y - y;
const d = Math.hypot(dx, dy) || 1;
if (d > blastRadius) continue;
const q = clamp(1 - d / blastRadius, 0, 1);
o.damage(4 + q * 15, "\u7206\u767a\u75c5");
o.addStress ? o.addStress(16 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 16 * q, 0, 130));
o.vx += dx / d * (120 + q * 260) + rand(-34, 34);
o.vy += dy / d * (120 + q * 260) + rand(-34, 34);
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.2 + q);
o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q);
if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t);
}
t.die("\u7206\u767a\u75c5");
audio.explode();
this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident");
}
applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) {
const radius = 96;
const speed = Math.hypot(dx, dy) || 1;
const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1);
const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7);
let cleaned = 0;
for (const it of this.nearbyItems(x, y, radius + 80)) {
if (!it || it.dead) continue;
const d = distXY(it.x, it.y, x, y);
if (d > radius + (it.r || 12)) continue;
const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1);
if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") {
it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p);
cleaned += p;
}
}
for (const ef of this.effects || []) {
if (!ef || ef.dead) continue;
if (ef.type !== "bleed" && ef.type !== "splat") continue;
const d = distXY(ef.x || 0, ef.y || 0, x, y);
if (d > radius + 22) continue;
const p = clamp(1 - d / (radius + 22), 0, 1);
ef.life = Math.min(ef.life || 0.1, Math.max(0.01, (ef.life || 0.1) - dt * (1.6 + p * 5.0)));
cleaned += p * 0.55;
}
for (const t of this.nearbyTarinai(x, y, radius + 70)) {
if (!t || t.dead) continue;
const d = distXY(t.x, t.y, x, y);
if (d > radius + t.radius) continue;
const p = clamp(1 - d / (radius + t.radius), 0, 1);
t.vx += nx * (95 + 165 * p) + rand(-10, 10);
t.vy += ny * (95 + 165 * p) + rand(-10, 10);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24);
t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose");
}
if (Math.random() < 0.72) {
this.effects.push(new Effect("ring", x + rand(-18, 18), y + rand(-12, 12), { size: rand(8, 22), life: rand(0.18, 0.32), color: "rgba(128,204,238,0.60)" }));
}
if (cleaned > 0.25) {
this.drawListDirty = true;
this.updateItemCounts();
}
}
spawnZunchi(x, y) {
const zunchiLimit = CONFIG.zunchiLimit ?? 56;
if ((this.itemCounts.zunchi || 0) >= zunchiLimit) {
@ -1061,7 +1136,7 @@ class World {
}
isSleepFurniture(it) {
return Boolean(it && it.type === "bed");
return Boolean(it && (it.type === "bed" || it.type === "nest_box"));
}
bedOccupancy(bed) {
@ -1672,6 +1747,84 @@ class World {
this.tarinai.length = write;
}
environmentScores() {
const alive = this.tarinai || [];
const avgStress = alive.length ? alive.reduce((sum, t) => sum + (t.stress || 0), 0) / alive.length : 0;
let fightPressure = 0;
let diseaseCount = 0;
for (const t of alive) {
if (!t || t.dead) continue;
if (t.state === "fight") fightPressure += 16;
if (t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04 || (t.intimidatedTimer || 0) > 0.04) fightPressure += 7;
if ((t.hurtTimer || 0) > 0.04 || (t.lastBleedAt || -999) + 30 > (this.time || 0)) fightPressure += 5;
if (t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) diseaseCount += 1;
}
let blood = 0;
for (const ef of this.effects || []) if (ef && !ef.dead && (ef.type === "bleed" || ef.type === "splat")) blood += 1;
const zunchi = this.itemCounts?.zunchi || 0;
const splat = this.itemCounts?.splat || 0;
const trace = this.itemCounts?.trace || 0;
const security = clamp(100 - fightPressure - blood * 1.6, 0, 100);
const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - blood * 1.8 - diseaseCount * 8.0, 0, 100);
const happiness = clamp(100 - Math.max(0, 100 - security) * 0.32 - Math.max(0, 100 - hygiene) * 0.24 - avgStress * 0.42, 0, 100);
return { security, hygiene, happiness };
}
observeHighlightKind(t) {
const s = this.selected;
if (!s || !t || t === s || t.dead) return "";
if ((s.parents || []).includes(t.id)) return "parent";
if ((s.children || []).includes(t.id)) return "child";
const rel = s.relationTo ? s.relationTo(t.id) : null;
if (rel && (rel.fear || 0) >= 8) return "enemy";
if (rel && (rel.affinity || 0) >= 10) return "friend";
return "";
}
nestBoxOccupants(box) {
if (!box || box.dead || box.type !== "nest_box") return [];
return this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id).slice(0, 12);
}
pointerNestBoxInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "nest_box") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
}
if (!best) return null;
const occupants = this.nestBoxOccupants(best);
return { box: best, occupants };
}
blastZunchiFrom(x, y, radius, strength = 1) {
let moved = 0;
for (const it of this.nearbyItems(x, y, radius + 60)) {
if (!it || it.dead || it.type !== "zunchi") continue;
let dx = it.x - x;
let dy = it.y - y;
let d = Math.hypot(dx, dy) || 1;
if (d > radius) continue;
if (d < 0.001) {
const a = rand(0, Math.PI * 2);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
const p = clamp(1 - d / radius, 0, 1);
it.vx = (it.vx || 0) + dx / d * (130 + p * 430) * strength + rand(-45, 45);
it.vy = (it.vy || 0) + dy / d * (130 + p * 430) * strength + rand(-45, 45);
it.amount = Math.max(10, (it.amount || 80) - p * 8);
it.stage = "fresh";
moved += 1;
if (moved < 12) this.effects.push(new Effect("zunchi_miasma", it.x, it.y - 4, { vx: (it.vx || 0) * 0.15, vy: (it.vy || 0) * 0.15 - 18, size: rand(9, 18), life: rand(0.36, 0.62), color: "rgba(58,102,38,0.48)" }));
}
if (moved) this.drawListDirty = true;
return moved;
}
update(dt) {
if (this.paused) return;
dt *= this.speed;
@ -1753,9 +1906,9 @@ class World {
}
}
if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 7 && Math.random() < dt * 0.035) {
if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 24 && Math.random() < dt * 0.12) {
const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58));
drop.amount = rand(18, 40);
drop.amount = rand(24, 58);
this.items.push(drop);
this.itemCounts.water = (this.itemCounts.water || 0) + 1;
}
@ -1912,14 +2065,14 @@ class World {
this.compactItems();
this.updateItemCounts();
this.rebuildSpatial();
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return;
}
const itemType = {
zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", water: "water", grass: "grass",
stone: "stone", bed: "bed", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", sleep_drug: "sleep_drug", water: "water", water_bowl: "water_bowl", grass: "grass",
stone: "stone", bed: "bed", nest_box: "nest_box", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
}[tool];
if (itemType) {
let px = x, py = y;
@ -1945,7 +2098,7 @@ class World {
}
const placed = this.placeItem(rawItem, true);
if (!placed) return;
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
}
}
@ -1968,6 +2121,10 @@ class World {
const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) };
if (!this.eventCounters) this.eventCounters = {};
this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
for (const t of this.tarinai || []) {
if (!t || !t.name || !text || !String(text).includes(t.name)) continue;
if (t.addRecord) t.addRecord(text, entry.kind);
}
this.logs.unshift(entry);
this.logs = this.logs.slice(0, 100);
renderLog(this.logs);