Compare commits

...

5 commits

Author SHA1 Message Date
434f07cc4e not bad 2026-06-25 14:51:58 +09:00
2741b93dea lag r 2026-06-24 21:20:53 +09:00
d875df8600 hmm? 2026-06-24 18:49:17 +09:00
9ef1b8dbb1 hmm 2026-06-24 17:39:44 +09:00
d5c661bc22 stable 2026-06-24 13:48:22 +09:00
109 changed files with 11034 additions and 6918 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.tmp-edge-profile/
__pycache__/
*.pyc
# Local browser smoke/probe artifacts.
*probe*.html
*probe*.png
*smoke*.html
*smoke*.png

View file

@ -1 +0,0 @@
level=none expiry=0

View file

@ -1 +0,0 @@
{"user_experience_metrics.stability.exited_cleanly":false,"variations_crash_streak":0}

View file

@ -1,17 +0,0 @@
# Patch Notes 15.24.12
- Added `activeBehavior` as the authoritative runtime behavior layer above legacy state timers.
- Added `forcedBehaviorQueue` and `forceBehavior()` hook for external forced actions such as love mochi, fight mochi, and drugs.
- Re-routed love mochi, fight mochi, and sleep drug effects through forced need actions.
- Converted eating, panic, fighting, intimidation, and birth ritual display/scheduling to behavior-backed actions.
- Split social need pressure internally into bond/family/mate/conflict/fear parts and used those parts in social action selection.
- Weighted fulfill action selection by its internal reason parts.
- Reduced direct old-style birth starts: proximity now raises mate pressure; ritual starts when mate behavior is active or forced.
- UI action text now prefers `activeBehavior` over stale thoughts or intent strings.
## 15.24.14
- 外部強制行動を強化。薬・餅から `forceBehavior()` 経由で優先割込み、対象再探索、失敗時再試行を行うようにした。
- へこ餅、けんか餅、ねむり薬の強制行動優先度を更新。
- 喧嘩対象が Item 等になった場合の `relationTo is not a function` エラーを防止。
- 睡眠欲求を概日圧 `circadianSleepPressure` 中心に変更。夜は大きく上昇、昼下がりは微増、それ以外は減衰。
- 初期生成時、夜なら睡眠欲求が高めになるよう初期エネルギーと概日圧を調整。

View file

@ -1,51 +0,0 @@
# Tarinai Colony Observation Game
Buildless single-page browser simulation. Open `index.html`; no package manager, transpiler, server API, or module loader is required. Runtime order is explicit `<script defer>` order generated from `app_manifest.json`. Current inspected version: `15.20.16`.
## Edit contract
- Preserve Japanese in-game UI tone and strings.
- Do not edit `docs/prompt.txt`; it is external user material.
- Use `app_manifest.json` as the source of truth for CSS/JS load order and service-worker cache entries.
- Run `python3 scripts/generate_app_files.py` after version or asset-list changes.
- Run `node --check js/*.js service-worker.js` before zipping.
- Verify ZIP integrity with `zip -T`.
## Documentation map
- `docs/ARCHITECTURE.md`: runtime architecture, file ownership, system boundaries.
- `docs/CONTENT_GUIDE.md`: item visuals, UI/field visual parity, audio, bubble text inventory.
- `docs/MAINTENANCE.md`: release process, validation, debugging, cache/version rules.
- `docs/prompt.txt`: untouched user prompt/context archive.
## Player-facing surface
- Startup uses a loading overlay that eagerly preloads a small sprite set, initializes UI/world state, then defers remaining image work.
- Field choices are cage, garden, and park. `FIELD_TYPES` controls world scale, starting population, initial grass count, and responsive field height.
- The default field is garden. Reset opens the field dialog; cancel keeps the current field.
- Tools are grouped in collapsible categories: operation/observation, food/water/grass, medicine/effects, habitat/living things, hazards/obstacles.
- The top bar owns pause, speed, ecology, sound, credits, and reset controls. Event push toasts live inside the Event card, while sound category toggles live only in the sound menu.
- The colony panel charts population, mental state, environment, and object counts. The family tree is a separate main section with manual/auto refresh and reset controls.
- Mobile operation modes are available only on compact touch-first phone viewports: auto, camera, tool, family.
## Core systems
- `World`: `js/world.js` plus `js/world_*.js`; time, weather, placement, effects, family records, spatial queries, ants, combat, logging.
- `Tarinai`: `js/tarinai.js` plus `js/tarinai_*.js`; identity, personality, needs, items, disease, movement, social behavior, rendering.
- `Item`: `js/items.js`; lifecycle, scaling, field rendering. Field rendering is the visual source of truth for tool icons.
- `ItemVisualRegistry`: `js/item_visual_registry.js`; canonical item visuals used by field sprites and generated icon previews.
- `EffectRegistry`: `js/effect_registry.js`; item-effect names, modifiers, exclusivity, display metadata, random-effect candidates.
- `DiseaseRegistry`: `js/disease_registry.js`; disease flags, labels, display metadata, cure-rule notes, mystery-drug eligibility.
- `FoodRegistry`: `js/food_registry.js`; serving classes, labels, decay, hygiene penalty, special effect IDs.
- `InputModeManager`: `js/input_mode_manager.js`; mobile/touch classification, gesture capability policy, mode persistence.
- `WeatherSystem`: `js/weather_system.js`; weather state transitions and rain-spawned water items.
- `Audio`: `js/sound_pack.js` owns sound metadata; `js/audio.js` loads samples, applies settings, enforces cooldowns, and runs synthetic fallbacks.
- `Performance`: `js/performance_manager.js`; FPS quality level, ant stride, effect budget, terrain redraw cadence.
## Current high-risk areas
- Item effects touch `effect_registry`, `tarinai_item_effects`, `tarinai_needs_items`, `items`, and selected-info UI.
- Mobile input must remain hidden on desktop-class devices; `TarinaiInputMode.snapshot().touchFirst` controls body class `touch-mobile-ui`.
- Weather rain overlay is diagonal line streaks. Fallen water items, zunda juice, and mercury share the same horizontal oval item-droplet silhouette; recolor only.
- Tool UI icons must be generated previews of `js/items.js` drawing helpers, never separate designs.

View file

@ -1,5 +1,5 @@
{
"version": "15.24.14",
"version": "16.07.09",
"css": [
"css/base.css",
"css/layout.css",
@ -10,29 +10,43 @@
"js": [
"js/version.js",
"js/event_bus.js",
"js/performance_manager.js",
"js/registry_base.js",
"js/disease_registry.js",
"js/effect_registry.js",
"js/sound_pack.js",
"js/item_registry.js",
"js/data.js",
"js/item_visual_registry.js",
"js/food_registry.js",
"js/ground_types.js",
"js/input_mode_manager.js",
"js/math.js",
"js/physics_helpers.js",
"js/assets.js",
"js/audio.js",
"js/perf_profiler.js",
"js/render.js",
"js/sim_core.js",
"js/structures.js",
"js/items.js",
"js/item_type_initializers.js",
"js/item_lifecycle_runtime.js",
"js/structure_lifecycle.js",
"js/item_render_runtime.js",
"js/ants.js",
"js/health.js",
"js/tarinai.js",
"js/tarinai_action_spec.js",
"js/tarinai_behavior_state.js",
"js/tarinai_identity_social.js",
"js/tarinai_action_state.js",
"js/tarinai_disease_nest.js",
"js/tarinai_item_effects.js",
"js/tarinai_needs_core.js",
"js/tarinai_behavior_text.js",
"js/tarinai_forced_behavior.js",
"js/tarinai_item_targeting.js",
"js/tarinai_consumable_behavior.js",
"js/tarinai_social_action_runtime.js",
"js/tarinai_building_behavior.js",
"js/tarinai_action_definitions.js",
"js/tarinai_needs_items.js",
"js/tarinai_social_move_life.js",
"js/tarinai_render.js",
@ -45,8 +59,12 @@
"js/world_spatial_budget.js",
"js/world_ants_system.js",
"js/weather_system.js",
"js/item_update_scheduler.js",
"js/world_update_phases.js",
"js/world_update.js",
"js/world_tool_actions.js",
"js/world_placement_log.js",
"js/world_event_effects.js",
"js/world_bootstrap.js",
"js/text_catalog.js",
"js/ui.js",
@ -54,12 +72,22 @@
"js/ui_helpers.js",
"js/ui_selected.js",
"js/snapshot_system.js",
"js/restore_coordinator.js",
"js/save_codec.js",
"js/save_storage.js",
"js/save_system.js",
"js/patch_helpers.js",
"js/freeze_store.js",
"js/freeze_snapshot_adapter.js",
"js/freeze_panel.js",
"js/freeze_system.js",
"js/relation_event_system.js",
"js/ui_tooltips.js",
"js/ui_layout_dialogs.js",
"js/ui_ground.js",
"js/ui_tools.js",
"js/ui_input_shared.js",
"js/ui_input_touch.js",
"js/ui_input_mouse.js",
"js/ui_bind.js",
"js/ui_charts.js",
"js/ui_family_data.js",
@ -77,5 +105,5 @@
"assets/sounds/*.wav",
"assets/sounds/*.mp3"
],
"notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list."
"notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list. Item definitions are centralized in js/item_registry.js. Save compatibility migration is intentionally not retained. Snapshot restore is data-only; save and freeze are split into coordinator/codec/storage/panel/adapter modules. EventBus owns phase audio and log UI/audio notification side effects. World.update is phase-driven through js/world_update_phases.js; UI field input is split into shared/touch/mouse modules. Item construction, lifecycle, rendering, Tarinai targeting, ActionSpec definitions, consumable behavior, social action ticks, and building behavior are split into dedicated modules. Lightweight cleanup: item type constructor initialization is isolated; action selection bonus logic is shared; diagnostics no longer use removed performance-quality fields. Ground definitions, structure lifecycle, delete-tool actions, shared tooltips, and ground UI helpers are split into dedicated modules. FPS work: profiler, static/dynamic spatial buckets, item update scheduler, visible-cell rendering, chunked terrain cache, adaptive DPR, and render quality LOD are included."
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -216,7 +216,7 @@ h1 {
width: min(440px, 100%);
max-height: calc(100vh - 36px);
overflow: auto;
border-radius: 16px;
border-radius: 12px;
padding: 16px;
background: rgba(255, 252, 245, 0.96);
border: 1px solid rgba(84,68,48,0.16);
@ -309,7 +309,7 @@ h1 {
color: white;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-size: 12px;
pointer-events: none;
opacity: 1;
transition: opacity 200ms ease, transform 200ms ease;
@ -346,59 +346,84 @@ h1 {
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0,1fr));
grid-template-columns: repeat(3, minmax(0,1fr));
gap: 8px;
}
.tool {
border-radius: 14px;
display: flex;
align-items: center;
gap: 7px;
justify-content: flex-start;
padding: 8px 8px 8px 42px;
min-height: 46px;
--tool-main-icon-size: 30px;
--tool-watermark-size: 0px;
border-radius: 12px;
display: grid;
grid-template-rows: 27px minmax(1.05em, auto);
align-items: start;
justify-items: center;
gap: 0;
padding: 3px 5px 4px;
min-height: 36px;
font-size: 12px;
line-height: 1.08;
letter-spacing: -0.015em;
text-align: center;
position: relative;
overflow: hidden;
background-repeat: no-repeat;
background-position: 7px center, center;
background-size: 30px 30px, cover;
background-position: center 4px, center;
background-size: var(--tool-main-icon-size) var(--tool-main-icon-size), cover;
}
.tool::before {
content: "";
position: absolute;
left: 7px;
top: 50%;
width: 30px;
height: 30px;
transform: translateY(-50%);
left: 50%;
top: 3px;
width: var(--tool-main-icon-size);
height: var(--tool-main-icon-size);
transform: translateX(-50%);
background: var(--tool-icon) center / contain no-repeat;
opacity: 0.92;
opacity: 0.98;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool::after {
content: "";
position: absolute;
inset: 0;
background: var(--tool-icon) right -8px center / 54px 54px no-repeat;
opacity: 0.10;
background: none;
display: none;
pointer-events: none;
z-index: 0;
}
.tool-label {
position: relative;
z-index: 2;
display: block;
grid-row: 2;
align-self: start;
max-width: 100%;
min-width: 0;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.12;
font-weight: 700;
text-shadow: 0 1px 0 rgba(255,255,255,0.62);
}
.tool.tool-has-canvas-icon { padding-left: 42px; --tool-icon: none; }
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-rows: 34px minmax(1.05em, auto); min-height: 44px; padding-top: 1px; }
.tool.tool-has-canvas-icon::before,
.tool.tool-has-canvas-icon::after { background: none !important; }
.tool-sprite-preview {
position: absolute;
left: 3px;
top: 50%;
width: 36px;
height: 36px;
transform: translateY(-50%);
left: 50%;
top: -5px;
width: 48px;
height: 48px;
transform: translateX(-50%);
pointer-events: none;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool > span:not(.tool-size-badge) { display: none; }
.tool-sprite-watermark { display: none !important; }
.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.webp"); }
.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.webp"); }
.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.webp"); }
@ -442,8 +467,8 @@ h1 {
}
.signboard-editor.hidden { display: none; }
.signboard-editor-panel {
width: min(430px, calc(100vw - 28px));
border-radius: 18px;
width: min(220px, calc(100vw - 28px));
border-radius: 12px;
padding: 14px;
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.96));
border: 1px solid rgba(94, 70, 42, 0.20);
@ -476,16 +501,23 @@ h1 {
}
.signboard-editor-close:hover { background: rgba(255,248,232,0.96); }
#signboardEditorText {
width: 100%;
width: 7.4em;
max-width: 100%;
box-sizing: border-box;
resize: vertical;
display: block;
margin: 0 auto;
resize: none;
min-height: 88px;
max-height: 88px;
overflow: hidden;
white-space: pre;
border-radius: 12px;
border: 1px solid rgba(84,68,48,0.18);
background: rgba(255,255,255,0.84);
padding: 10px 11px;
color: var(--ink);
font: 14px/1.55 ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
font: 15px/1.55 ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
letter-spacing: 0.03em;
outline: none;
}
#signboardEditorText:focus {
@ -494,6 +526,8 @@ h1 {
}
.signboard-editor-meta {
display: flex;
flex-wrap: wrap;
gap: 2px 8px;
justify-content: space-between;
color: var(--muted);
font-size: 12px;
@ -501,8 +535,42 @@ h1 {
}
.signboard-editor-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
/* v16.07.08: right menu fixed quick tabs */
.panel-quick-tabs {
position: sticky;
top: 0;
z-index: 12;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 8px 4px 9px;
margin: -2px 0 2px;
background: linear-gradient(180deg, rgba(244, 250, 247, 0.96), rgba(244, 250, 247, 0.78));
border-bottom: 1px solid rgba(102, 169, 215, 0.34);
box-shadow: 0 8px 16px rgba(79, 103, 121, 0.10);
backdrop-filter: blur(8px);
}
.panel-quick-tabs button {
min-width: 0;
border: 1px solid rgba(102, 169, 215, 0.34);
border-radius: 999px;
padding: 7px 5px;
background: rgba(255,255,255,0.76);
color: var(--ink);
font-size: 11px;
line-height: 1.1;
font-weight: 700;
cursor: pointer;
box-shadow: 0 3px 9px rgba(68, 92, 110, 0.08);
}
.panel-quick-tabs button:hover,
.panel-quick-tabs button.active {
background: #efffde;
border-color: rgba(103,178,44,0.46);
}

View file

@ -431,17 +431,6 @@
to { transform: translateY(8px); opacity: 0; }
}
/* v15.12.0: loading / collapsible cards / sound settings */
.loading-screen {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 20px;
background: radial-gradient(circle at top, rgba(255,251,243,0.98), rgba(245,238,225,0.96) 56%, rgba(234,225,208,0.98));
transition: opacity .22s ease, visibility .22s ease;
}
.selected-info .info-row[data-metric] {
border-radius: 8px;
@ -662,3 +651,27 @@
justify-content: flex-start;
}
}
.ground-stat {
padding: 7px 8px;
}
.ground-type-btn {
width: 100%;
min-height: 28px;
border: 1px solid rgba(92, 142, 74, 0.28);
border-radius: 999px;
background: rgba(255,255,255,0.58);
color: inherit;
cursor: pointer;
font: inherit;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
}
.ground-type-btn:hover {
background: rgba(255,255,255,0.82);
transform: translateY(-1px);
}
.ground-type-btn strong {
display: inline-block;
font-size: 16px;
line-height: 1.2;
}

View file

@ -3,78 +3,24 @@
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); }
.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); }
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); }
.tool[data-tool="poke"] { --tool-icon: none; }
.tool[data-tool="poke"]::before {
content: "\1F448";
background: none;
font-size: 25px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool[data-tool="poke"]::after {
content: "\1F448";
background: none;
font-size: 48px;
line-height: 1;
opacity: 0.11;
right: -6px;
left: auto;
top: 50%;
bottom: auto;
transform: translateY(-50%);
width: 54px;
height: 54px;
}
.tool[data-tool="pinch"] { --tool-icon: none; }
.tool[data-tool="pinch"]::before {
content: "\1F90F";
background: none;
font-size: 25px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool[data-tool="pinch"]::after {
content: "\1F90F";
background: none;
font-size: 48px;
line-height: 1;
opacity: 0.10;
right: -6px;
left: auto;
top: 50%;
bottom: auto;
transform: translateY(-50%);
width: 54px;
height: 54px;
}
.tool[data-tool="water_hose"] { --tool-icon: none; }
.tool[data-tool="water_hose"]::before {
content: "\1F6BF";
background: none;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 24px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool[data-tool="water_hose"]::after {
content: "\1F6BF";
background: none;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 46px;
line-height: 1;
opacity: 0.10;
right: -6px;
left: auto;
top: 50%;
bottom: auto;
transform: translateY(-50%);
width: 54px;
height: 54px;
}
.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); }
.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); }
.tool[data-tool="poke"] { --tool-emoji: "\1F448"; }
.tool[data-tool="pinch"] { --tool-emoji: "\1F90F"; }
.tool[data-tool="water_hose"] { --tool-emoji: "\1F6BF"; }
.tool.tool-emoji-icon { --tool-icon: none; }
.tool.tool-emoji-icon::before {
content: var(--tool-emoji);
background: none;
display: grid;
place-items: center;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 29px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool.tool-emoji-icon::after { content: none; display: none; }
.tool.selected {
background: linear-gradient(180deg, #ffffff, #efffde);
border-color: rgba(103, 178, 44, 0.52);
@ -91,8 +37,8 @@
.tool-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.tool-head h2 { margin-bottom: 0; }
.btn.mini { padding: 6px 10px; font-size: 12px; }
.tool-body { margin-top: 12px; }
.tool-palette { display: flex; flex-direction: column; gap: 9px; }
.tool-body { margin-top: 10px; }
.tool-palette { display: flex; flex-direction: column; gap: 8px; }
.tool-category {
border: 1px solid rgba(84,68,48,0.10);
border-radius: 14px;
@ -104,7 +50,7 @@
border: 0;
border-bottom: 1px solid rgba(84,68,48,0.08);
background: rgba(255,252,244,0.66);
padding: 8px 10px;
padding: 7px 10px;
text-align: left;
cursor: pointer;
font-weight: 700;
@ -112,7 +58,7 @@
}
.tool-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); }
.tool-category.collapsed .tool-category-toggle::before { content: "\25b8 "; }
.tool-category-body { padding: 8px; }
.tool-category-body { padding: 7px; }
.tool-category.collapsed .tool-category-body { display: none; }
.selected-data-groups {
@ -146,14 +92,14 @@
.selected-category-body .info-row:last-child { border-bottom: 0; }
.tool-size-badge {
position: absolute;
right: 7px;
bottom: 6px;
min-width: 20px;
padding: 2px 5px;
right: 4px;
bottom: 3px;
min-width: 16px;
padding: 1px 4px;
border-radius: 999px;
background: rgba(42,36,29,0.68);
background: rgba(42,36,29,0.72);
color: #fff;
font-size: 10px;
font-size: 8.5px;
line-height: 1;
text-align: center;
box-shadow: 0 1px 4px rgba(42,36,29,0.16);
@ -321,7 +267,7 @@ meter {
margin-right: 6px;
padding: 2px 5px;
border-radius: 999px;
font-size: 9px;
font-size: 9.5px;
line-height: 1.1;
text-align: center;
background: rgba(84,68,48,0.08);
@ -373,18 +319,6 @@ meter {
.tool-category[data-tool-category="hazard"] { --tool-cat-a: rgba(255,230,224,0.84); --tool-cat-b: rgba(250,207,197,0.78); --tool-cat-line: rgba(189,88,66,0.30); }
.tool-category { border-color: var(--tool-cat-line, rgba(84,68,48,0.10)); background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,255,255,0.36)), rgba(255,255,255,0.28)); }
.tool-category-toggle { background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54))); border-bottom-color: var(--tool-cat-line, rgba(84,68,48,0.08)); }
.tool[data-tool="ball"] { --tool-icon: none; }
.tool[data-tool="ball"]::before {
content: "⚽";
background: none;
display: grid;
place-items: center;
font-size: 28px;
line-height: 1;
opacity: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool[data-tool="ball"]::after { background: none; }
@media (min-width: 921px) {
.mobile-mode-controls { display: none !important; }
}

View file

@ -1,31 +1,3 @@
/* CSS split file; preserve manifest load order. */
.loading-screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.loading-kicker { font-size: 12px; letter-spacing: .14em; color: rgba(116,96,76,.82); margin-bottom: 6px; }
.loading-title { margin: 0 0 8px; font-size: 28px; color: #3f3429; }
.loading-text { margin: 0 0 12px; color: rgba(90,74,58,.86); }
.loading-sprite-strip { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin: 8px 0 14px; }
.loading-sprite-card { border-radius: 16px; background: linear-gradient(180deg,rgba(255,255,255,.84),rgba(248,243,232,.90)); border: 1px solid rgba(126,104,80,.12); min-height: 92px; display: grid; place-items: center; padding: 8px; box-shadow: 0 8px 18px rgba(83,63,44,.06); }
.loading-sprite-card img { width: 68px; height: 68px; object-fit: contain; }
.loading-progress-track { width: 100%; height: 14px; border-radius: 999px; overflow: hidden; background: rgba(132,164,217,.16); border: 1px solid rgba(92,126,181,.14); }
.loading-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg,#92d1ff,#a3dfa0 58%,#ffe192); transition: width .18s ease; }
.loading-progress-meta { margin-top: 8px; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: rgba(88,73,60,.82); }
.loading-hint { margin: 10px 0 0; font-size: 12px; line-height: 1.55; color: rgba(111,92,73,.78); }
.collapsible-card { padding-top: 10px; }
.panel-card-toggle { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 0; border-radius: 14px; padding: 10px 12px; cursor: pointer; font: inherit; color: var(--ink); background: rgba(255,255,255,.48); box-shadow: inset 0 1px 0 rgba(255,255,255,.54); }
.panel-card-title { font-size: 17px; font-weight: 700; letter-spacing: .03em; }
.panel-card-state { font-size: 12px; color: rgba(98,82,64,.78); padding-left: 16px; position: relative; }
.panel-card-state::before { content: "▾"; position: absolute; left: 0; top: 50%; transform: translateY(-52%); color: rgba(98,82,64,.72); }
.collapsible-card.collapsed .panel-card-state::before { content: "▸"; }
.collapsible-card.collapsed .panel-card-body { display: none; }
.panel-card-body { padding-top: 12px; }
.tool-card { background: linear-gradient(180deg, rgba(255,247,252,.92), rgba(249,240,249,.90)); border-color: rgba(200,153,186,.34); }
.colony-card { background: linear-gradient(180deg, rgba(244,250,238,.92), rgba(235,246,230,.90)); border-color: rgba(132,178,128,.34); }
.log-card { background: linear-gradient(180deg, rgba(255,246,238,.92), rgba(252,239,226,.90)); border-color: rgba(207,157,121,.34); }
.tool-card .panel-card-toggle { background: linear-gradient(180deg,rgba(255,239,248,.82),rgba(253,234,246,.84)); }
.colony-card .panel-card-toggle { background: linear-gradient(180deg,rgba(240,252,237,.82),rgba(230,247,228,.86)); }
.log-card .panel-card-toggle { background: linear-gradient(180deg,rgba(255,242,231,.84),rgba(253,235,220,.86)); }
.sound-category-controls,
.log-push-controls {
margin-top: 10px;
@ -61,7 +33,6 @@
.log-push-toast.log-push-grass::before { background: #8cc77a; }
.log-push-toast.leaving { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; }
@keyframes logPushIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 920px) { .loading-sprite-strip { grid-template-columns: repeat(2,minmax(0,1fr)); } }
/* v15.12.1: top-right sound settings panel */
@ -91,25 +62,7 @@
@media (max-width: 760px) { .sound-panel { right: auto; left: 0; } }
/* v15.12.3: collapsed event panel should be genuinely thin */
.log-card.collapsed {
min-height: 0 !important;
padding: 5px 7px !important;
flex: 0 0 auto !important;
}
.log-card.collapsed .panel-card-toggle {
min-height: 30px;
padding: 5px 9px;
border-radius: 11px;
}
.log-card.collapsed .panel-card-title {
font-size: 13px;
line-height: 1.1;
}
.log-card.collapsed .panel-card-state {
font-size: 10px;
}
/* Collapsed card sizing is shared by the base panel rules; no event-only shrink override. */
/* v15.12.4: event log scroll chains to the right menu at top/bottom */
.log {
@ -179,16 +132,7 @@
}
}
/* Item icons and mobile input mode */
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); }
/* Mobile input mode */
.mobile-mode-controls {
display: none;
position: absolute;

View file

@ -1,59 +0,0 @@
# Architecture
Build target: static browser page. No bundler. Globals and ordered deferred scripts are intentional. Current inspected version: `15.20.16`.
## Load order
`app_manifest.json` owns CSS/JS order. Regenerate `index.html` and `service-worker.js` with `python3 scripts/generate_app_files.py` after version/order changes. `js/version.js` exposes `TARINAI_APP.version`, cache name, and query helper.
The old `css/styles.css` compatibility shim and `js/ui_family.js` split marker have been removed. The family tree entry point now starts at `ui_family_data.js` and continues through async/layout/path/render modules.
## Runtime objects
- `World`: constructor in `js/world.js`; methods split into view, family/social, combat/effects, environment, spatial/budget, ants, update, placement/log, bootstrap.
- `Tarinai`: constructor in `js/tarinai.js`; methods split into identity/social, disease/nest, item effects, needs/items, social/move/life, render.
- `Item`: item state, lifecycle, scaling, and field rendering in `js/items.js`.
- `Effect`: transient visuals in `js/sim_core.js`; combat/weather/food effects are spawned through `World` helpers.
- `TarinaiEvents`: global event bus for logs, terrain dirtiness, audio failures, phase/performance changes.
- `TarinaiPerformance`: FPS quality manager; provides effect caps, ant stride, terrain redraw cadence, UI intervals.
- `TarinaiInputMode`: input classification and mobile-mode state. UI code reads this manager instead of duplicating touch heuristics.
- `TarinaiWeatherSystem`: weather transitions and rain-spawned water item creation. Rain overlay drawing remains in `render.js`.
## Startup path
`main.js` owns the user-visible boot sequence:
1. Start the loading showcase and preload the initial sprite/image set through `loadImages`.
2. Bind UI, apply the default `garden` field layout, reset the world, and select `observe`.
3. Render logs, family archive, first frame, and stats.
4. Start deferred background image loading, enter `requestAnimationFrame(loop)`, and hide the loading screen.
5. Register `service-worker.js` with `updateViaCache: "none"` after window load.
## Data ownership
- `js/data.js`: base item/tool definitions, `TOOL_CATEGORIES`, config, image metrics, field presets.
- `js/item_visual_registry.js`: canonical item visual descriptors used by field drawing and generated tool-icon previews.
- `js/food_registry.js`: food/medicine semantics such as labels, serving classes, decay multipliers, and hygiene penalties.
- `js/disease_registry.js`: disease labels, flags, display metadata, cure-rule notes, and mystery-drug eligibility.
- `js/input_mode_manager.js`: touch/mobile classification, mobile mode persistence, and gesture capability policy.
- `js/weather_system.js`: weather state transitions and rain-water item spawning.
- `js/text_catalog.js`: Japanese UI copy/tooltips. Keep JS strings escaped where existing style uses escapes.
- `js/effect_registry.js`: item-effect labels, colors, modifiers, exclusivity/removal metadata, behavior callbacks (`onApply`, `onTick`, `onRemove`), direct eating context, and random-effect candidates.
- `js/sound_pack.js`: canonical sound metadata: IDs, categories, gain multipliers, cooldowns, optional sample paths, and ID-to-category mapping.
- `FIELD_TYPES` in `js/data.js`: cage/garden/park field labels, responsive heights, world scale, starting population, and initial grass.
## State loops
`main.js` starts asset preload, world bootstrap, animation loop, deferred image loading, service-worker registration. `World.update(dt)` advances time, weather, items, ants, Tarinai, effects, budgets, terrain dirtiness. `render()` draws cached background/terrain, visible entities, weather overlay, cards, HUD, and debug overlays.
World time is `CONFIG.dayLength` seconds per day. Version `15.20.16` uses 120 seconds/day and weather changes every 46-96 seconds. The phase boundary rules live in `World.phaseName()`.
Domain events are emitted at central mutation points: placement, eating, decay, effect apply/remove, disease infect/cure, damage/death/birth, fight hit/loss/end, ant spawn/grab/carry/death, weather changes, and rain item drops. Logs/audio still use direct calls where they already existed; do not add duplicate log or sound side effects when subscribing to these events.
Ant behavior remains intentionally conservative. `ANT_EXTENSION_HOOKS` names future ecology slots for nest hunger, nest growth, queen reproduction, worker lifecycle, carrying success, and territorial behavior, but this pass does not rebalance those mechanics.
## Spatial/performance rules
Use `World.nearby*` helpers rather than full-array scans in hot paths. Terrain cache uses dirty/version flags; do not reintroduce every-frame full terrain hashing. Performance degradation should be centralized in `performance_manager.js`.
Family tree rendering is intentionally asynchronous and cached. `ui_family_async.js` chunks component discovery with `requestIdleCallback` when available; `ui_family_render.js` hydrates cached HTML, keeps archive dirty/version state, and exposes `validateFamilyTree()` plus `familyTreeDiagnostics()` for browser-console checks.

View file

@ -1,64 +0,0 @@
# Content and visual guide
## Visual source of truth
Do not invent tool-UI-only item designs. `js/item_visual_registry.js` is the canonical descriptor table for drift-prone item visuals; field drawing in `js/items.js` and generated previews must consume the same concepts. Tool icons in `assets/ui/tool_*.webp` are generated previews and must match field rendering. Update `scripts/generate_item_icons.py` when changing item helper silhouettes; regenerate icons before shipping.
Canonical helper mapping:
- Weather rain overlay: `drawRain` in `render.js`; diagonal blue line streaks only, never item droplets.
- Horizontal item droplet: `drawOvalWaterDropSprite`; used by fallen water items, zunda juice, mercury. Same silhouette; recolor only.
- Water bowl: `water_bowl` descriptor in `js/item_visual_registry.js`; bowl and inner horizontal water palette are registry-owned.
- Sleep drug: `drawSleepTabletSprite`.
- Laxative: `drawLaxativeTabletSprite`; zunchi-motif tablet.
- Mystery drug: `drawMysteryTabletSprite`; question-mark tablet.
- Ammo: `drawBulletSprite`; bullet silhouette.
- Protein/niteropu: powder-pile rendering in the parameter-item branch.
## Tool groups
`TOOL_CATEGORIES` in `js/data.js` owns the runtime-generated tool palette. CSS colors/icons are tied to `data-tool-category` and `data-tool` values:
- Operation/observation: observe, delete, poke, pinch, new, water hose.
- Food/water/grass: zunda mochi, love mochi, fight mochi, grass, water bowl, zunda juice.
- Medicine/effects: sleep drug, laxative, protein, niteropu, ammo, mystery drug, mercury, giant drug, dwarf drug.
- Habitat/living things: zunchi, bed, nest box, ant nest, ball.
- Hazards/obstacles: stone, genkotsu, firecracker, vertical fence, horizontal fence.
When adding or renaming a tool, update `TOOL_DEFINITIONS`, `TOOL_CATEGORIES`, `TEXT_CATALOG.toolTips`, CSS icon mapping, generated `assets/ui/tool_*.webp`, and docs. Do not duplicate category membership in `index.html`.
## Gameplay data registries
- Item effects: `TarinaiEffectRegistry` owns labels, display colors, modifiers, exclusivity/removal metadata, behavior callbacks, and mystery-drug candidates. Tarinai item-effect methods are the integration layer and should route apply/tick/remove behavior through the registry.
- Diseases: `TarinaiDiseaseRegistry` owns disease flags, labels, symptoms/display text, base infection chances, cure-rule notes, and mystery-drug eligibility.
- Food: `TarinaiFoodRegistry` owns labels, serving classes, nutrition, hunger relief, decay multipliers, hygiene penalties, and special effect IDs. Serving-food eating paths must read nutrition and hunger relief from this registry.
- Weather: `TarinaiWeatherSystem` owns weather transitions and rain-spawned water items. Rain visuals are diagonal line streaks only. Rain-spawned water items, water bowls, zunda juice, and mercury are separate item/object concepts.
- Ants: `ANT_EXTENSION_HOOKS` documents future ecology slots only. Do not change worker count, queen founding, carrying, or nest removal balance as part of registry/event cleanup.
## UI copy and labels
Most visible UI remains Japanese. Keep user-facing naming consistent: the former lifestyle chart label is now the mental-state label; selected Tarinai combat summary shows fight win rate, not cumulative defeats. The selected Tarinai data panel must show both daily personality-change causes and the rolling `recentChangeCauses` history.
## Bubble text inventory
Active Tarinai/world bubble text classes:
- Symbols: `!`, `?`, `♡`, `...`.
- Sleep: `Zzz...`.
- Eating/poop: short munching and poop sounds.
- Fear/fight: short panic, shout, and breathing sounds.
- Disease/recovery: short weak or relief sounds.
- Random small voice: short Tarinai voice variants.
- Item effects: `???`.
When adding bubbles, keep them short; every bubble may also trigger `audio.bubble`.
## Audio
Sound settings are in the top-right sound menu, not inside Event UI. Categories: Tarinai voice, notification, operation. `js/sound_pack.js` is the canonical metadata source for sound IDs, categories, gains, cooldowns, sample paths, and ID mapping; `js/audio.js` is the playback engine. Missing samples fall back to synthetic audio. Notification and operation sounds are intentionally louder than voice sounds.
The Event card owns in-page push toast settings for log kinds: death, accident, birth, fight, food, weather, grass, relation. Those settings are separate from audio categories and persist through localStorage.
## Ecology and credits
Ecology dialog card copy and images come from `TEXT_CATALOG.ecologyCards`; image paths should use real assets or generated WebP assets. Credits are hardcoded in `index.html`; update them when adding new external media sources.

View file

@ -1,45 +0,0 @@
# Maintenance
## Release process
1. Edit source files.
2. Bump `app_manifest.json` and `js/version.js` to the same version.
3. Regenerate static references: `python3 scripts/generate_app_files.py`.
4. Regenerate item icons if item visuals changed: `python3 scripts/generate_item_icons.py`.
5. Check versions: no stale `v=<old>` in `index.html`, CSS icon URLs, `service-worker.js`.
6. Validate syntax: `node --check js/*.js service-worker.js`. If Node is not installed on the local machine, document that explicitly in the handoff and use browser-console smoke checks instead.
7. Open `index.html` in a browser and verify startup, reset field selection, one tool placement, observe selection, family tree render, sound menu toggles, and `?debug=1`.
8. Zip from project root and run `zip -T`.
If `scripts/generate_app_files.py` cannot run locally, manually keep these in sync: `app_manifest.json`, `index.html` script/link tags, and `service-worker.js` `coreScriptNames`/CSS cache entries.
## Cache rules
`service-worker.js` must remain no-cache/no-store in `.htaccess`; versioned JS/CSS/WebP can be immutable. Register service worker with `updateViaCache: "none"`.
## Debugging
Use `?debug=1` for FPS, performance level, counts, cache/SW state, last audio ID, effect registry count, terrain/spatial version, and recent event-bus history.
Useful browser-console checks:
- `TARINAI_VERSION` and `TARINAI_APP.cacheName` confirm version/cache naming.
- `world.fieldType`, `world.tarinai.length`, `world.items.length`, and `world.ants.length` confirm the active simulation shape.
- `TarinaiEffectRegistry.debugSnapshot()` confirms registered item effects.
- `TarinaiDiseaseRegistry.debugSnapshot()` confirms disease metadata.
- `TarinaiFoodRegistry.debugSnapshot()` confirms food semantics.
- `toolCategories().map(c => c.id)` confirms the runtime tool palette registry.
- `TarinaiInputMode.snapshot()` confirms mobile classification and mode policy.
- `TarinaiEvents.recent(null, 20).map(e => e.type)` confirms domain-event emission.
- `validateFamilyTree()` checks reciprocal family links and rendered family tree node/link consistency.
- `familyTreeDiagnostics()` reports the latest async archive render state.
## Mobile input
The mobile mode controls are owned by `TarinaiInputMode` and are only for compact/no-hover/coarse-pointer touch-first devices. Desktop browsers, including touch-screen PCs, should not show the mobile controls unless the viewport or UA also indicates mobile/tablet.
The mode state is stored as `tarinai_mobile_input_mode_v1`; sound settings use `tarinai_audio_settings_v2`; log push settings use `tarinai_log_push_settings_v2`.
## Prompt archive
`docs/prompt.txt` is external user material. Do not rewrite, translate, trim, or delete it.

View file

@ -1,860 +0,0 @@
Implement the new need-driven behavior system for the existing “tarinai” observation game.
Keep the implementation simple, localized, and compatible with the existing architecture. Do not perform a broad rewrite unless absolutely necessary.
Important source-code text rule:
All newly added Japanese UI strings or Japanese reason/action strings in source code must be written as Unicode escape sequences, not raw Japanese characters.
Example:
Do not write:
"あぶないけど、食べた。"
Write:
"\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002"
Runtime text may display Japanese, but source literals must be escaped.
============================================================
GOAL
============================================================
Replace or absorb the existing detailed emotion-driven behavior system into a simple six-need system.
Behavior should be selected from six needs:
- food
- sleep
- health
- safety
- social
- fulfill
The system should allow imperfect behavior.
Example:
If safety and food are both maxed, the tarinai may randomly choose food and eat even though it is dangerous.
============================================================
1. SIX NEEDS
============================================================
Each tarinai should have:
needs = {
food: 0,
sleep: 0,
health: 0,
safety: 0,
social: 0,
fulfill: 0
};
Meaning:
food:
- eating
- drinking
sleep:
- sleep
- rest
- tiredness
health:
- illness
- injury
- dirt
- poison
- recovery
safety:
- danger
- fear
- ants
- explosions
- punches
- being attacked
social:
- friends
- parent/child
- mating/reproduction
- enemies if existing relation logic supports it
fulfill:
- play
- owned place
- owned structure
- grass bed
- plushie
- simple construction
Each need is internally 0100.
After raw calculation, quantize each value to 10-point steps:
0, 10, 20, ... 100
Use a helper like:
function quantizeNeed(value) {
return Math.max(0, Math.min(100, Math.round(value / 10) * 10));
}
For UI display, show only the tens digit:
0, 1, 2, ... 10
Example:
internal value 80 -> display 8
internal value 100 -> display 10
Safety is capped at 100 like every other need.
Safety is not a hard interrupt.
============================================================
2. NEED CALCULATION
============================================================
Calculate needs from existing tarinai/world state where possible.
Suggested mapping:
food:
- hunger
- thirst if available
sleep:
- low energy
- long awake time if available
- night/time-of-day bonus if available
health:
- disease
- injury
- dirtiness
- poison or negative status if available
safety:
- nearby ants
- nearby dangerous items
- recent damage
- fear/panic-related existing state if any
- being attacked
- owned structure destruction shock
social:
- loneliness if available
- nearby friend / parent / child
- reproduction readiness
- enemy proximity if current relation system supports it
fulfill:
- wants to play
- has no owned structure
- wants to return to owned grass bed
- wants to carry/use plushie
- wants to build a simple owned structure
Use existing personality fields if available:
aggression:
- safety rises slightly less from danger
- may choose intimidate instead of panic when there is a breaker/attacker
openness:
- fulfill rises more easily
- play/build actions become more likely
sociability:
- social rises more easily
- reacts less negatively when others use owned structures
neuroticism:
- safety and health rise more easily
- owned structure damage/destruction has stronger effect
Keep personality modifiers small and easy to tune.
============================================================
3. ACTION SELECTION
============================================================
Use this behavior flow:
1. Calculate all six raw needs.
2. Quantize all needs.
3. Find the maximum need value.
4. Collect all needs equal to the maximum.
5. Randomly choose one need from that tied top set.
6. Choose an executable action associated with that chosen need.
7. Store the chosen need and action in tarinai.intent.
8. Highlight only the chosen need in the tarinai data UI.
If safety and food are both 100, either may be chosen randomly.
Add helpers:
- createDefaultNeeds()
- quantizeNeed(value)
- updateNeeds(tarinai, world)
- chooseTopNeedRandom(needs)
- chooseActionForNeed(need, tarinai, world)
- buildReasonText(chosenNeed, tiedNeeds, action)
- getNeedDisplayValue(value)
============================================================
4. SIMPLE ACTION DEFINITIONS
============================================================
Use a simple action list, not a complex behavior tree.
Each action should have:
- id
- need
- label
- phrase
- weight
- condition(tarinai, world)
- run(tarinai, world)
Example shape:
const TARINAI_ACTIONS = [
{
id: "eat_food",
need: "food",
label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b",
phrase: "\u98df\u3079\u305f",
weight: 50,
condition(t, world) {
return !!findNearestItemWithRole(world, t, "food");
},
run(t, world) {
const item = findNearestItemWithRole(world, t, "food");
moveToOrUse(t, item);
}
}
];
Suggested actions:
food:
- eat_food
- drink_water
sleep:
- sleep_in_bed
- sleep_in_owned_structure
- sleep_anywhere
health:
- use_medicine
- wash_or_drink_water
- rest_to_recover
safety:
- flee
- hide_in_nest
- hide_at_owned_structure
social:
- approach_friend
- approach_parent_or_child
- approach_mate
- intimidate_enemy
fulfill:
- play
- wander_lightly
- build_grass_bed
- build_plushie
- return_owned_structure
- use_plushie
Use weighted random among executable actions for the chosen need.
If no action is executable, fall back to idle, wander, or rest.
Do not make tarinai perfectly optimal.
============================================================
5. TIE REASON TEXT
============================================================
If multiple needs are tied for highest and the chosen need is only one of them, generate:
“[ignored need phrase]\u3051\u3069\u3001[chosen action phrase]\u3002”
Examples:
- "\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002"
- "\u306d\u3080\u3044\u3051\u3069\u3001\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f\u3002"
- "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b\u3051\u3069\u3001\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f\u3002"
- "\u8abf\u5b50\u304c\u60aa\u3044\u3051\u3069\u3001\u904a\u3093\u3060\u3002"
When multiple ignored needs exist, mention the ignored need with highest priority:
safety > health > food > sleep > social > fulfill
Conflict phrases:
food:
"\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b"
sleep:
"\u306d\u3080\u3044"
health:
"\u8abf\u5b50\u304c\u60aa\u3044"
safety:
"\u3042\u3076\u306a\u3044"
social:
"\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b"
fulfill:
"\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b"
If there is no tie, use:
“[need phrase]\u306e\u3067\u3001[action phrase]\u3002”
Store:
tarinai.intent = {
need: chosenNeed,
tiedNeeds,
actionId: action.id,
actionLabel: action.label,
reasonText
};
============================================================
6. NEED UI
============================================================
Update the tarinai data UI.
Show six needs as 010 values.
Labels must be escaped in source:
food label:
"\u6442\u990c"
sleep label:
"\u7761\u7720"
health label:
"\u5065\u5eb7"
safety label:
"\u5b89\u5168"
social label:
"\u95a2\u4fc2"
fulfill label:
"\u5145\u8db3"
Example UI:
摂餌 8
睡眠 3
健康 1
安全 10
関係 4
充足 10 ★
Only the need actually chosen for the current action receives the highlight.
Also show:
"\u3044\u307e":
[action label]
"\u7406\u7531":
[reasonText]
Do not show detailed need breakdowns.
Do not add graphs or extra panels.
============================================================
7. ITEM ROLES AND NEED EFFECTS
============================================================
Use minimal item roles and needEffects.
Do not create a broad tag system.
Item example:
item.roles = {
food: true,
drink: true,
medicine: true,
sleepPlace: true,
danger: true,
grassMaterial: true
};
item.needEffects = {
food: 0,
sleep: 0,
health: 0,
safety: 0,
social: 0,
fulfill: 0
};
Only add roles that are actually needed by the new behavior.
Useful helpers:
- findNearestItemWithRole(world, tarinai, role)
- findNearestSleepPlace(world, tarinai)
- findNearbyDanger(world, tarinai)
- findNearbyMaterial(world, tarinai, materialRole)
Adapt to existing item registries where possible.
============================================================
8. STRUCTURE REGISTRY
============================================================
Keep StructureRegistry because tarinai-built structures will expand in the future.
Do not collapse structures into one-off special item cases.
Create or adapt a small StructureRegistry for buildable tarinai structures.
Shared structure instance shape:
{
id,
type,
isStructure: true,
ownerId,
hp,
attachment,
usedCount,
x,
y,
roles,
needEffects
}
Do not implement:
- repair
- inheritance
- ownership privacy
- material-carrying inventory
- complex construction logistics
First buildable structures:
- grass_bed
- plushie
============================================================
9. STRUCTURE: GRASS BED
============================================================
Structure type:
"grass_bed"
Japanese label, escaped:
"\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9"
Concept:
A simple bed made from grass. It is an owned place. The owner prefers it for sleep, safety, and fulfill.
Example registry entry shape:
grass_bed: {
type: "grass_bed",
label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9",
buildNeed: "fulfill",
buildTime: 4,
maxHp: 100,
materials: {
grassMaterial: 1
},
roles: {
sleepPlace: true,
ownedStructure: true
},
needEffects: {
food: 0,
sleep: 60,
health: 5,
safety: 25,
social: 0,
fulfill: 40
},
create(owner, x, y, world) {
return {
id: createId("structure"),
type: "grass_bed",
isStructure: true,
ownerId: owner.id,
hp: 100,
attachment: 20,
usedCount: 0,
x,
y,
roles: this.roles,
needEffects: this.needEffects
};
},
onUse(user, structure, world) {
// owner gets stronger effects
// non-owner gets weaker effects
// owner use increases attachment
},
onDestroyed(structure, world, breaker) {
// increase owner safety and fulfill
// trigger panic or intimidate through need shock logic
}
}
Build condition:
- chosen need is fulfill
- tarinai has no owned grass_bed
- grass material is nearby
- tarinai is not sleeping, fighting, or panicking
Build process:
- move near grass
- spend short build time if existing state system supports it
- consume/reduce grass if easy
- create grass_bed through StructureRegistry
Owner behavior:
- owner prefers own grass_bed for sleep, safety, and fulfill
- using own grass_bed reduces sleep, safety, and fulfill
- using own grass_bed increases attachment
Other tarinai:
- may use it with weaker effects
- if owner sees another tarinai using it:
- aggression may cause intimidate
- neuroticism may increase safety/fulfill
- sociability reduces negative reaction
Destruction:
- hp can be reduced by explosions, punches, weather, repeated use, or existing damage sources if easy to connect
- when hp reaches 0, call onDestroyed and remove structure
============================================================
10. STRUCTURE: PLUSHIE
============================================================
Add a new tarinai-built structure:
type:
"plushie"
Japanese label, escaped:
"\u306c\u3044\u3050\u308b\u307f"
Concept:
A small plushie item that resembles a tarinai. It is made from grass. When completed, it attaches to the owner tarinais head. While attached, it reduces fulfill. If damaged, it breaks and disappears. The owner receives a strong need shock and enters panic or intimidate.
Example registry entry shape:
plushie: {
type: "plushie",
label: "\u306c\u3044\u3050\u308b\u307f",
buildNeed: "fulfill",
buildTime: 4,
maxHp: 1,
materials: {
grassMaterial: 1
},
roles: {
carriedObject: true,
ownedStructure: true,
fulfillObject: true
},
needEffects: {
food: 0,
sleep: 0,
health: 0,
safety: 5,
social: 0,
fulfill: 50
},
create(owner, x, y, world) {
return {
id: createId("structure"),
type: "plushie",
isStructure: true,
ownerId: owner.id,
hp: 1,
attachment: 30,
usedCount: 0,
x,
y,
carriedById: owner.id,
onHead: true,
roles: this.roles,
needEffects: this.needEffects
};
},
onUse(user, structure, world) {
// if owner carries/uses it, reduce fulfill
// optionally reduce safety slightly
// increase attachment slightly
},
onDestroyed(structure, world, breaker) {
// large shock to owner safety and fulfill
// trigger panic or intimidate
}
}
Build condition:
- chosen need is fulfill
- tarinai does not already own/carry a plushie
- grass material is nearby
- tarinai is not sleeping, fighting, or panicking
Build process:
- move near grass
- spend short build time if existing state system supports it
- consume/reduce grass if easy
- create plushie through StructureRegistry
- attach it to owners head
Behavior while attached:
- follow owner position
- render above owners head
- use same or similar sprite as tarinai, scaled down
- reduce fulfill over time or when used
- optionally reduce safety slightly
- do not implement inventory
Damage/destruction:
- any meaningful damage destroys it
- remove plushie
- increase owner safety and fulfill strongly
- trigger panic or intimidate through need shock logic
Do not implement:
- repair
- inheritance
- trading
- inventory
- multiple plushies per tarinai
============================================================
11. EMOTION SYSTEM SIMPLIFICATION
============================================================
Remove the old detailed emotion system as a behavior driver.
Behavior must be selected only from the six needs:
food, sleep, health, safety, social, fulfill.
Old emotions should be removed or absorbed:
fear -> safety
loneliness -> social
happiness -> low stress / low fulfill pressure
sadness -> fulfill/social/safety pressure
comfort -> sleep/safety/fulfill satisfaction
boredom -> do not implement
anger -> intimidate state only
panic -> need shock reaction
stress -> derived pressure from needs
Do not keep happiness, sadness, fear, loneliness, comfort, boredom, or anger as independent behavior-driving values.
============================================================
12. STRESS REDEFINITION
============================================================
Stress is not an independent emotion.
Stress is a derived value calculated from current need pressure.
Do not directly add to stress for shock events.
For shock events, increase relevant needs first, then recalculate stress from needs.
Stress must not be used as a primary action-selection need.
Behavior is selected only from the six needs.
Stress may affect:
- expression
- minor visual intensity
- UI display if already present
- minor secondary effects if existing systems need it
Suggested pressure function:
function needPressure(value) {
const x = value / 100;
return x * x * 100;
}
Suggested stress calculation:
stress =
needPressure(food) * 0.10 +
needPressure(sleep) * 0.12 +
needPressure(health) * 0.25 +
needPressure(safety) * 0.30 +
needPressure(social) * 0.08 +
needPressure(fulfill) * 0.15;
Adjust weights if needed, but keep stress derived from needs.
============================================================
13. PANIC REDEFINITION
============================================================
Panic is not a need.
Panic is not a persistent emotion.
Panic is a short temporary reaction state caused by large sudden increases in safety, health, or fulfill.
Food, sleep, and social should not normally trigger panic.
Track previous needs or otherwise compute need delta.
Suggested rule:
panic triggers if:
- safety increases suddenly by 40 or more
- health increases suddenly by 45 or more
- fulfill increases suddenly by 50 or more
Panic should last for a short timer, such as 24 seconds.
After panic timer ends, return to normal need-based action selection.
If panic would be triggered and the tarinai has high aggression and a known breaker/attacker, use intimidate instead of panic.
Example:
if (shockTriggered) {
if (breaker && t.personality?.aggression > 0.6) {
t.state = "intimidate";
t.target = breaker;
} else {
t.state = "panic";
t.panicTimer = 3.0;
}
}
============================================================
14. EXPRESSION / VISUAL EMOTION COMPATIBILITY
============================================================
If the old emotion system is deeply tied to expressions, keep only a small compatibility layer for visuals.
Expressions should be derived from:
- current state
- chosen need
- stress level
- panic/intimidate
Suggested mapping:
state panic:
panic expression
state intimidate:
angry/threat expression
chosen need safety:
worried expression
chosen need food:
hungry expression
chosen need sleep:
sleepy expression
chosen need health:
unwell expression
chosen need social:
social/looking expression
chosen need fulfill:
neutral/pleased expression
Do not let old expression emotions drive behavior.
============================================================
15. SAVE COMPATIBILITY
============================================================
Backward compatibility is not required.
Do not implement save migrations.
Do not add compatibility shims for old saves unless the current code would immediately crash during normal startup.
It is acceptable if old saves are incompatible after this feature.
============================================================
16. DO NOT ADD
============================================================
Do not add:
- long-term memory
- boredom/habit system
- player-intervention profile
- ReactionRegistry
- WorldRules
- full behavior tree
- complex utility AI
- detailed need breakdown UI
- repair system
- inheritance system
- material-carrying inventory
- structure privacy system
============================================================
17. ACCEPTANCE CRITERIA
============================================================
After implementation:
- Each tarinai has six needs:
- food
- sleep
- health
- safety
- social
- fulfill
- Needs are clamped 0100.
- Needs are quantized to 10-point steps.
- UI displays needs as 010.
- Only the chosen action-driving need is highlighted.
- If multiple needs are tied for highest, one is chosen randomly.
- Safety does not force interruption.
- If safety and another need are tied, non-safety behavior can happen.
- Tie reason text uses the “Xけど、Yした。” format.
- Newly added Japanese source strings are Unicode escaped.
- Actions are defined in a simple action list.
- Items expose minimal roles and needEffects.
- StructureRegistry exists and supports at least grass_bed and plushie.
- grass_bed consumes grass to build.
- grass_bed is preferred by its owner.
- grass_bed destruction increases owner safety/fulfill and can trigger panic/intimidate.
- plushie consumes grass to build.
- plushie attaches to the owners head.
- plushie visually resembles a small tarinai.
- plushie reduces fulfill while carried/used.
- plushie breaks and disappears when damaged.
- plushie destruction strongly increases owner safety/fulfill and triggers panic/intimidate.
- Old detailed emotion values no longer drive behavior.
- Stress is derived from needs.
- Panic is caused by sudden safety/health/fulfill increases.
- Old save compatibility is not required.

View file

@ -3,24 +3,24 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<title>個体&#x89B3;&#x5BDF;</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Ctext y=%2752%27 font-size=%2752%27%3E%E3%81%9F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="css/base.css?v=15.24.14" />
<link rel="stylesheet" href="css/layout.css?v=15.24.14" />
<link rel="stylesheet" href="css/panel.css?v=15.24.14" />
<link rel="stylesheet" href="css/components.css?v=15.24.14" />
<link rel="stylesheet" href="css/mobile.css?v=15.24.14" />
<link rel="stylesheet" href="css/base.css?v=16.07.09" />
<link rel="stylesheet" href="css/layout.css?v=16.07.09" />
<link rel="stylesheet" href="css/panel.css?v=16.07.09" />
<link rel="stylesheet" href="css/components.css?v=16.07.09" />
<link rel="stylesheet" href="css/mobile.css?v=16.07.09" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
<div class="loading-panel">
<div class="loading-kicker">たりないコロニー</div>
<h1 class="loading-title">読み込み中…</h1>
<p id="loadingText" class="loading-text">準備しています</p>
<div class="loading-kicker">個体&#x30B3;&#x30ED;&#x30CB;&#x30FC;</div>
<h1 class="loading-title">&#x8AAD;&#x307F;&#x8FBC;&#x307F;&#x4E2D;&#x2026;</h1>
<p id="loadingText" class="loading-text">&#x6E96;&#x5099;&#x3057;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;</p>
<div id="loadingSpriteStrip" class="loading-sprite-strip" aria-hidden="true"></div>
<div class="loading-progress-track"><div id="loadingBar" class="loading-progress-bar" style="width:0%"></div></div>
<div class="loading-progress-meta"><span>進行状況</span><strong id="loadingPercent">0%</strong></div>
<p class="loading-hint">初期表示に必要な画像だけ先に読み込み、残りは後から順次ロードします。</p>
<div class="loading-progress-meta"><span>&#x9032;&#x884C;&#x72B6;&#x6CC1;</span><strong id="loadingPercent">0%</strong></div>
<p class="loading-hint">&#x521D;&#x671F;&#x8868;&#x793A;&#x306B;&#x5FC5;&#x8981;&#x306A;&#x753B;&#x50CF;&#x3060;&#x3051;&#x5148;&#x306B;&#x8AAD;&#x307F;&#x8FBC;&#x307F;&#x3001;&#x6B8B;&#x308A;&#x306F;&#x5F8C;&#x304B;&#x3089;&#x9806;&#x6B21;&#x30ED;&#x30FC;&#x30C9;&#x3057;&#x307E;&#x3059;&#x3002;</p>
</div>
</div>
<div id="app">
@ -28,45 +28,51 @@
<div class="top-left">
<a class="btn room-link" href="https://host.nishi.boats/~333/">333&#x306E;&#x90E8;&#x5C4B;&#x306B;&#x79FB;&#x52D5;</a>
<div class="brand">
<h1>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</h1>
<h1>個体&#x89B3;&#x5BDF;</h1>
</div>
</div>
<div class="top-actions">
<button id="pauseBtn" class="btn primary">&#x4E00;&#x6642;&#x505C;&#x6B62;</button>
<button id="speedBtn" class="btn">&#x901F;&#x5EA6; x1</button>
<button id="ecologyBtn" class="btn">&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x751F;&#x614B;</button>
<button id="ecologyBtn" class="btn">個体&#x306E;&#x751F;&#x614B;</button>
<div id="soundMenu" class="sound-menu">
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音設定 ON</button>
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="効果音設定">
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">&#x97F3;&#x8A2D;&#x5B9A; ON</button>
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="&#x52B9;&#x679C;&#x97F3;&#x8A2D;&#x5B9A;">
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>&#x52B9;&#x679C;&#x97F3;</span></label>
<div class="sound-panel-divider"></div>
<label><input type="checkbox" data-sound-category="voice"><span>たりないの声</span></label>
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
<label><input type="checkbox" data-sound-category="voice"><span>個体&#x306E;&#x58F0;</span></label>
<label><input type="checkbox" data-sound-category="notify" checked><span>&#x901A;&#x77E5;</span></label>
<label><input type="checkbox" data-sound-category="ops" checked><span>&#x64CD;&#x4F5C;</span></label>
</div>
</div>
<button id="creditsBtn" class="btn">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</button>
<button id="saveBtn" class="btn">セーブ</button>
<button id="saveBtn" class="btn">&#x30BB;&#x30FC;&#x30D6;</button>
<button id="resetBtn" class="btn danger">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
</div>
</header>
<main class="layout">
<section class="stage-wrap">
<canvas id="gameCanvas" aria-label="&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;"></canvas>
<div id="mobileModeControls" class="mobile-mode-controls" aria-label="スマホ操作モード">
<button type="button" data-mobile-mode="auto" class="active">自動</button>
<button type="button" data-mobile-mode="camera">カメラ</button>
<button type="button" data-mobile-mode="tool">道具</button>
<button type="button" data-mobile-mode="family">家系図</button>
<canvas id="gameCanvas" aria-label="個体&#x89B3;&#x5BDF;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;"></canvas>
<div id="mobileModeControls" class="mobile-mode-controls" aria-label="&#x30B9;&#x30DE;&#x30DB;&#x64CD;&#x4F5C;&#x30E2;&#x30FC;&#x30C9;">
<button type="button" data-mobile-mode="auto" class="active">&#x81EA;&#x52D5;</button>
<button type="button" data-mobile-mode="camera">&#x30AB;&#x30E1;&#x30E9;</button>
<button type="button" data-mobile-mode="tool">&#x9053;&#x5177;</button>
<button type="button" data-mobile-mode="family">&#x5BB6;&#x7CFB;&#x56F3;</button>
</div>
<div id="toast" class="toast hidden"></div>
</section>
<aside class="panel">
<nav id="panelQuickTabs" class="panel-quick-tabs" aria-label="右メニューのタブ">
<button type="button" data-panel-tab="selected">たりないデータ</button>
<button type="button" data-panel-tab="tools">道具</button>
<button type="button" data-panel-tab="colony">コロニー</button>
<button type="button" data-panel-tab="events">イベント</button>
</nav>
<section id="selectedCard" class="card selected-card hidden">
<div class="selected-head">
<h2>&#x305F;&#x308A;&#x306A;&#x3044;&#x30C7;&#x30FC;&#x30BF;</h2>
<h2>個体&#x30C7;&#x30FC;&#x30BF;</h2>
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="&#x89B3;&#x5BDF;&#x3092;&#x3084;&#x3081;&#x308B;">&times;</button>
</div>
<div id="selectedInfo" class="selected-info empty">&#x672A;&#x9078;&#x629E;</div>
@ -86,11 +92,12 @@
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">&#x30B3;&#x30ED;&#x30CB;&#x30FC;</span><span class="panel-card-state">&#x6298;&#x308A;&#x7573;&#x307F;</span></button>
<div class="panel-card-body">
<div class="stats colony-stats">
<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 class="stat"><span>雰囲気</span><strong id="statColonyMood">のんびり</strong></div>
<div class="stat"><span>個体</span><strong id="statPop">0</strong></div>
<div class="stat"><span>死亡</span><strong id="statDead">0</strong></div>
<div class="stat"><span>状態</span><strong id="statColonyMood">安定</strong></div>
<div class="stat ground-stat"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" aria-label="地面&#x3092;&#x304D;&#x308A;&#x304B;&#x3048;&#x308B;"><strong id="statGroundType">&#x571F;</strong></button></div>
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">精神</button><button data-chart="environment">&#x74B0;&#x5883;</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">個体数</button><button data-chart="life">欲求</button><button data-chart="environment">環境</button><button data-chart="objects">物量</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>
</div>
@ -151,7 +158,7 @@
<h2 id="creditsDialogTitle">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</h2>
<div class="credits-content">
<p><strong>&#x88FD;&#x4F5C;:</strong> 333</p>
<p><strong>&#x305F;&#x308A;&#x306A;&#x3044;:</strong> &#x3080;&#x3089;&#x3055;&#x304D;&#x6C0F;</p>
<p><strong>個体:</strong> &#x3080;&#x3089;&#x3055;&#x304D;&#x6C0F;</p>
<p><strong>&#x52B9;&#x679C;&#x97F3;:</strong> VOICEVOX: &#x305A;&#x3093;&#x3060;&#x3082;&#x3093;&#x3000;&#x52B9;&#x679C;&#x97F3;&#x30E9;&#x30DC;: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
<p><strong>&#x30A2;&#x30EA;&#x7D20;&#x6750;:</strong> &#x3044;&#x3089;&#x3059;&#x3068;&#x3084;: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
</div>
@ -164,7 +171,7 @@
<div id="archiveResetDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="archiveResetDialogTitle">
<div class="field-dialog-panel archive-reset-dialog-panel">
<h2 id="archiveResetDialogTitle">&#x5BB6;&#x7CFB;&#x56F3;&#x3092;&#x30EA;&#x30BB;&#x30C3;&#x30C8;&#x3057;&#x307E;&#x3059;&#x304B;&#xFF1F;</h2>
<p class="hint">&#x73FE;&#x5728;&#x306E;&#x89AA;&#x5B50;&#x95A2;&#x4FC2;&#x3068;&#x4E16;&#x4EE3;&#x60C5;&#x5831;&#x3092;&#x6D88;&#x53BB;&#x3057;&#x307E;&#x3059;&#x3002;&#x500B;&#x4F53;&#x305D;&#x306E;&#x3082;&#x306E;&#x3084;&#x5EAD;&#x306E;&#x72B6;&#x614B;&#x306F;&#x6D88;&#x3048;&#x307E;&#x305B;&#x3093;&#x3002;</p>
<p class="hint">&#x73FE;&#x5728;&#x306E;&#x89AA;&#x5B50;&#x95A2;&#x4FC2;&#x3068;&#x4E16;&#x4EE3;&#x60C5;&#x5831;&#x3092;&#x6D88;&#x53BB;&#x3057;&#x307E;&#x3059;&#x3002;&#x500B;&#x4F53;&#x305D;&#x306E;物量&#x3084;&#x5EAD;&#x306E;&#x72B6;&#x614B;&#x306F;&#x6D88;&#x3048;&#x307E;&#x305B;&#x3093;&#x3002;</p>
<div class="field-dialog-actions archive-reset-actions">
<button id="archiveResetCancelBtn" class="btn" type="button">&#x30AD;&#x30E3;&#x30F3;&#x30BB;&#x30EB;</button>
<button id="archiveResetConfirmBtn" class="btn danger" type="button">&#x30EA;&#x30BB;&#x30C3;&#x30C8;&#x3059;&#x308B;</button>
@ -174,7 +181,7 @@
<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>
<h2 id="ecologyDialogTitle">個体&#x306E;&#x751F;&#x614B;</h2>
<div id="ecologyGrid" class="ecology-grid"></div>
<div class="field-dialog-actions">
<button id="ecologyCloseBtn" class="btn" type="button">&#x9589;&#x3058;&#x308B;</button>
@ -183,67 +190,94 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=15.24.14" defer></script>
<script src="js/event_bus.js?v=15.24.14" defer></script>
<script src="js/performance_manager.js?v=15.24.14" defer></script>
<script src="js/registry_base.js?v=15.24.14" defer></script>
<script src="js/disease_registry.js?v=15.24.14" defer></script>
<script src="js/effect_registry.js?v=15.24.14" defer></script>
<script src="js/sound_pack.js?v=15.24.14" defer></script>
<script src="js/data.js?v=15.24.14" defer></script>
<script src="js/item_visual_registry.js?v=15.24.14" defer></script>
<script src="js/food_registry.js?v=15.24.14" defer></script>
<script src="js/input_mode_manager.js?v=15.24.14" defer></script>
<script src="js/math.js?v=15.24.14" defer></script>
<script src="js/physics_helpers.js?v=15.24.14" defer></script>
<script src="js/assets.js?v=15.24.14" defer></script>
<script src="js/audio.js?v=15.24.14" defer></script>
<script src="js/render.js?v=15.24.14" defer></script>
<script src="js/sim_core.js?v=15.24.14" defer></script>
<script src="js/structures.js?v=15.24.14" defer></script>
<script src="js/items.js?v=15.24.14" defer></script>
<script src="js/ants.js?v=15.24.14" defer></script>
<script src="js/health.js?v=15.24.14" defer></script>
<script src="js/tarinai.js?v=15.24.14" defer></script>
<script src="js/tarinai_identity_social.js?v=15.24.14" defer></script>
<script src="js/tarinai_action_state.js?v=15.24.14" defer></script>
<script src="js/tarinai_disease_nest.js?v=15.24.14" defer></script>
<script src="js/tarinai_item_effects.js?v=15.24.14" defer></script>
<script src="js/tarinai_needs_items.js?v=15.24.14" defer></script>
<script src="js/tarinai_social_move_life.js?v=15.24.14" defer></script>
<script src="js/tarinai_render.js?v=15.24.14" defer></script>
<script src="js/world.js?v=15.24.14" defer></script>
<script src="js/world_view.js?v=15.24.14" defer></script>
<script src="js/family_graph.js?v=15.24.14" defer></script>
<script src="js/world_family_social.js?v=15.24.14" defer></script>
<script src="js/world_combat_effects.js?v=15.24.14" defer></script>
<script src="js/world_environment.js?v=15.24.14" defer></script>
<script src="js/world_spatial_budget.js?v=15.24.14" defer></script>
<script src="js/world_ants_system.js?v=15.24.14" defer></script>
<script src="js/weather_system.js?v=15.24.14" defer></script>
<script src="js/world_update.js?v=15.24.14" defer></script>
<script src="js/world_placement_log.js?v=15.24.14" defer></script>
<script src="js/world_bootstrap.js?v=15.24.14" defer></script>
<script src="js/text_catalog.js?v=15.24.14" defer></script>
<script src="js/ui.js?v=15.24.14" defer></script>
<script src="js/ui_log.js?v=15.24.14" defer></script>
<script src="js/ui_helpers.js?v=15.24.14" defer></script>
<script src="js/ui_selected.js?v=15.24.14" defer></script>
<script src="js/snapshot_system.js?v=15.24.14" defer></script>
<script src="js/save_system.js?v=15.24.14" defer></script>
<script src="js/patch_helpers.js?v=15.24.14" defer></script>
<script src="js/freeze_system.js?v=15.24.14" defer></script>
<script src="js/relation_event_system.js?v=15.24.14" defer></script>
<script src="js/ui_layout_dialogs.js?v=15.24.14" defer></script>
<script src="js/ui_tools.js?v=15.24.14" defer></script>
<script src="js/ui_bind.js?v=15.24.14" defer></script>
<script src="js/ui_charts.js?v=15.24.14" defer></script>
<script src="js/ui_family_data.js?v=15.24.14" defer></script>
<script src="js/ui_family_async.js?v=15.24.14" defer></script>
<script src="js/ui_family_layout.js?v=15.24.14" defer></script>
<script src="js/ui_family_paths.js?v=15.24.14" defer></script>
<script src="js/ui_family_render.js?v=15.24.14" defer></script>
<script src="js/main.js?v=15.24.14" defer></script>
<script src="js/debug_tools.js?v=15.24.14" defer></script>
<script src="js/version.js?v=16.07.09" defer></script>
<script src="js/event_bus.js?v=16.07.09" defer></script>
<script src="js/registry_base.js?v=16.07.09" defer></script>
<script src="js/disease_registry.js?v=16.07.09" defer></script>
<script src="js/sound_pack.js?v=16.07.09" defer></script>
<script src="js/item_registry.js?v=16.07.09" defer></script>
<script src="js/data.js?v=16.07.09" defer></script>
<script src="js/ground_types.js?v=16.07.09" defer></script>
<script src="js/input_mode_manager.js?v=16.07.09" defer></script>
<script src="js/math.js?v=16.07.09" defer></script>
<script src="js/physics_helpers.js?v=16.07.09" defer></script>
<script src="js/assets.js?v=16.07.09" defer></script>
<script src="js/audio.js?v=16.07.09" defer></script>
<script src="js/perf_profiler.js?v=16.07.09" defer></script>
<script src="js/render.js?v=16.07.09" defer></script>
<script src="js/sim_core.js?v=16.07.09" defer></script>
<script src="js/structures.js?v=16.07.09" defer></script>
<script src="js/items.js?v=16.07.09" defer></script>
<script src="js/item_type_initializers.js?v=16.07.09" defer></script>
<script src="js/item_lifecycle_runtime.js?v=16.07.09" defer></script>
<script src="js/structure_lifecycle.js?v=16.07.09" defer></script>
<script src="js/item_render_runtime.js?v=16.07.09" defer></script>
<script src="js/ants.js?v=16.07.09" defer></script>
<script src="js/health.js?v=16.07.09" defer></script>
<script src="js/tarinai.js?v=16.07.09" defer></script>
<script src="js/tarinai_action_spec.js?v=16.07.09" defer></script>
<script src="js/tarinai_behavior_state.js?v=16.07.09" defer></script>
<script src="js/tarinai_identity_social.js?v=16.07.09" defer></script>
<script src="js/tarinai_action_state.js?v=16.07.09" defer></script>
<script src="js/tarinai_disease_nest.js?v=16.07.09" defer></script>
<script src="js/tarinai_item_effects.js?v=16.07.09" defer></script>
<script src="js/tarinai_needs_core.js?v=16.07.09" defer></script>
<script src="js/tarinai_behavior_text.js?v=16.07.09" defer></script>
<script src="js/tarinai_forced_behavior.js?v=16.07.09" defer></script>
<script src="js/tarinai_item_targeting.js?v=16.07.09" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=16.07.09" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=16.07.09" defer></script>
<script src="js/tarinai_building_behavior.js?v=16.07.09" defer></script>
<script src="js/tarinai_action_definitions.js?v=16.07.09" defer></script>
<script src="js/tarinai_needs_items.js?v=16.07.09" defer></script>
<script src="js/tarinai_social_move_life.js?v=16.07.09" defer></script>
<script src="js/tarinai_render.js?v=16.07.09" defer></script>
<script src="js/world.js?v=16.07.09" defer></script>
<script src="js/world_view.js?v=16.07.09" defer></script>
<script src="js/family_graph.js?v=16.07.09" defer></script>
<script src="js/world_family_social.js?v=16.07.09" defer></script>
<script src="js/world_combat_effects.js?v=16.07.09" defer></script>
<script src="js/world_environment.js?v=16.07.09" defer></script>
<script src="js/world_spatial_budget.js?v=16.07.09" defer></script>
<script src="js/world_ants_system.js?v=16.07.09" defer></script>
<script src="js/weather_system.js?v=16.07.09" defer></script>
<script src="js/item_update_scheduler.js?v=16.07.09" defer></script>
<script src="js/world_update_phases.js?v=16.07.09" defer></script>
<script src="js/world_update.js?v=16.07.09" defer></script>
<script src="js/world_tool_actions.js?v=16.07.09" defer></script>
<script src="js/world_placement_log.js?v=16.07.09" defer></script>
<script src="js/world_event_effects.js?v=16.07.09" defer></script>
<script src="js/world_bootstrap.js?v=16.07.09" defer></script>
<script src="js/text_catalog.js?v=16.07.09" defer></script>
<script src="js/ui.js?v=16.07.09" defer></script>
<script src="js/ui_log.js?v=16.07.09" defer></script>
<script src="js/ui_helpers.js?v=16.07.09" defer></script>
<script src="js/ui_selected.js?v=16.07.09" defer></script>
<script src="js/snapshot_system.js?v=16.07.09" defer></script>
<script src="js/restore_coordinator.js?v=16.07.09" defer></script>
<script src="js/save_codec.js?v=16.07.09" defer></script>
<script src="js/save_storage.js?v=16.07.09" defer></script>
<script src="js/save_system.js?v=16.07.09" defer></script>
<script src="js/patch_helpers.js?v=16.07.09" defer></script>
<script src="js/freeze_store.js?v=16.07.09" defer></script>
<script src="js/freeze_snapshot_adapter.js?v=16.07.09" defer></script>
<script src="js/freeze_panel.js?v=16.07.09" defer></script>
<script src="js/freeze_system.js?v=16.07.09" defer></script>
<script src="js/ui_tooltips.js?v=16.07.09" defer></script>
<script src="js/ui_layout_dialogs.js?v=16.07.09" defer></script>
<script src="js/ui_ground.js?v=16.07.09" defer></script>
<script src="js/ui_tools.js?v=16.07.09" defer></script>
<script src="js/ui_input_shared.js?v=16.07.09" defer></script>
<script src="js/ui_input_touch.js?v=16.07.09" defer></script>
<script src="js/ui_input_mouse.js?v=16.07.09" defer></script>
<script src="js/ui_bind.js?v=16.07.09" defer></script>
<script src="js/ui_charts.js?v=16.07.09" defer></script>
<script src="js/ui_family_data.js?v=16.07.09" defer></script>
<script src="js/ui_family_async.js?v=16.07.09" defer></script>
<script src="js/ui_family_layout.js?v=16.07.09" defer></script>
<script src="js/ui_family_paths.js?v=16.07.09" defer></script>
<script src="js/ui_family_render.js?v=16.07.09" defer></script>
<script src="js/main.js?v=16.07.09" defer></script>
<script src="js/debug_tools.js?v=16.07.09" defer></script>
</body>
</html>

View file

@ -94,7 +94,8 @@ class AntActor {
}
homeNest() {
return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null;
const nest = this.world?.itemById?.(this.homeId) || null;
return nest && nest.type === "ant_nest" ? nest : null;
}
adoptableNest() {
@ -165,7 +166,8 @@ class AntActor {
// filter or rank candidates here while preserving sleep-disease hauling.
let best = null;
let bestScore = Infinity;
for (const t of this.world?.tarinai || []) {
const candidates = this.world?.nearbyTarinai?.(this.x, this.y, maxDist, true) || this.world?.tarinai || [];
for (const t of candidates) {
if (!t || t.dead || t.insideNestBoxId || t.isZunchiSlave) continue;
const d = distXY(this.x, this.y, t.x, t.y);
if (d > maxDist) continue;
@ -344,10 +346,10 @@ class AntActor {
const corpse = new Item("ant_corpse", this.x, this.y);
corpse.amount = 24;
corpse.workerSprite = this.workerSpriteId();
this.world.items.push(corpse);
this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse);
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
if (home) syncAntNestCount(this.world, home);
this.world.updateItemCounts?.();
this.world.ensureItemBuckets?.("ant-corpse");
this.world.drawListDirty = true;
}
@ -370,11 +372,9 @@ class AntActor {
base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT);
if (!force && this.world.placementBlocked?.(base)) return null;
audio.antNest?.();
this.world.items.push(base);
this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1;
this.world.markTerrainDirty?.("ant-nest-built");
this.world.addItem?.(base, "ant-nest-built") || this.world.items.push(base);
this.world.emit?.("ant:nest-built", { nest: base, queen: this });
this.world.rebuildSpatial?.(true);
this.world.ensureSpatial?.("ant-nest-built");
this.world.drawListDirty = true;
this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event");
return base;

View file

@ -10,8 +10,11 @@ const stainOverlayCache = new Map();
const SPRITE_CACHE_LIMIT = 128;
const STAIN_CACHE_LIMIT = 64;
const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]);
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]);
const SUNBATH_IMAGE_IDS = new Set((typeof SPRITES !== "undefined" ? SPRITES : [])
.map(asset => asset?.id || "")
.filter(id => String(id).startsWith("sunbath")));
const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
function trimCanvasCache(cache, limit = 128) {
while (cache.size > limit) {

View file

@ -107,27 +107,11 @@ const audio = {
categoryOn(category = "ops") {
return Boolean(this.categoryEnabled[category] ?? true);
},
gainFor(category = "ops", base = 1) {
return Math.max(0.1, Number(base) || 1) * (this.categoryGain?.[category] ?? 1);
},
sampleVolumeFor(key = "") {
const category = this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops");
const base = category === "voice" ? 0.95 : 1.0;
return Math.min(1, base * Math.max(1, (this.categoryGain?.[category] ?? 1) * 0.35) * (this.idGain?.[key] ?? 1));
},
distanceGain(opts = {}) {
if (!opts || opts.category === "notify" || !Number.isFinite(opts.x) || !Number.isFinite(opts.y)) return 1;
const w = window.world;
if (!w || !w.worldToScreen) return 1;
const p = w.worldToScreen(opts.x, opts.y);
const margin = 140;
const vw = w.viewportW || w.w || 1000;
const vh = w.viewportH || w.h || 720;
if (p.x < -margin || p.y < -margin || p.x > vw + margin || p.y > vh + margin) return 0;
const cx = vw / 2, cy = vh / 2;
const d = Math.hypot(p.x - cx, p.y - cy);
return clamp(1 - d / Math.max(vw, vh) * 0.65, 0.35, 1);
},
beginVoice(dur = 0.1) {
this.activeVoices = (this.activeVoices || 0) + 1;
setTimeout(() => { this.activeVoices = Math.max(0, (this.activeVoices || 0) - 1); }, Math.max(60, dur * 1000 + 80));
@ -137,11 +121,6 @@ const audio = {
this.categoryEnabled[category] = Boolean(on);
this.saveSettings();
},
toggleCategory(category) {
if (!(category in this.categoryEnabled)) return false;
this.setCategory(category, !this.categoryEnabled[category]);
return this.categoryEnabled[category];
},
setEnabled(on) {
this.enabled = Boolean(on);
if (this.enabled) this.ensure();
@ -298,6 +277,7 @@ const audio = {
if (s.includes("Zzz")) return this.playSample("sfx_sleep", 1.25, "voice") || this.play("sfx_sleep", { category: "voice", minGap: 1.25 });
if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("voice_flee", 0.70, "voice") || this.play("sfx_damage_heavy", { category: "voice", minGap: 0.70 });
if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("voice_hau", 0.45, "voice") || this.play("sfx_wake", { category: "voice", minGap: 0.45 });
if (s.includes("\u307d\u304b")) return this.playSample("voice_sunbath", 0.95, "voice") || this.play("sfx_heal", { category: "voice", minGap: 0.95 });
return false;
},
eat() { if (!this.playSample("sfx_eat", 0.20, "voice")) this.play("sfx_eat", { category: "voice", minGap: 0.20 }); },
@ -323,7 +303,6 @@ const audio = {
grab() { this.play("sfx_grab", { category: "ops", minGap: 0.08 }); },
drop() { this.play("sfx_drop", { category: "ops", minGap: 0.08 }); },
waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); },
waterClean() { this.play("sfx_water_clean", { category: "ops", minGap: 0.22 }); },
ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); },
stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); },
genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); },

View file

@ -46,150 +46,6 @@ const DECOR_ASSETS = [
{ id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" },
];
const TOOL_DEFINITIONS = Object.freeze({
observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\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: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" },
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" },
laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" },
protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" },
niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" },
ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" },
mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" },
mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" },
giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" },
dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" },
zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" },
grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" },
stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" },
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026文字×3行まで\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "食べ物や薬をセットすると、自動供給してくれる。" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true },
trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
});
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo"]),
});
function itemHasTrait(type = "", trait = "") {
return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || "")));
}
function itemTraitsFor(type = "") {
const out = [];
for (const [trait, set] of Object.entries(ITEM_TRAITS)) if (set.has(String(type || ""))) out.push(trait);
return out;
}
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
function lodgedPinFor(tarinai = null, worldRef = null) {
if (!tarinai?.stuckPushpinId) return null;
const items = worldRef?.items || tarinai.world?.items || [];
return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null;
}
function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); }
function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); }
const PIN_BEHAVIORS = Object.freeze({
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
});
function pinBehaviorFor(type = "") {
return PIN_BEHAVIORS[String(type || "")] || null;
}
const TOOL_CATEGORIES = Object.freeze([
{ id: "operate", label: "\u64cd\u4f5c\u30fb\u89b3\u5bdf", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269\u30fb\u6c34\u30fb\u8349", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] },
{ id: "medicine", label: "\u85ac\u30fb\u500b\u4f53\u52b9\u679c", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator"] },
{ id: "hazard", label: "\u5371\u967a\u30fb\u969c\u5bb3\u7269", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h"] },
]);
function toolCategories() {
return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0));
}
function toolDefinition(id = "") {
return TOOL_DEFINITIONS[id] || null;
}
function toolLabel(id = "") {
const def = toolDefinition(id);
return def?.label || id || "";
}
function toolItemType(id = "") {
const def = toolDefinition(id);
return def?.placeable ? (def.itemType || def.id) : null;
}
function itemDefinition(type = "") {
return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null;
}
function itemRadiusFor(type = "", fallback = 12) {
return itemDefinition(type)?.radius ?? fallback;
}
function itemAmountFor(type = "", fallback = 80) {
return itemDefinition(type)?.amount ?? fallback;
}
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
const TOOL_SIZE_LABELS = Object.freeze({ small: "小", medium: "中", large: "大" });
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
function normalizedToolSizeFor(worldRef = null, type = "") {
const def = itemDefinition(type);
if (!def?.scalable) return "medium";
const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium";
return TOOL_SIZE_ORDER.includes(value) ? value : "medium";
}
function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; }
function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; }
function scalableToolIds() {
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
}
function toolTipsFromDefinitions() {
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
}
const ALPHA_BOUNDS = {
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 },
angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
@ -235,13 +91,10 @@ const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c
const CONFIG = {
initialPopulation: 18,
maxPopulation: Number.MAX_SAFE_INTEGER,
itemLimit: 260,
fenceLimit: 72,
maxFenceCollisionChecks: 18,
zunchiLimit: 56,
traceLimit: 64,
splatLimit: 48,
effectLimit: 78,
grassLimit: 99,
grassLimitsByField: { cage: 19, garden: 99, park: 299 },
worldPadding: 30,
hungerPerMinute: 36.0,
lonelinessPerMinute: 7.6,
@ -255,7 +108,6 @@ const CONFIG = {
weatherChangeMin: 46,
weatherChangeMax: 96,
bubbleLimit: 32,
grassLimit: 180,
highPopulationMode: 70,
ultraPopulationMode: 120,
};

View file

@ -19,20 +19,31 @@
window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; });
window.setInterval(() => {
const w = window.world;
const perf = window.TarinaiPerformance?.debugSnapshot(w) || {};
const input = window.TarinaiInputMode?.snapshot?.() || {};
const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {};
const behaviorCounts = diag.behavior?.counts || {};
const topBehaviors = Object.entries(behaviorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}:${n}`).join(", ");
const perf = diag.perf || window.TarinaiPerf?.snapshot?.() || {};
const perfTop = (perf.entries || []).slice(0, 5).map(e => `${e.label}:${e.avg}ms`).join(", ");
panel.textContent = [
`tarinai v${window.TARINAI_VERSION || "?"}`,
`fps ${perf.fps ?? window.__tarinaiFps ?? "?"} perf ${perf.level ?? "?"}`,
`fps ${window.__tarinaiFps ?? "?"}`,
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} dpr ${diag.runtime?.dpr || uiCache?.canvasDpr || "?"}`,
`items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`,
`effects ${(w?.effects || []).length}`,
`terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`,
`drawList ${(w?.drawList || []).length} logs ${(w?.logs || []).length}`,
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
`drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`,
`item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`,
`work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`,
`scheduler heap:${diag.scheduler?.heap ?? 0} realtime:${diag.scheduler?.realtime ?? 0} ran:${diag.scheduler?.lastRan ?? 0} rebuilds:${diag.scheduler?.rebuilds ?? 0}`,
`perf ${perf.tier || "?"} dprScale:${perf.dprScale || 1} ${perfTop || "-"}`,
`behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`,
`dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`,
`sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`,
`input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)} uaMobile:${Boolean(input.userAgentMobile)}`,
`audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`,
`effects ${window.TarinaiEffectRegistry?.debugSnapshot?.().length || 0} registered`,
`effects ${window.TarinaiItemRegistry?.effect?.debugSnapshot?.().length || 0} registered`,
`events ${lastEvents.join(", ")}`,
].join("\n");
}, 500);

View file

@ -1,280 +0,0 @@
"use strict";
(function (global) {
const rawDefinitions = {
laxative: {
label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60,
tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`,
},
ammo: {
label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18,
tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`,
},
protein: {
label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d",
},
niteropu: {
label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d",
},
mercury: {
label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply",
tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`,
},
giant_drug: {
label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
dwarf_drug: {
label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
love_mochi: {
label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
fight_mochi: {
label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
sleep_drug: {
label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"],
},
mystery_drug: {
label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"],
},
zunda_juice: {
label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"],
},
disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] },
disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] },
};
Object.assign(rawDefinitions.laxative, {
modifiers: { hungerReliefMultiplier: 0 },
tickInterval: 3,
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.ammo, {
modifiers: { speedMultiplier: 10 },
visualEffectId: "fall",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.protein, {
modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 },
visualEffectId: "fall_up_red",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.niteropu, {
modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 },
visualEffectId: "fall_down_purple",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.mercury, {
modifiers: { lifeMultiplierPerUse: 1.3 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.giant_drug, {
modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.dwarf_drug, {
modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) {
Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" });
}
Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false });
Object.assign(rawDefinitions.love_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
if (context.source === "eating") {
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" });
}
return true;
},
});
Object.assign(rawDefinitions.fight_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
if (context.source === "eating") {
tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32);
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" });
}
return true;
},
});
Object.assign(rawDefinitions.sleep_drug, {
onApply(tarinai, context = {}) {
if (context.source === "eating") {
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 });
tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100);
tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18);
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5);
const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075);
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" });
if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true;
return true;
}
return tarinai.infectSleepDisease?.(context.item || null) || true;
},
});
Object.assign(rawDefinitions.laxative, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1)));
},
onTick(tarinai) {
tarinai.forceLaxativePoop?.();
return true;
},
});
Object.assign(rawDefinitions.ammo, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1)));
},
});
Object.assign(rawDefinitions.protein, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); },
});
Object.assign(rawDefinitions.niteropu, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); },
});
Object.assign(rawDefinitions.mercury, {
onApply(tarinai) { return tarinai.setMercuryEffect?.(); },
});
Object.assign(rawDefinitions.giant_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.dwarf_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.zunda_juice, {
onApply(tarinai) { return tarinai.clearAllItemEffects?.(); },
});
Object.assign(rawDefinitions.disease_zunchi, {
onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; },
});
Object.assign(rawDefinitions.disease_sleep, {
onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_explosion, {
onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_fight, {
onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; },
});
class EffectRegistry extends global.TarinaiRegistryBase {
label(id = "") { return super.label(id, id || "\u4e0d\u660e"); }
color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
display(id = "", timer = 0, tarinai = null) {
const def = this.get(id);
if (!def) return "";
return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || "");
}
apply(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onApply !== "function") return false;
return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
tick(id = "", tarinai = null, dt = 0, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onTick !== "function") return false;
return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this }));
}
remove(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onRemove !== "function") return false;
return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
modifier(id = "", key = "", fallback = 1) {
const value = this.get(id)?.modifiers?.[key];
return Number.isFinite(value) ? value : fallback;
}
timerValue(tarinai = null, id = "") {
const def = this.get(id);
if (!def || !tarinai) return 0;
if (def.timerProp) return Number(tarinai[def.timerProp] || 0);
if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0);
return 0;
}
isActive(tarinai = null, id = "") {
if (!tarinai) return false;
const def = this.get(id);
if (!def) return false;
if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id;
if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id;
if (id === "mercury") return tarinai.lifeItemMode === "mercury";
return this.timerValue(tarinai, id) > 0.04;
}
activeSummary(tarinai = null) {
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"];
return order
.filter(id => this.isActive(tarinai, id))
.map(id => ({
id,
label: this.label(id),
detail: this.display(id, this.timerValue(tarinai, id), tarinai),
}));
}
labels() { return super.labels(); }
colors() { return super.colors(); }
randomPool() {
const itemEffects = [...this.defs.entries()]
.filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease")))
.map(([id]) => id)
.filter(id => id !== "mystery_drug" && id !== "zunda_juice");
const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || [];
return [...new Set([...itemEffects, ...diseases])];
}
permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); }
timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); }
debugSnapshot() {
return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" }));
}
}
const registry = new EffectRegistry(rawDefinitions);
global.TarinaiEffectRegistry = registry;
global.TARINAI_EFFECT_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,57 +0,0 @@
"use strict";
(function (global) {
const rawDefinitions = {
food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 },
sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] },
love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" },
fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" },
sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" },
laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true },
protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" },
niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" },
ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" },
mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" },
mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" },
giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" },
dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" },
zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true },
grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 }
};
class FoodRegistry extends global.TarinaiRegistryBase {
servingTypes() {
return [...this.defs.entries()]
.filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest))
.map(([id]) => id);
}
typesByInterest(...interests) {
const wanted = new Set(interests.flat().filter(Boolean));
return [...this.defs.entries()]
.filter(([, def]) => wanted.has(def.interest))
.map(([id]) => id);
}
paramEffectTypes() {
return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id);
}
nutrition(type = "", fallback = 0) {
const value = this.get(type)?.nutrition;
return Number.isFinite(value) ? value : fallback;
}
hungerRelief(type = "", fallback = 0) {
const value = this.get(type)?.hungerRelief;
return Number.isFinite(value) ? value : fallback;
}
effectId(type = "") { return this.get(type)?.effectId || ""; }
passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; }
hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; }
debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); }
}
const registry = new FoodRegistry(rawDefinitions);
global.TarinaiFoodRegistry = registry;
global.TARINAI_FOOD_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);

162
js/freeze_panel.js Normal file
View file

@ -0,0 +1,162 @@
"use strict";
(function (global) {
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
function spritePathFor(type = "smile") {
const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []));
const meta = sprites.find(s => s.id === type) || sprites[0];
return meta?.path || "assets/sprites/tarinai_01_smile.webp";
}
function ensureStyles() {
if (document.getElementById("freezeSystemStyles")) return;
const style = document.createElement("style");
style.id = "freezeSystemStyles";
style.textContent = `
.frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; }
.frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; }
.frozen-panel-toggle::after { content:"\u6298\u308a\u7573\u307f"; font-size:11px; opacity:.62; font-weight:700; }
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"\u5c55\u958b"; }
.frozen-panel-count { font-size:12px; opacity:.72; }
.frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; }
.frozen-panel.collapsed .frozen-list { display:none; }
.frozen-panel.collapsed { padding-bottom:7px; }
.frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); }
.frozen-card:active { cursor:grabbing; }
.frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); }
.frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); }
.frozen-card-main { min-width:0; flex:1; }
.frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; }
canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; }
`;
document.head.appendChild(style);
}
function ensurePanel() {
ensureStyles();
let panel = document.getElementById("frozenPanel");
if (panel) return panel;
panel = document.createElement("section");
panel.id = "frozenPanel";
panel.className = "frozen-panel";
panel.innerHTML = `
<div class="frozen-panel-title"><button id="frozenPanelToggle" class="frozen-panel-toggle" type="button" aria-expanded="false"><span>\u51b7\u51cd\u5eab</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></button></div>
<div id="frozenList" class="frozen-list"></div>
`;
const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed");
const collapsedByDefault = storedCollapse !== "0";
if (collapsedByDefault) {
panel.classList.add("collapsed");
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false");
} else {
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true");
}
const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body');
const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette");
if (operateBody) operateBody.appendChild(panel);
else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
else document.body.appendChild(panel);
return panel;
}
function render(entries = []) {
const panel = ensurePanel();
const listEl = panel.querySelector("#frozenList");
const countEl = panel.querySelector("#frozenPanelCount");
const list = Array.isArray(entries) ? entries : [];
if (countEl) countEl.textContent = `${list.length}`;
if (!listEl) return;
if (!list.length) {
listEl.innerHTML = `<div class="frozen-empty">\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002</div>`;
return;
}
listEl.innerHTML = list.map(entry => {
const data = entry.data || entry;
const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : "";
const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || "");
return `<button class="frozen-card" type="button" draggable="true" data-frozen-id="${id}" title="\u30c9\u30e9\u30c3\u30b0\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3059">
<img src="${htmlEscape(spritePathFor(data.type || "smile"))}" alt="">
<span class="frozen-card-main"><span class="frozen-card-name">${htmlEscape(data.name || "\u305f\u308a\u306a\u3044")}</span><span class="frozen-card-meta">${htmlEscape(gen)}</span></span>
</button>`;
}).join("");
}
function markPending(id = "") {
const panel = ensurePanel();
for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c.dataset.frozenId === id);
}
function clearPending() {
document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw"));
}
function bind({ onSelect, onDrop } = {}) {
const panel = ensurePanel();
if (panel.dataset.freezeBound !== "1") {
panel.dataset.freezeBound = "1";
panel.addEventListener("dragstart", (e) => {
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
const id = card.dataset.frozenId || "";
e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`);
e.dataTransfer?.setData("application/x-tarinai-frozen", id);
e.dataTransfer.effectAllowed = "move";
global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready");
});
panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready"));
panel.addEventListener("click", (e) => {
const toggle = e.target.closest("#frozenPanelToggle");
if (toggle) {
const collapsed = panel.classList.toggle("collapsed");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {}
global.audio?.uiFold?.();
return;
}
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
const id = card.dataset.frozenId || "";
markPending(id);
onSelect?.(id);
});
}
const canvas = global.canvas || document.getElementById("gameCanvas");
if (canvas && canvas.dataset.freezeDropBound !== "1") {
canvas.dataset.freezeDropBound = "1";
canvas.addEventListener("dragover", (e) => {
const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain");
if (!text) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
canvas.classList.add("freeze-drop-ready");
});
canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready"));
canvas.addEventListener("drop", (e) => {
const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, "");
if (!id) return;
e.preventDefault();
canvas.classList.remove("freeze-drop-ready");
const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const worldRef = global.world;
return worldRef?.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true };
})();
onDrop?.(id, p.x, p.y);
});
}
}
global.TarinaiFreezePanel = {
ensurePanel,
render,
bind,
markPending,
clearPending,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,97 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_snapshot_adapter.js");
const clonePlain = Snapshot.clonePlain;
function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) {
const data = Snapshot.copyOwnData(entity, skip);
const targetRef = Snapshot.refFor(worldRef, entity?.target);
const panicTargetRef = Snapshot.refFor(worldRef, entity?.panicTarget);
const targetRefObject = Snapshot.refFor(worldRef, entity?.targetRef);
if (targetRef) data.__targetRef = targetRef;
if (panicTargetRef) data.__panicTargetRef = panicTargetRef;
if (targetRefObject) data.__targetRefObject = targetRefObject;
return data;
}
function restoreTarinaiData(worldRef, data, opts = {}) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
const t = new TarinaiClass(worldRef, data || {});
Snapshot.applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"]));
t.world = worldRef;
if (opts.clearTransient !== false) {
t.target = null;
t.panicTarget = null;
t.sleeping = false;
t.insideNestBoxId = "";
}
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
function makeEntry(t, worldRef = global.world) {
const data = snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"]));
data.sleeping = false;
data.insideNestBoxId = "";
data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle");
data.targetKey = "";
const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`);
return {
freezeId: id,
frozenAt: Date.now(),
worldTime: worldRef?.time || 0,
name: t.name || "\u305f\u308a\u306a\u3044",
familyKey: t.familyKey || "",
liveId: t.id || "",
liveToken: t.liveToken || 0,
type: t.type || "smile",
data,
};
}
function updateLiveIdAfterThaw(worldRef, t) {
if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map();
const id = t.id || "";
const conflict = id && worldRef.liveTarinai.has(id);
if (!id || conflict) {
worldRef.assignTarinaiLiveId?.(t);
return;
}
worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t });
const m = /^t(\d+)$/.exec(id);
if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1);
worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0);
}
function thawEntry(entry, x, y, worldRef = global.world) {
const data = clonePlain(entry?.data || entry) || {};
const t = restoreTarinaiData(worldRef, data, { clearTransient: true });
t.dead = false;
t.deathReason = "";
t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
t.vx = 0;
t.vy = 0;
t.target = null;
t.panicTarget = null;
t.insideNestBoxId = "";
t.sleeping = false;
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
t.thought = "\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b";
t.frozenAt = 0;
updateLiveIdAfterThaw(worldRef, t);
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
global.TarinaiFreezeSnapshotAdapter = {
snapshotEntity,
restoreTarinaiData,
makeEntry,
thawEntry,
};
})(typeof window !== "undefined" ? window : globalThis);

45
js/freeze_store.js Normal file
View file

@ -0,0 +1,45 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_store.js");
const STORAGE_KEY = "tarinai_frozen_storage_v1";
const clonePlain = Snapshot.clonePlain;
function frozenList(worldRef = global.world) {
if (!worldRef) return [];
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
return worldRef.frozenTarinai;
}
function save(worldRef = global.world) {
try {
global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef)));
} catch (_) {}
}
function load(worldRef = global.world) {
if (!worldRef) return [];
try {
const raw = global.localStorage?.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || [];
} catch (_) {
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
}
return frozenList(worldRef);
}
function ensure(worldRef = global.world) {
if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = [];
return frozenList(worldRef);
}
global.TarinaiFreezeStore = {
STORAGE_KEY,
frozenList,
load,
save,
ensure,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,333 +1,103 @@
"use strict";
(function (global) {
const STORAGE_KEY = "tarinai_frozen_storage_v1";
const MAX_PENDING_LOSSES = 16;
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_system.js");
const clonePlain = Snapshot.clonePlain;
const Store = global.TarinaiFreezeStore;
const Adapter = global.TarinaiFreezeSnapshotAdapter;
const Panel = global.TarinaiFreezePanel;
if (!Store || !Adapter || !Panel) throw new Error("freeze_system.js dependencies are not available");
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
let pendingThawId = "";
function frozenList(worldRef = global.world) {
if (!worldRef) return [];
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
return worldRef.frozenTarinai;
}
function saveFrozenStore(worldRef = global.world) {
try {
global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef)));
} catch (_) {}
}
function loadFrozenStore(worldRef = global.world) {
if (!worldRef) return [];
try {
const raw = global.localStorage?.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || [];
} catch (_) {
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
}
return frozenList(worldRef);
}
function spritePathFor(type = "smile") {
const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []));
const meta = sprites.find(s => s.id === type) || sprites[0];
return meta?.path || "assets/sprites/tarinai_01_smile.webp";
}
function ensureStyles() {
if (document.getElementById("freezeSystemStyles")) return;
const style = document.createElement("style");
style.id = "freezeSystemStyles";
style.textContent = `
.frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; }
.frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; }
.frozen-panel-toggle::after { content:"折り畳み"; font-size:11px; opacity:.62; font-weight:700; }
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"展開"; }
.frozen-panel-count { font-size:12px; opacity:.72; }
.frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; }
.frozen-panel.collapsed .frozen-list { display:none; }
.frozen-panel.collapsed { padding-bottom:7px; }
.frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); }
.frozen-card:active { cursor:grabbing; }
.frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); }
.frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); }
.frozen-card-main { min-width:0; flex:1; }
.frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; }
canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; }
`;
document.head.appendChild(style);
}
function ensurePanel() {
ensureStyles();
let panel = document.getElementById("frozenPanel");
if (panel) return panel;
panel = document.createElement("section");
panel.id = "frozenPanel";
panel.className = "frozen-panel";
panel.innerHTML = `
<div class="frozen-panel-title"><button id="frozenPanelToggle" class="frozen-panel-toggle" type="button" aria-expanded="false"><span>\u51b7\u51cd\u5eab</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></button></div>
<div id="frozenList" class="frozen-list"></div>
`;
const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed");
const collapsedByDefault = storedCollapse !== "0";
if (collapsedByDefault) {
panel.classList.add("collapsed");
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false");
} else {
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true");
}
const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body');
const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette");
if (operateBody) operateBody.appendChild(panel);
else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
else document.body.appendChild(panel);
return panel;
return Store.frozenList(worldRef);
}
function renderFrozenPanel(worldRef = global.world) {
const panel = ensurePanel();
const listEl = panel.querySelector("#frozenList");
const countEl = panel.querySelector("#frozenPanelCount");
const list = frozenList(worldRef);
if (countEl) countEl.textContent = `${list.length}`;
if (!listEl) return;
if (!list.length) {
listEl.innerHTML = `<div class="frozen-empty">\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002</div>`;
return;
}
listEl.innerHTML = list.map(entry => {
const data = entry.data || entry;
const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / \u8a03\u5831${entry.pendingLosses.length}` : "";
const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : "";
const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || "");
return `<button class="frozen-card" type="button" draggable="true" data-frozen-id="${id}" title="\u30c9\u30e9\u30c3\u30b0\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3059">
<img src="${htmlEscape(spritePathFor(data.type || "smile"))}" alt="">
<span class="frozen-card-main"><span class="frozen-card-name">${htmlEscape(data.name || "\u305f\u308a\u306a\u3044")}</span><span class="frozen-card-meta">${htmlEscape(gen)}${htmlEscape(losses)}</span></span>
</button>`;
}).join("");
Panel.render(frozenList(worldRef));
}
function makeFrozenEntry(t, worldRef = global.world) {
const data = Snapshot.snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"]));
data.sleeping = false;
data.insideNestBoxId = "";
data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle");
data.targetKey = "";
const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`);
return {
freezeId: id,
frozenAt: Date.now(),
worldTime: worldRef?.time || 0,
name: t.name || "\u305f\u308a\u306a\u3044",
familyKey: t.familyKey || "",
liveId: t.id || "",
liveToken: t.liveToken || 0,
type: t.type || "smile",
data,
pendingLosses: [],
};
function saveFrozenStore(worldRef = global.world) {
Store.save(worldRef);
}
function findTarinaiAt(worldRef, x, y) {
const arr = worldRef?.tarinai || [];
for (let i = arr.length - 1; i >= 0; i--) {
const t = arr[i];
if (!t || t.dead) continue;
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.35) return t;
}
return null;
function loadFrozenStore(worldRef = global.world) {
return Store.load(worldRef);
}
function freezeTarinai(t, worldRef = global.world) {
if (!t || t.dead || !worldRef) return false;
const entry = makeFrozenEntry(t, worldRef);
worldRef.clearLiveReferencesTo?.(t);
if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id);
worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t);
if (worldRef.selected === t) worldRef.selected = null;
frozenList(worldRef).push(entry);
function refreshWorldAfterFreezeChange(worldRef = global.world, selected = null) {
if (!worldRef) return;
if (selected !== undefined) worldRef.selected = selected;
worldRef.drawListDirty = true;
worldRef.familyTreeDirty = true;
worldRef.updateItemCounts?.();
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
global.showToast?.(`${t.name}を冷凍保存しました。`);
worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] });
global.renderStats?.();
global.render?.();
worldRef.emit?.("freeze:changed", { world: worldRef, selected, renderWorld: true });
}
function freezeTarinai(t, worldRef = global.world) {
if (!t || t.dead || !worldRef) return false;
const entry = Adapter.makeEntry(t, worldRef);
worldRef.clearLiveReferencesTo?.(t);
if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id);
worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t);
if (worldRef.selected === t) worldRef.selected = null;
frozenList(worldRef).push(entry);
refreshWorldAfterFreezeChange(worldRef, worldRef.selected);
global.showToast?.(`${t.name}\u3092\u51b7\u51cd\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3057\u305f\u3002`, "observe", { participants: [t] });
worldRef.emit?.("tarinai:frozen", { entry, tarinai: t });
return true;
}
function updateLiveIdAfterThaw(worldRef, t) {
if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map();
const id = t.id || "";
const conflict = id && worldRef.liveTarinai.has(id);
if (!id || conflict) {
worldRef.assignTarinaiLiveId?.(t);
return;
}
worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t });
const m = /^t(\d+)$/.exec(id);
if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1);
worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0);
}
function applyPendingLossesOnThaw(t, entry, worldRef) {
const losses = Array.isArray(entry.pendingLosses) ? entry.pendingLosses : [];
if (!losses.length) return;
const unique = [];
const seen = new Set();
for (const loss of losses) {
const key = loss.familyKey || loss.liveId || loss.name || JSON.stringify(loss);
if (seen.has(key)) continue;
seen.add(key);
unique.push(loss);
}
const relPriority = unique.some(l => l.relation === "親" || l.relation === "子") ? 1 : 0;
const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8);
if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" });
else {
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 });
t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35));
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true });
}
t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)");
t.recordChangeCause?.("冷凍中の\u8a03\u5831", "ストレス", { value: stress });
t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death");
worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] });
}
function thawFrozen(freezeId, x, y, worldRef = global.world) {
const list = frozenList(worldRef);
const index = list.findIndex(e => (e.freezeId || e.id) === freezeId);
if (index < 0 || !worldRef) return false;
const entry = list[index];
const data = clonePlain(entry.data || entry) || {};
let t = null;
try {
t = Snapshot.restoreTarinaiData(worldRef, data, { clearTransient: true });
t = Adapter.thawEntry(entry, x, y, worldRef);
} catch (_) {
return false;
}
t.dead = false;
t.deathReason = "";
t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
t.vx = 0;
t.vy = 0;
t.target = null;
t.panicTarget = null;
t.insideNestBoxId = "";
t.sleeping = false;
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("解凍されて少し驚いている");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
t.thought = "解凍されて少し驚いている";
t.frozenAt = 0;
updateLiveIdAfterThaw(worldRef, t);
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
worldRef.tarinai.push(t);
worldRef.recordFamily?.(t);
applyPendingLossesOnThaw(t, entry, worldRef);
list.splice(index, 1);
worldRef.selected = t;
worldRef.drawListDirty = true;
worldRef.familyTreeDirty = true;
worldRef.updateItemCounts?.();
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
global.showToast?.(`${t.name}を解凍しました。`);
worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] });
global.renderStats?.();
global.render?.();
refreshWorldAfterFreezeChange(worldRef, t);
global.showToast?.(`${t.name}\u3092\u89e3\u51cd\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u89e3\u51cd\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3057\u305f\u3002`, "birth", { participants: [t] });
worldRef.emit?.("tarinai:thawed", { entry, tarinai: t });
return true;
}
function bindFreezeSystem() {
const worldRef = global.world;
loadFrozenStore(worldRef);
renderFrozenPanel(worldRef);
const panel = ensurePanel();
if (panel.dataset.freezeBound !== "1") {
panel.dataset.freezeBound = "1";
panel.addEventListener("dragstart", (e) => {
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
const id = card.dataset.frozenId || "";
e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`);
e.dataTransfer?.setData("application/x-tarinai-frozen", id);
e.dataTransfer.effectAllowed = "move";
global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready");
});
panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready"));
panel.addEventListener("click", (e) => {
const toggle = e.target.closest("#frozenPanelToggle");
if (toggle) {
const collapsed = panel.classList.toggle("collapsed");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {}
global.audio?.uiFold?.();
return;
}
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
pendingThawId = card.dataset.frozenId || "";
for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card);
global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。");
});
}
const canvas = global.canvas || document.getElementById("gameCanvas");
if (canvas && canvas.dataset.freezeDropBound !== "1") {
canvas.dataset.freezeDropBound = "1";
canvas.addEventListener("dragover", (e) => {
const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain");
if (!text) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
canvas.classList.add("freeze-drop-ready");
});
canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready"));
canvas.addEventListener("drop", (e) => {
const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, "");
if (!id) return;
e.preventDefault();
canvas.classList.remove("freeze-drop-ready");
const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true };
})();
thawFrozen(id, p.x, p.y, worldRef);
});
}
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
Panel.bind({
onSelect(id) {
pendingThawId = id || "";
global.showToast?.("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u89e3\u51cd\u3057\u307e\u3059\u3002\u30c9\u30e9\u30c3\u30b0&\u30c9\u30ed\u30c3\u30d7\u3067\u3082\u914d\u7f6e\u3067\u304d\u307e\u3059\u3002");
},
onDrop(id, x, y) {
thawFrozen(id, x, y, global.world);
},
});
}
let pendingThawId = "";
function tryPendingThawAt(x, y, worldRef = global.world) {
if (!pendingThawId) return false;
const id = pendingThawId;
pendingThawId = "";
document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw"));
Panel.clearPending();
return thawFrozen(id, x, y, worldRef);
}
function afterSnapshotRestored(worldRef = global.world) {
if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = [];
function afterWorldRestored(worldRef = global.world) {
Store.ensure(worldRef);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
}
function patchWorldPrototype() {
@ -355,6 +125,6 @@
frozenList,
loadFrozenStore,
saveFrozenStore,
afterSnapshotRestored,
afterWorldRestored,
};
})(typeof window !== "undefined" ? window : globalThis);

76
js/ground_types.js Normal file
View file

@ -0,0 +1,76 @@
"use strict";
const GROUND_TYPE_ORDER = Object.freeze(["soil", "concrete", "blanket", "foot_massage"]);
const GROUND_TYPES = Object.freeze({
soil: Object.freeze({ id: "soil", label: "土", description: "いつものじめん", stressMultiplier: 1.0, grassMultiplier: 1.0 }),
concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassLimit: 0 }),
blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0 }),
foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0 }),
});
function groundDefinition(id = "soil") {
return GROUND_TYPES[id] || GROUND_TYPES.soil;
}
function groundExists(id = "soil") {
return Boolean(GROUND_TYPES[id]);
}
function groundLabel(id = "soil") {
return groundDefinition(id).label || "土";
}
function groundDescription(id = "soil") {
return groundDefinition(id).description || "";
}
function normalizedGroundStressMultiplier(id = "soil") {
const mult = Number(groundDefinition(id).stressMultiplier);
return Number.isFinite(mult) && mult > 0 ? mult : 1;
}
function normalizedGroundGrassMultiplier(id = "soil") {
const mult = Number(groundDefinition(id).grassMultiplier);
return Number.isFinite(mult) && mult >= 0 ? mult : 1;
}
function groundLimitForField(fieldType = "garden") {
const config = typeof CONFIG !== "undefined" ? CONFIG : null;
const byField = config?.grassLimitsByField || { cage: 19, garden: 99, park: 299 };
const fieldLimit = byField[fieldType || "garden"];
return Number(Number.isFinite(fieldLimit) ? fieldLimit : (config?.grassLimit ?? 99));
}
function groundGrassLimit(id = "soil", fieldType = "garden") {
const def = groundDefinition(id);
if (Number.isFinite(def.grassLimit)) return Math.max(0, Math.floor(Number(def.grassLimit)));
const mult = normalizedGroundGrassMultiplier(def.id);
if (mult <= 0) return 0;
const limit = groundLimitForField(fieldType) * mult;
return Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity;
}
function nextGroundTypeId(current = "soil") {
const index = GROUND_TYPE_ORDER.indexOf(current || "soil");
return GROUND_TYPE_ORDER[(index + 1 + GROUND_TYPE_ORDER.length) % GROUND_TYPE_ORDER.length] || "soil";
}
function groundTooltipText(id = "soil") {
const def = groundDefinition(id);
const desc = def.description || "クリックで切り替え";
return `${def.label || "じめん"}\n${desc}`;
}
window.TarinaiGround = Object.freeze({
definitions: GROUND_TYPES,
order: GROUND_TYPE_ORDER,
definition: groundDefinition,
exists: groundExists,
label: groundLabel,
description: groundDescription,
stressMultiplier: normalizedGroundStressMultiplier,
grassMultiplier: normalizedGroundGrassMultiplier,
grassLimit: groundGrassLimit,
nextId: nextGroundTypeId,
tooltipText: groundTooltipText,
});

View file

@ -134,7 +134,7 @@ const HEALTH = (() => {
if (actualLoss > 0.01) {
cause = rememberDamage(t, actualLoss, reason);
t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason });
const plushie = (t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
const plushie = (t.world?.itemsOfType?.("plushie") || t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null);
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure();
audio.damage?.(actualLoss);

View file

@ -0,0 +1,626 @@
"use strict";
// Item update/lifecycle and type-specific runtime methods.
const DUPLICATOR_STORABLE_TYPES = new Set([
"food", "sweet", "love_mochi", "fight_mochi", "sleep_drug",
"laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug",
"zunda_juice", "grass"
]);
function duplicatorLoadTypeForItem(item) {
if (!item || item.dead || item.type === "duplicator") return "";
const type = String(item.type || "");
if (!type || type === "water_bowl" || type === "water") return "";
// Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が
// serving-food 判定や medicine role の揺れに左右されず複製機へ入る。
if (DUPLICATOR_STORABLE_TYPES.has(type)) return type;
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return type;
if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type;
const foodDef = window.TarinaiItemRegistry?.food?.get?.(type);
if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type;
if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type;
return "";
}
function syncDuplicatorRoles(item) {
if (!item || item.type !== "duplicator") return;
const type = String(item.storedFoodType || "");
item.roles.food = Boolean(type);
item.roles.medicine = Boolean(type && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))));
item.roles.drink = Boolean(type === "water" || type === "zunda_juice");
}
Object.assign(Item.prototype, {
update(dt, worldRef = world) {
this.age += dt;
if (this.type === "zunchi") this.spawnGrace = Math.max(0, (this.spawnGrace || 0) - dt);
if (this.dropTimer > 0) {
const beforeDrop = this.dropTimer;
this.dropTimer = Math.max(0, this.dropTimer - dt);
if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) {
this.dropImpactDone = true;
if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
}
}
if (this.type === "water") this.amount -= dt * 0.9;
if (isServingFoodType(this.type)) {
const interval = passiveFoodDecayInterval(worldRef);
this.passiveFoodDecayTimer = (this.passiveFoodDecayTimer || 0) + dt;
let ticks = 0;
while (this.passiveFoodDecayTimer >= interval && ticks < 3) {
this.passiveFoodDecayTimer -= interval;
ticks++;
if (Number.isFinite(this.foodServingsRemaining)) {
const before = Math.max(0, this.foodServingsRemaining || 0);
const lost = Math.min(before, interval * passiveFoodDecayRate(this));
if (lost > 0) {
this.foodServingsRemaining = Math.max(0, before - lost);
this.amount = this.foodServingsRemaining;
const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING;
worldRef?.registerFoodSpoilage?.(lost * penalty, this);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true });
if (this.foodServingsRemaining <= 0.015) this.amount = 0;
}
} else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(this.type)) {
const before = this.amount || 0;
const lost = Math.min(before, interval * 0.12);
this.amount -= lost;
if (lost > 0) {
const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? 0.002;
worldRef?.registerFoodSpoilage?.(lost * penalty, this);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true });
}
}
}
}
if (this.type === "trace") this.amount -= dt * 1.35;
if (this.type === "splat") this.amount -= dt * 1.05;
if (this.type === "ant_corpse") this.amount -= dt * 0.018;
if (this.type === "firecracker") {
this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
if (this.fuseTimer <= 0) {
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this);
else this.amount = 0;
}
}
if (this.type === "genkotsu") {
this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt);
if (this.dropImpactDone) {
this.lingerTimer = Math.max(0, (this.lingerTimer || 0) - dt);
if ((this.lingerTimer || 0) <= 0) this.amount = 0;
} else {
this.amount = Math.max(this.amount || 0, 100);
}
}
if (this.type === "ball") this.updateBall(dt, worldRef);
if (this.type === "duplicator") this.updateDuplicator(dt, worldRef);
if (isPinType(this.type)) this.updatePushpin(dt, worldRef);
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
if (this.type === "grass") {
// Grass is intentionally not simulated per item. World.update randomly
// advances one grass by one discrete stage every few seconds.
normalizeGrassStage(this);
} else if (this.type === "zunchi") {
this.lifecycleTimer += dt;
const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45);
if (this.lifecycleTimer >= interval) {
const lifecycleDt = Math.min(this.lifecycleTimer, 2.4);
this.lifecycleTimer = 0;
this.updateZunchi(lifecycleDt, worldRef);
}
}
},
updateDuplicator(dt, worldRef) {
this.amount = 999;
syncDuplicatorRoles(this);
if (!worldRef?.nearbyItems) return;
const pickupRadius = Math.max(96, (this.r || 34) * 3.0);
const maxContact = item => Math.max(68, (this.r || 34) + (item?.r || 14) + 24);
const candidates = Array.from(worldRef.nearbyItems(this.x, this.y, pickupRadius, true) || []);
// SpatialGrid の分類漏れがあっても、複製機の近接投入だけは取りこぼさない。
// 全件を「候補にする」のではなく、投入半径付近にあるものだけ補完する。
if (worldRef.items) {
for (const item of worldRef.items) {
if (!item || candidates.includes(item) || item === this || item.dead) continue;
const approxReach = pickupRadius + Math.max(18, item.r || 12);
if (distXY(this.x, this.y, item.x, item.y) <= approxReach) candidates.push(item);
}
}
let best = null, bestD = Infinity, bestType = "";
for (const it of candidates) {
if (!it || it === this || it.dead) continue;
const loadType = duplicatorLoadTypeForItem(it);
if (!loadType) continue;
if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue;
const d = distXY(this.x, this.y, it.x, it.y);
if (d < bestD && d <= maxContact(it)) { best = it; bestD = d; bestType = loadType; }
}
if (!best || !bestType) return;
const def = typeof itemDefinition === "function" ? itemDefinition(bestType) : null;
const newLabel = def?.label || bestType;
const changed = this.storedFoodType !== bestType;
if (!changed && this.storedFoodType) return;
this.storedFoodType = bestType;
this.storedFoodLabel = newLabel;
syncDuplicatorRoles(this);
this.loadedAt = worldRef.time || 0;
best.amount = 0;
best.foodServingsRemaining = 0;
// Item.dead is a getter based on amount. Do not assign to it.
worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
worldRef.emit?.("duplicator:loaded", { duplicator: this, foodType: this.storedFoodType, replaced: changed });
worldRef.log?.(`\u8907\u88fd\u6a5f\u306b${this.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, "food");
},
updateZunchiMotion(dt, worldRef) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
} else {
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;
worldRef.drawListDirty = true;
}
this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed);
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
},
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) {
if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return;
const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18);
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(this.x, this.y, t.x, t.y);
if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue;
const now = worldRef.time || 0;
if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue;
this.lastHitTarinaiAt = this.lastHitTarinaiAt || {};
this.lastHitTarinaiAt[t.id] = now;
const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1);
const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1);
const impulse = clamp(speed * 1.15, 150, 620);
t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20);
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
t.fallDir = nx < 0 ? -1 : 1;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" });
else {
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5);
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true });
if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 });
}
worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65);
worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" }));
this.vx *= -0.18;
this.vy *= -0.18;
break;
}
},
updateBall(dt, worldRef) {
this.prevX = this.x;
this.prevY = this.y;
const moving = Math.hypot(this.vx || 0, this.vy || 0);
if (moving > 0.04) {
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.72; this.spinVelocity *= -0.72; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
this.resolveBallObstacleCollisions(dt, worldRef);
this.x = clamp(this.x, p, worldRef.w - p);
this.y = clamp(this.y, p, worldRef.h - p);
const groundFriction = Math.pow(0.72, dt);
this.vx *= groundFriction;
this.vy *= groundFriction;
}
this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18);
this.spinVelocity *= Math.pow(0.65, dt);
if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; }
},
resolveBallObstacleCollisions(dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || [];
for (const it of items) {
if (!it || it === this || it.dead) continue;
if (it.type === "bed") {
this.applyBallHayDrag(it, dt, worldRef);
} else if (it.type === "stone") {
this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef);
}
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
if (!rects.length) continue;
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it);
}
},
applyBallHayDrag(bed, dt, worldRef) {
const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40);
const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34);
const dx = this.x - bed.x;
const dy = this.y - bed.y;
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
if (!inside) return;
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
this.vx *= slow;
this.vy *= slow;
this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) {
this.lastHaySlowLogAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }));
}
},
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) {
let dx = this.x - obstacle.x;
let dy = this.y - obstacle.y;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = this.prevX;
const py = this.prevY;
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
const sx = this.x - px;
const sy = this.y - py;
const len2 = sx * sx + sy * sy;
if (len2 <= 0.0001) return;
const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1);
const cx = px + sx * t;
const cy = py + sy * t;
dx = cx - obstacle.x;
dy = cy - obstacle.y;
d = Math.hypot(dx, dy);
if (d >= hitRadius) return;
if (d < 0.001) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; }
}
}
if (d < 0.001) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; }
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = obstacle.x + nx * (hitRadius + 0.5);
this.y = obstacle.y + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - (1 + restitution) * toward * nx;
this.vy = (this.vy || 0) - (1 + restitution) * toward * ny;
} else {
this.vx = (this.vx || 0) + nx * 24;
this.vy = (this.vy || 0) + ny * 24;
}
this.vx *= 0.96;
this.vy *= 0.96;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
this.emitBallBounce(worldRef, obstacle);
},
resolveBallRectBounce(rect, worldRef, source = null) {
if (!rect) return;
const hitRadius = (this.r || 18) + 2;
const cx = clamp(this.x, rect.left, rect.right);
const cy = clamp(this.y, rect.top, rect.bottom);
let dx = this.x - cx;
let dy = this.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = this.prevX;
const py = this.prevY;
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return;
let nx = 0;
let ny = 0;
const vx = this.vx || 0;
const vy = this.vy || 0;
const candidates = [];
const sx = this.x - px;
const sy = this.y - py;
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
if (candidates.length) {
candidates.sort((a, b) => a.t - b.t);
nx = candidates[0].nx;
ny = candidates[0].ny;
} else if (Math.abs(vx) >= Math.abs(vy)) {
nx = vx >= 0 ? -1 : 1;
} else {
ny = vy >= 0 ? -1 : 1;
}
const toward = vx * nx + vy * ny;
if (nx < 0) this.x = expanded.left - 0.5;
else if (nx > 0) this.x = expanded.right + 0.5;
if (ny < 0) this.y = expanded.top - 0.5;
else if (ny > 0) this.y = expanded.bottom + 0.5;
if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom);
if (ny) this.x = clamp(this.x, expanded.left, expanded.right);
if (toward < 0) {
this.vx = vx - 1.78 * toward * nx;
this.vy = vy - 1.78 * toward * ny;
} else {
this.vx = vx + nx * 18;
this.vy = vy + ny * 18;
}
this.vx *= 0.94;
this.vy *= 0.94;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
this.emitBallBounce(worldRef, source || rect.item || rect);
return;
}
if (d < 0.001) {
const left = Math.abs(this.x - rect.left);
const right = Math.abs(rect.right - this.x);
const top = Math.abs(this.y - rect.top);
const bottom = Math.abs(rect.bottom - this.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; }
else if (m === right) { dx = 1; dy = 0; }
else if (m === top) { dx = 0; dy = -1; }
else { dx = 0; dy = 1; }
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = cx + nx * (hitRadius + 0.5);
this.y = cy + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - 1.78 * toward * nx;
this.vy = (this.vy || 0) - 1.78 * toward * ny;
} else {
this.vx = (this.vx || 0) + nx * 18;
this.vy = (this.vy || 0) + ny * 18;
}
this.vx *= 0.94;
this.vy *= 0.94;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
this.emitBallBounce(worldRef, source || rect.item || rect);
},
emitBallBounce(worldRef, obstacle) {
const now = worldRef?.time || 0;
if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return;
this.lastObstacleBounceAt = now;
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" }));
},
updatePushpin(dt, worldRef) {
if (!worldRef) return;
if (this.pinState === "lodged") {
this.updateLodgedPushpin(dt, worldRef);
return;
}
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 });
} else {
this.prevX = this.x;
this.prevY = this.y;
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
}
this.tryStickPushpin(worldRef);
},
tryStickPushpin(worldRef) {
if (!worldRef?.tarinai?.length) return false;
if (this.pinState === "lodged") return false;
const now = worldRef.time || 0;
if ((this.noStickUntil || 0) > now) return false;
let best = null;
let bestD = Infinity;
const hitRange = Math.max(10, (this.r || 8) * 1.25);
const candidates = worldRef.nearbyTarinai?.(this.x, this.y, hitRange + 32, true) || worldRef.tarinai;
for (const t of candidates) {
if (!t || t.dead) continue;
if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue;
const d = distXY(this.x, this.y, t.x, t.y);
const hit = (t.radius || 16) * 0.86 + hitRange;
if (d <= hit && d < bestD) { best = t; bestD = d; }
}
if (!best) return false;
return this.attachPushpin(best, worldRef, bestD);
},
attachPushpin(t, worldRef, d = null) {
if (!t || t.dead) return false;
if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
const dx = this.x - t.x;
const dy = this.y - t.y;
const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy);
this.pinState = "lodged";
this.deletable = false;
this.pinTargetId = t.id;
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = isOshibyo ? -faceDir : faceDir;
this.pinVisualSide = visualSide;
this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide);
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
this.pinDamageTick = 0;
this.pinFallCheckTimer = 0;
this.vx = 0;
this.vy = 0;
this.spinVelocity = 0;
this.spin = this.pinAttachAngle;
t.stuckPushpinId = this.id;
t.sleeping = false;
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
if (isOshibyo) {
t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f";
worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }));
return true;
}
if (t.enterPanic) {
t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
} else {
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
}
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) {
this.pinLogAt = worldRef.time || 0;
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
}
worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }));
return true;
},
updateLodgedPushpin(dt, worldRef) {
const t = worldRef.liveTarinaiById?.(this.pinTargetId) || null;
if (!t) {
this.detachPushpin(worldRef, "owner-lost");
return;
}
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
t.stuckPushpinId = this.id;
this.deletable = false;
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = isOshibyo ? -faceDir : faceDir;
this.pinVisualSide = visualSide;
this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36;
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04;
this.prevX = this.x;
this.prevY = this.y;
this.spin = visualSide * (isOshibyo ? 0.52 : 0.28);
this.vx = t.vx || 0;
this.vy = t.vy || 0;
if (isOshibyo) {
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044";
return;
}
this.pinDamageTick = (this.pinDamageTick || 0) + dt;
while (this.pinDamageTick >= 0.55) {
this.pinDamageTick -= 0.55;
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
}
t.sleeping = false;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
else {
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
}
this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt;
while (this.pinFallCheckTimer >= 1.0) {
this.pinFallCheckTimer -= 1.0;
if (Math.random() < 0.10) {
this.detachPushpin(worldRef, "fall");
return;
}
}
},
detachPushpin(worldRef, reason = "released") {
const t = worldRef?.liveTarinaiById?.(this.pinTargetId) || null;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const wasOshibyo = Boolean(behavior?.blocksZunchi);
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null;
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
if (reason === "pinch") {
const now = worldRef?.time || 0;
this.noStickUntil = now + 1.0;
this.noStickTargetId = t?.id || "";
this.noStickTargetUntil = now + 1.0;
} else if (t) {
const now = worldRef?.time || 0;
this.noStickTargetId = t.id || "";
this.noStickTargetUntil = now + 0.55;
}
this.pinState = "loose";
this.pinTargetId = "";
this.pinDamageTick = 0;
this.pinFallCheckTimer = 0;
this.deletable = true;
if (reason === "pinch") {
this.vx = 0;
this.vy = 0;
this.spinVelocity = 0;
} else {
this.vx = rand(-24, 24);
this.vy = rand(-10, 18);
this.spinVelocity = rand(-1.6, 1.6);
this.spin += rand(-0.25, 0.25);
if (t) {
this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30));
this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30));
}
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }));
if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] });
}
if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) {
worldRef.spawnBurstZunchi(t || this, storedZunchi, this);
}
},
updateZunchi(dt, worldRef) {
const rainy = worldRef.weather === "light_rain";
const rainBoost = rainy ? 1.28 : 1;
const decayBoost = rainy ? 1.35 : 1;
let waterBoost = 0;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
for (const it of nearby) {
if (it.type !== "water") continue;
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
}
this.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65);
const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt;
if (this.stage === "fresh") {
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
this.amount -= decayDt * 0.018 * decayBoost;
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
} else if (this.stage === "dry") {
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
this.amount -= decayDt * 0.032 * decayBoost;
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
} else if (this.stage === "decomposing") {
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
this.amount -= decayDt * 0.046 * decayBoost;
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
} else if (this.stage === "fertile_soil") {
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
this.amount -= decayDt * 0.090 * decayBoost;
}
}
});

682
js/item_registry.js Normal file
View file

@ -0,0 +1,682 @@
"use strict";
// Unified item definition source: tools, item traits, visuals, food metadata, and item effects.
const TOOL_DEFINITIONS = Object.freeze({
observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\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: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" },
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" },
laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" },
protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" },
niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" },
ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" },
mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" },
mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" },
giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" },
dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" },
zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" },
grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" },
stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" },
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3084\u85ac\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u3001\u81ea\u52d5\u4f9b\u7d66\u3057\u3066\u304f\u308c\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true },
trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
});
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "water_bowl", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo"]),
});
function itemHasTrait(type = "", trait = "") {
return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || "")));
}
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
function lodgedPinFor(tarinai = null, worldRef = null) {
if (!tarinai?.stuckPushpinId) return null;
const items = worldRef?.items || tarinai.world?.items || [];
return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null;
}
function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); }
function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); }
const PIN_BEHAVIORS = Object.freeze({
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
});
function pinBehaviorFor(type = "") {
return PIN_BEHAVIORS[String(type || "")] || null;
}
let ITEM_VISUAL_DEFINITIONS = null;
let FOOD_REGISTRY = null;
let FOOD_DEFINITIONS = null;
let EFFECT_REGISTRY = null;
let EFFECT_DEFINITIONS = null;
const TOOL_CATEGORIES = Object.freeze([
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice", "duplicator"] },
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_v", "fence_h"] },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
]);
function toolCategories() {
return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0));
}
function toolDefinition(id = "") {
return TOOL_DEFINITIONS[id] || null;
}
function toolLabel(id = "") {
const def = toolDefinition(id);
return def?.label || id || "";
}
function toolItemType(id = "") {
const def = toolDefinition(id);
return def?.placeable ? (def.itemType || def.id) : null;
}
function itemDefinition(type = "") {
return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null;
}
function itemRadiusFor(type = "", fallback = 12) {
return itemDefinition(type)?.radius ?? fallback;
}
function itemAmountFor(type = "", fallback = 80) {
return itemDefinition(type)?.amount ?? fallback;
}
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" });
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
function normalizedToolSizeFor(worldRef = null, type = "") {
const def = itemDefinition(type);
if (!def?.scalable) return "medium";
const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium";
return TOOL_SIZE_ORDER.includes(value) ? value : "medium";
}
function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; }
function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; }
function scalableToolIds() {
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
}
function toolTipsFromDefinitions() {
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
}
(function (global) {
const raw = String.raw`{
"water": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"]
},
"zunda_juice": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"]
},
"mercury": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"]
},
"water_bowl": {
"renderer": "water_bowl",
"shape": "bowl_with_horizontal_oval_water",
"palette": {
"bowlFill": "rgba(171,120,76,0.72)",
"bowlStroke": "rgba(94,69,48,0.55)",
"waterFill": "rgba(86,157,224,0.58)",
"waterStroke": "rgba(62,113,178,0.36)",
"highlight": "rgba(255,255,255,0.54)"
},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"]
},
"sleep_drug": {
"renderer": "sleep_tablet",
"shape": "moon_tablet",
"palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "moon", "purple"]
},
"laxative": {
"renderer": "laxative_tablet",
"shape": "rounded_tablet_zunchi_motif",
"palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "zunchi motif", "tan"]
},
"mystery_drug": {
"renderer": "mystery_tablet",
"shape": "question_mark_tablet",
"palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "question mark", "purple"]
},
"ammo": {
"renderer": "bullet",
"shape": "bullet",
"palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bullet", "gold"]
},
"protein": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "orange"]
},
"niteropu": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "purple"]
},
"giant_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" },
"scale": 1.08,
"uiPreviewScale": 1.08,
"fieldScale": 1.08,
"accessibleShapeTraits": ["pill", "large", "orange"]
},
"dwarf_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" },
"scale": 0.92,
"uiPreviewScale": 0.92,
"fieldScale": 0.92,
"accessibleShapeTraits": ["pill", "small", "purple"]
},
"ball": {
"renderer": "emoji",
"shape": "soccer_ball",
"palette": {},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["round", "soccer ball"],
"emojiFallback": "\u26bd"
}
}`;
const defs = Object.freeze(JSON.parse(raw));
function itemVisualDefinition(type = "") {
return defs[String(type || "")] || null;
}
function itemVisualPalette(type = "") {
return itemVisualDefinition(type)?.palette || null;
}
ITEM_VISUAL_DEFINITIONS = defs;
global.itemVisualDefinition = itemVisualDefinition;
global.itemVisualPalette = itemVisualPalette;
})(typeof window !== "undefined" ? window : globalThis);
(function (global) {
const rawDefinitions = {
food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 },
sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] },
love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" },
fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" },
sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" },
laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true },
protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" },
niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" },
ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" },
mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" },
mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" },
giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" },
dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" },
zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true },
grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 }
};
class FoodRegistry extends global.TarinaiRegistryBase {
servingTypes() {
return [...this.defs.entries()]
.filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest))
.map(([id]) => id);
}
typesByInterest(...interests) {
const wanted = new Set(interests.flat().filter(Boolean));
return [...this.defs.entries()]
.filter(([, def]) => wanted.has(def.interest))
.map(([id]) => id);
}
paramEffectTypes() {
return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id);
}
nutrition(type = "", fallback = 0) {
const value = this.get(type)?.nutrition;
return Number.isFinite(value) ? value : fallback;
}
hungerRelief(type = "", fallback = 0) {
const value = this.get(type)?.hungerRelief;
return Number.isFinite(value) ? value : fallback;
}
effectId(type = "") { return this.get(type)?.effectId || ""; }
passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; }
hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; }
debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); }
}
const registry = new FoodRegistry(rawDefinitions);
FOOD_REGISTRY = registry;
FOOD_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);
(function (global) {
const rawDefinitions = {
laxative: {
label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60,
tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`,
},
ammo: {
label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18,
tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`,
},
protein: {
label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d",
},
niteropu: {
label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d",
},
mercury: {
label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply",
tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`,
},
giant_drug: {
label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
dwarf_drug: {
label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
love_mochi: {
label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
fight_mochi: {
label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
sleep_drug: {
label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"],
},
mystery_drug: {
label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"],
},
zunda_juice: {
label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"],
},
disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] },
disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] },
};
Object.assign(rawDefinitions.laxative, {
modifiers: { hungerReliefMultiplier: 0 },
tickInterval: 3,
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.ammo, {
modifiers: { speedMultiplier: 10 },
visualEffectId: "fall",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.protein, {
modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 },
visualEffectId: "fall_up_red",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.niteropu, {
modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 },
visualEffectId: "fall_down_purple",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.mercury, {
modifiers: { lifeMultiplierPerUse: 1.3 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.giant_drug, {
modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.dwarf_drug, {
modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) {
Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" });
}
Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false });
Object.assign(rawDefinitions.love_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
if (context.source === "eating") {
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
}
return true;
},
});
Object.assign(rawDefinitions.fight_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
if (context.source === "eating") {
tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32);
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
}
return true;
},
});
Object.assign(rawDefinitions.sleep_drug, {
onApply(tarinai, context = {}) {
if (context.source === "eating") {
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 });
tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100);
tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18);
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5);
const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075);
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true;
return true;
}
return tarinai.infectSleepDisease?.(context.item || null) || true;
},
});
Object.assign(rawDefinitions.laxative, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1)));
},
onTick(tarinai) {
tarinai.forceLaxativePoop?.();
return true;
},
});
Object.assign(rawDefinitions.ammo, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1)));
},
});
Object.assign(rawDefinitions.protein, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); },
});
Object.assign(rawDefinitions.niteropu, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); },
});
Object.assign(rawDefinitions.mercury, {
onApply(tarinai) { return tarinai.setMercuryEffect?.(); },
});
Object.assign(rawDefinitions.giant_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.dwarf_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.zunda_juice, {
onApply(tarinai) { return tarinai.clearAllItemEffects?.(); },
});
Object.assign(rawDefinitions.disease_zunchi, {
onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; },
});
Object.assign(rawDefinitions.disease_sleep, {
onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_explosion, {
onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_fight, {
onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; },
});
class EffectRegistry extends global.TarinaiRegistryBase {
label(id = "") { return super.label(id, id || "\u4e0d\u660e"); }
color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
display(id = "", timer = 0, tarinai = null) {
const def = this.get(id);
if (!def) return "";
return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || "");
}
apply(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onApply !== "function") return false;
return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
tick(id = "", tarinai = null, dt = 0, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onTick !== "function") return false;
return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this }));
}
remove(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onRemove !== "function") return false;
return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
modifier(id = "", key = "", fallback = 1) {
const value = this.get(id)?.modifiers?.[key];
return Number.isFinite(value) ? value : fallback;
}
timerValue(tarinai = null, id = "") {
const def = this.get(id);
if (!def || !tarinai) return 0;
if (def.timerProp) return Number(tarinai[def.timerProp] || 0);
if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0);
return 0;
}
isActive(tarinai = null, id = "") {
if (!tarinai) return false;
const def = this.get(id);
if (!def) return false;
if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id;
if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id;
if (id === "mercury") return tarinai.lifeItemMode === "mercury";
return this.timerValue(tarinai, id) > 0.04;
}
activeSummary(tarinai = null) {
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"];
return order
.filter(id => this.isActive(tarinai, id))
.map(id => ({
id,
label: this.label(id),
detail: this.display(id, this.timerValue(tarinai, id), tarinai),
}));
}
labels() { return super.labels(); }
colors() { return super.colors(); }
randomPool() {
const itemEffects = [...this.defs.entries()]
.filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease")))
.map(([id]) => id)
.filter(id => id !== "mystery_drug" && id !== "zunda_juice");
const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || [];
return [...new Set([...itemEffects, ...diseases])];
}
permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); }
timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); }
debugSnapshot() {
return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" }));
}
}
const registry = new EffectRegistry(rawDefinitions);
EFFECT_REGISTRY = registry;
EFFECT_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);
(function (global) {
function itemTypeDefinition(type = "") {
const id = String(type || "");
const tool = typeof itemDefinition === "function" ? itemDefinition(id) : null;
const food = FOOD_DEFINITIONS?.[id] || null;
const effectId = food?.effectId || (EFFECT_DEFINITIONS?.[id] ? id : "");
const effect = effectId ? (EFFECT_DEFINITIONS?.[effectId] || null) : null;
const visual = ITEM_VISUAL_DEFINITIONS?.[id] || null;
return {
id,
tool,
food,
effect,
effectId,
visual,
label: tool?.label || food?.label || effect?.label || id,
radius: tool?.radius,
amount: tool?.amount,
placeable: Boolean(tool?.placeable),
scalable: Boolean(tool?.scalable),
traits: Object.fromEntries(Object.keys(ITEM_TRAITS).map(trait => [trait, itemHasTrait(id, trait)])),
};
}
function itemTypeLabel(type = "") {
return itemTypeDefinition(type).label;
}
function itemTypeDebugSnapshot() {
const ids = new Set([
...Object.values(TOOL_DEFINITIONS).map(def => def.itemType || def.id),
...Object.keys(FOOD_DEFINITIONS || {}),
...Object.keys(EFFECT_DEFINITIONS || {}),
...Object.keys(ITEM_VISUAL_DEFINITIONS || {}),
]);
return [...ids].sort().map(id => itemTypeDefinition(id));
}
global.TarinaiItemRegistry = Object.freeze({
definition: itemTypeDefinition,
label: itemTypeLabel,
debugSnapshot: itemTypeDebugSnapshot,
tool: toolDefinition,
item: itemDefinition,
food: FOOD_REGISTRY,
effect: EFFECT_REGISTRY,
visual: itemVisualDefinition,
hasTrait: itemHasTrait,
radius: itemRadiusFor,
amount: itemAmountFor,
categories: toolCategories,
scalableToolIds,
pinBehavior: pinBehaviorFor,
});
})(typeof window !== "undefined" ? window : globalThis);

533
js/item_render_runtime.js Normal file
View file

@ -0,0 +1,533 @@
"use strict";
// Item field rendering. Kept separate from construction and lifecycle logic.
Object.assign(Item.prototype, {
draw(ctx, t, lighting = null) {
const lightState = lighting || getLightingState(world);
const night = clamp(lightState.nightStrength * 1.35, 0, 1);
const warm = lightState.warmth;
const styleNight = lightState.light < 0.42;
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
if (isPinType(this.type) && this.pinState === "lodged") {
const owner = world?.liveTarinaiById?.(this.pinTargetId) || null;
if (owner?.insideNestBoxId) return;
}
const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null;
const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1);
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170;
const dropY = dropT > 0 ? -dropFallHeight * dropT : 0;
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
if (this.type !== "trace" && this.type !== "splat") {
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null;
if (zunchiImg) {
const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi");
const ratio = metrics?.ratio || 178 / 236;
const zunchiH = this.r * 1.66 * ratio;
drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, {
alpha: shadow.alpha * visibleAlpha * 1.18,
widthScale: 0.92,
heightScale: 0.92,
});
} else if (this.type === "grass") {
ctx.save();
ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13);
ctx.fillStyle = shadow.color;
ctx.beginPath();
ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else {
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 });
}
}
drawItemGlow(ctx, this, lightState, visibleAlpha);
if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) {
ctx.save();
ctx.globalAlpha = 0.32;
ctx.strokeStyle = "rgba(255, 250, 220, 0.82)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.save();
ctx.translate(this.x, this.y + dropY);
ctx.globalAlpha = visibleAlpha;
if (isConsumableSpriteType(this.type)) {
drawConsumableFieldSprite(ctx, this.type, this.r, this.seed);
} else if (this.type === "genkotsu") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu");
const impact = clamp(this.impactFlash || 0, 0, 1);
ctx.save();
ctx.shadowColor = "rgba(54,42,31,0.26)";
ctx.shadowBlur = 5 + impact * 8;
ctx.shadowOffsetY = 4;
const squashY = 1 - impact * 0.08;
const stretchX = 1 + impact * 0.05;
ctx.scale(stretchX, squashY);
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null;
const w = this.r * 2.55;
const h = w * (metrics?.ratio || 1.27);
ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h);
} else {
ctx.fillStyle = "rgba(255,255,255,0.94)";
ctx.strokeStyle = "rgba(64,64,64,0.78)";
ctx.lineWidth = 5;
roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(42,46,52,0.92)";
ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("\u6b63", 0, -this.r * 0.74);
ctx.fillText("\u7fa9", 0, -this.r * 0.27);
}
if (impact > 0.01) {
ctx.globalAlpha = impact * 0.20;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (isPinType(this.type)) {
const stuck = this.pinState === "lodged";
const isOshibyo = this.type === "oshibyo";
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin");
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
ctx.save();
if (stuck) ctx.scale((this.pinVisualSide || 1) < 0 ? -1 : 1, 1);
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 2;
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
const ratio = metrics?.ratio || (stuck ? 1 : 2.0);
const w = isOshibyo ? (stuck ? this.r * 1.55 : this.r * 0.78) : (stuck ? this.r * 2.15 : this.r * 1.78);
const h = w * ratio;
ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h);
} else if (stuck) {
ctx.fillStyle = "#d92736";
ctx.strokeStyle = "rgba(125,18,30,0.78)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, this.r * 0.68, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, this.r * 0.26, 0, Math.PI * 2);
ctx.fillStyle = "rgba(176,20,30,0.82)";
ctx.fill();
} else {
ctx.fillStyle = "#d92736";
ctx.strokeStyle = "rgba(125,18,30,0.78)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(0, -this.r * 0.26, this.r * 0.70, this.r * 0.48, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.ellipse(0, this.r * 0.18, this.r * 0.82, this.r * 0.30, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.strokeStyle = "rgba(210,210,215,0.92)";
ctx.lineWidth = Math.max(1.5, this.r * 0.12);
ctx.beginPath();
ctx.moveTo(0, this.r * 0.42);
ctx.lineTo(0, this.r * 1.65);
ctx.stroke();
}
ctx.restore();
} else if (this.type === "water_bowl") {
const palette = visualPaletteFor("water_bowl");
ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)";
ctx.strokeStyle = palette.bowlStroke || "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 = palette.waterFill || "rgba(86, 157, 224, 0.58)";
ctx.strokeStyle = palette.waterStroke || "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 = palette.highlight || "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") {
drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water"));
} else if (this.type === "grass") {
const numericStage = Number.isFinite(this.grassStage) ? clampGrassStage(this.grassStage) : grassStageFromGrowth(this.growth, this.amount);
const growth = grassGrowthForStage(numericStage);
const eaten = (this.amount || 0) <= 0;
const stageLabel = numericStage <= 0 ? "sprout" : (numericStage <= 2 ? "young" : "mature");
const wither = clamp(this.wither || 0, 0, 1);
ctx.strokeStyle = eaten ? "#7b8755" : (wither > 0.08 ? `rgba(${Math.round(106 + wither * 40)}, ${Math.round(132 - wither * 32)}, ${Math.round(70 - wither * 18)}, 1)` : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438")));
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineCap = "round";
ctx.lineWidth = styleNight ? 1.5 : 1.8;
const bladeCount = Math.max(2, numericStage + 2);
for (let i = 0; i < bladeCount; i++) {
const spread = stageLabel === "mature" ? 1.05 : 0.68;
const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread);
const len = this.r * randSeed(this.seed + i + 10, 0.42, stageLabel === "mature" ? 1.18 : 0.92) * clamp(growth, 0.18, 0.92);
const rootX = stageLabel === "mature" ? randSeed(this.seed + i + 31, -this.r * 0.28, this.r * 0.28) : 0;
ctx.beginPath();
ctx.moveTo(rootX, 8);
ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78);
ctx.stroke();
}
if (numericStage >= 3 && !eaten) {
ctx.save();
ctx.globalAlpha = 0.14;
ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)";
ctx.beginPath();
ctx.ellipse(0, 1, this.r * 0.62, this.r * 0.20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
} else if (this.type === "stone") {
ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8");
ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63";
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineWidth = 2;
roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.34)";
ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill();
} else if (this.type === "bed") {
ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" );
ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" );
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = "rgba(116, 89, 42, 0.45)";
ctx.lineWidth = 1.5;
for (let i = 0; i < 13; i++) {
const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08);
const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46);
const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70);
const a = randSeed(this.seed + i + 60, -0.75, 0.75);
ctx.beginPath();
ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "nest_box") {
ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237");
ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24";
ctx.lineWidth = 2.6;
roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b";
roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6);
ctx.fill();
ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)";
ctx.lineWidth = 2.0;
ctx.beginPath();
ctx.moveTo(-this.r * 0.95, -this.r * 0.50);
ctx.lineTo(this.r * 0.95, -this.r * 0.50);
ctx.moveTo(-this.r * 0.95, this.r * 0.48);
ctx.lineTo(this.r * 0.95, this.r * 0.48);
ctx.stroke();
ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f";
ctx.beginPath();
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "duplicator") {
ctx.save();
const loaded = !!this.storedFoodType;
const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9);
bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6");
bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7");
bodyGrad.addColorStop(1, styleNight ? "#2c343c" : "#9fafb7");
ctx.fillStyle = bodyGrad;
ctx.strokeStyle = styleNight ? "#1b252c" : "#52636b";
ctx.lineWidth = Math.max(2, this.r * 0.075);
roundedRect(ctx, -this.r * 1.10, -this.r * 0.86, this.r * 1.52, this.r * 1.52, this.r * 0.20);
ctx.fill();
ctx.stroke();
ctx.fillStyle = styleNight ? "rgba(20,28,34,0.52)" : "rgba(255,255,255,0.72)";
roundedRect(ctx, -this.r * 0.92, -this.r * 0.64, this.r * 1.12, this.r * 0.42, this.r * 0.10);
ctx.fill();
ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)";
ctx.stroke();
ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)";
ctx.beginPath();
ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(35,47,54,0.52)";
ctx.stroke();
ctx.fillStyle = loaded ? "rgba(126,245,141,0.82)" : "rgba(255,255,255,0.45)";
ctx.beginPath();
ctx.arc(-this.r * 0.74, -this.r * 0.47, this.r * 0.045, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = styleNight ? "#232b30" : "#657079";
ctx.lineWidth = Math.max(1.6, this.r * 0.045);
ctx.beginPath();
ctx.moveTo(this.r * 0.18, -this.r * 0.34);
ctx.quadraticCurveTo(this.r * 0.48, -this.r * 0.52, this.r * 0.74, -this.r * 0.28);
ctx.stroke();
ctx.fillStyle = styleNight ? "#283139" : "#e6edf0";
ctx.beginPath();
ctx.arc(this.r * 0.78, -this.r * 0.28, this.r * 0.10, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
const dishGrad = ctx.createLinearGradient(this.r * 0.08, -this.r * 0.06, this.r * 1.28, this.r * 0.62);
dishGrad.addColorStop(0, styleNight ? "#3f454b" : "#eef4f4");
dishGrad.addColorStop(0.58, styleNight ? "#737c82" : "#ffffff");
dishGrad.addColorStop(1, styleNight ? "#242a30" : "#adb8bd");
ctx.fillStyle = dishGrad;
ctx.strokeStyle = styleNight ? "#12181d" : "#67767e";
ctx.lineWidth = Math.max(2, this.r * 0.06);
ctx.beginPath();
ctx.ellipse(this.r * 0.62, this.r * 0.42, this.r * 0.83, this.r * 0.39, -0.08, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = loaded ? "rgba(255,245,209,0.96)" : "rgba(230,235,238,0.48)";
ctx.beginPath();
ctx.ellipse(this.r * 0.62, this.r * 0.32, this.r * 0.50, this.r * 0.23, -0.08, 0, Math.PI * 2);
ctx.fill();
if (loaded) {
ctx.save();
ctx.translate(this.r * 0.62, this.r * 0.24);
ctx.scale(0.82, 0.82);
drawStoredConsumableSprite(ctx, this.storedFoodType || "food", Math.max(7, this.r * 0.35), this.seed + 241);
ctx.restore();
} else {
ctx.fillStyle = "rgba(72,80,86,0.62)";
ctx.font = `bold ${Math.max(10, Math.round(this.r * 0.34))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("?", this.r * 0.62, this.r * 0.28);
}
ctx.fillStyle = styleNight ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.45)";
ctx.beginPath();
ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else if (this.type === "signboard") {
ctx.save();
const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12);
const boardHalfW = this.r * 1.20;
boardGrad.addColorStop(0, styleNight ? "#d5cab8" : "#fff8e8");
boardGrad.addColorStop(0.55, styleNight ? "#c0b29a" : "#f3e3c6");
boardGrad.addColorStop(1, styleNight ? "#aa9a82" : "#dfc397");
ctx.fillStyle = styleNight ? "#8a7557" : "#d6ba87";
ctx.strokeStyle = styleNight ? "#5d4d3b" : "#9b7444";
ctx.lineWidth = 2.2;
roundedRect(ctx, -this.r * 0.13, -this.r * 0.02, this.r * 0.26, this.r * 1.78, this.r * 0.04);
ctx.fill();
ctx.stroke();
ctx.fillStyle = boardGrad;
ctx.strokeStyle = styleNight ? "#63513c" : "#a77e48";
roundedRect(ctx, -boardHalfW, -this.r * 1.28, boardHalfW * 2, this.r * 1.38, this.r * 0.18);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(103,83,58,0.36)" : "rgba(167,126,72,0.38)";
ctx.lineWidth = 1.1;
for (let i = 0; i < 3; i++) {
const y = -this.r * 1.02 + i * this.r * 0.36;
ctx.beginPath();
ctx.moveTo(-boardHalfW * 0.82, y);
ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, boardHalfW * 0.82, y + Math.cos(this.seed + i) * 2);
ctx.stroke();
}
ctx.fillStyle = "rgba(255,255,255,0.28)";
roundedRect(ctx, -boardHalfW * 0.82, -this.r * 1.10, boardHalfW * 1.64, this.r * 0.20, this.r * 0.08);
ctx.fill();
const raw = String(this.text || "").replace(/\r/g, "").trim();
if (raw) {
const charsPerLine = 6;
const lines = [];
for (const rawLine of raw.split("\n")) {
let rest = rawLine.trim();
if (!rest && lines.length < 3) { lines.push(""); continue; }
while (rest && lines.length < 3) {
const chars = Array.from(rest);
lines.push(chars.slice(0, charsPerLine).join(""));
rest = chars.slice(charsPerLine).join("");
}
if (lines.length >= 3) break;
}
ctx.fillStyle = styleNight ? "rgba(37,31,24,0.95)" : "rgba(55,37,22,0.96)";
ctx.font = `bold ${Math.max(11, Math.round(this.r * 0.39))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const startY = -this.r * 0.66 - (lines.length - 1) * this.r * 0.16;
lines.forEach((line, index) => ctx.fillText(line, 0, startY + index * this.r * 0.34));
} else {
ctx.fillStyle = styleNight ? "rgba(55,45,33,0.58)" : "rgba(91,64,35,0.50)";
ctx.font = `bold ${Math.max(14, Math.round(this.r * 0.44))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("\u2026", 0, -this.r * 0.58);
}
ctx.restore();
} else if (this.type === "ant_nest") {
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a");
ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#211a17" : "#30231d";
ctx.beginPath();
ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(38,37,35,0.88)";
for (let i = 0; i < Math.min(6, count); i++) {
const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0);
const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55);
ctx.beginPath();
ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "rgba(255,248,220,0.82)";
ctx.font = "bold 10px ui-rounded, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${count}`, 0, this.r * 1.25);
} else if (this.type === "ant_corpse") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker");
ctx.save();
ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26);
ctx.scale(1, -1);
ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1);
if (img) {
const w = this.r * 3.9;
const metrics = getImageMetrics("ant_worker");
const h = w * (metrics?.ratio || 0.52);
ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h);
} else {
ctx.fillStyle = "rgba(42,39,35,0.85)";
ctx.beginPath();
ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "ball") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);
ctx.save();
ctx.rotate(this.spin || 0);
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)";
ctx.shadowBlur = 2 + moving * 3;
ctx.shadowOffsetY = 2;
ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("\u26bd", 0, 1);
if (moving > 0.55) {
ctx.globalAlpha = 0.14 + moving * 0.10;
ctx.shadowColor = "transparent";
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "firecracker") {
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43";
ctx.strokeStyle = "rgba(96,48,34,0.78)";
ctx.lineWidth = 2;
roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#f4d15b";
ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
ctx.strokeStyle = "rgba(74,55,39,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(0, -this.r * 0.88);
ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
ctx.stroke();
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)";
ctx.beginPath();
ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(42,36,29,0.66)";
ctx.font = "bold 10px ui-rounded, sans-serif";
ctx.textAlign = "center";
ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
} else if (this.type === "fence_v" || this.type === "fence_h") {
const vertical = this.type === "fence_v";
const len = Math.max(112, this.r * 3.55);
const thick = Math.max(10, this.r * 0.31);
ctx.save();
ctx.rotate(vertical ? 0 : Math.PI / 2);
const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31");
const edge = styleNight ? "#4d3d2f" : "#5f3f25";
const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)";
ctx.fillStyle = wood;
ctx.strokeStyle = edge;
ctx.lineWidth = 2.4;
ctx.beginPath();
roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = hi;
ctx.beginPath();
roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3);
ctx.fill();
ctx.restore();
} else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "zunchi";
const img = images.get(zunchiId) || images.get("zunchi");
if (img) {
const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi");
const ratio = metrics?.ratio || 178 / 236;
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
ctx.globalAlpha *= stageAlpha;
ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio);
}
} else if (this.type === "trace") {
ctx.fillStyle = "rgba(122, 100, 76, 0.12)";
ctx.strokeStyle = "rgba(122, 100, 76, 0.16)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
} else if (this.type === "splat") {
const fade = clamp(this.amount / 260, 0, 1);
ctx.globalAlpha = visibleAlpha * fade;
ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`;
ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`;
ctx.lineWidth = 1.8;
const blobs = [
[-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5]
];
for (const [bx, by, bw, bh] of blobs) {
ctx.beginPath();
ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
for (let i = 0; i < 7; i++) {
ctx.beginPath();
ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
});

View file

@ -0,0 +1,115 @@
"use strict";
// Type-specific Item constructor state. Kept out of items.js so construction stays focused on shared fields.
function initializeItemTypeState(item, type, x, y) {
if (!item) return item;
if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true;
if (type === "water" || type === "water_bowl" || type === "zunda_juice") item.roles.drink = true;
if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true;
if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true;
if (["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(type)) item.roles.danger = true;
if (type === "grass") item.roles.grassMaterial = true;
if (type === "grass") {
item.grassStage = Math.floor(rand(0, 2.999));
setGrassStage(item, item.grassStage, { force: true });
item.seedTimer = rand(18, 38);
item.eatenAmount = 0;
item.fertilityBoost = 0;
item.lifeSpan = rand(420, 860);
item.wither = 0;
}
if (type === "bed") {
item.comfort = rand(0.86, 1.14);
item.wear = 0;
}
if (type === "signboard") {
item.text = "";
item.textEditedAt = -999;
}
if (type === "duplicator") {
item.storedFoodType = "";
item.storedFoodLabel = "";
item.roles.food = false;
item.roles.duplicator = true;
item.needEffects.food = 55;
item.amount = 999;
}
if (type === "ball") {
item.vx = rand(-5, 5);
item.vy = rand(-5, 5);
item.prevX = x;
item.prevY = y;
item.spin = rand(0, Math.PI * 2);
item.spinVelocity = rand(-1.6, 1.6);
item.lastKickedAt = -999;
item.lastKickerId = "";
item.lastPokedAt = -999;
item.pokeCombo = 0;
item.lastPokeAngle = rand(0, Math.PI * 2);
}
if (type === "zunchi") {
item.amount = Math.max(Number(item.amount || 0) || 0, 240);
item.stage = "fresh";
item.stageTimer = 0;
item.fertility = 0;
item.freshness = 1;
item.spawnGrace = 10.0;
item.zunchiVariant = stableUnit(item.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
}
if (type === "ant_nest") {
item.antCount = ANT_NEST_START_COUNT || 6;
item.antSpawnTimer = rand(0.6, 2.2);
item.antSpawnCooldown = rand(0.8, 2.4);
item.antWorkers = typeof buildAntWorkerPool === "function"
? buildAntWorkerPool(item.antCount)
: Array.from({ length: item.antCount }, () => ANT_WORKER_HP || 32);
item.queenSpawnAt = -999;
}
if (type === "ant_corpse") {
item.amount = 24;
item.workerSprite = "ant_worker";
item.decayTimer = 0;
}
if (type === "firecracker") {
item.fuseTimer = 5;
item.fuseMax = 5;
}
if (type === "genkotsu") {
item.amount = 100;
item.dropMax = 0;
item.dropImpactDone = false;
item.impactFlash = 0;
item.lingerTimer = 0;
item.lingerDuration = 2.0;
}
if (isPinType(type)) {
item.amount = 100;
item.vx = rand(-120, 120);
item.vy = rand(-90, 90);
item.prevX = x;
item.prevY = y;
item.spin = rand(-0.35, 0.35);
item.spinVelocity = rand(-5.4, 5.4);
item.pinState = "loose";
item.pinTargetId = "";
item.pinAttachAngle = 0;
item.pinAttachDistance = 0;
item.pinOffsetY = 0;
item.pinDamageTick = 0;
item.pinFallCheckTimer = 0;
item.pinLogAt = -999;
item.deletable = true;
}
if (isServingFoodType(type)) {
item.toolSize = "medium";
item.foodServingScale = 1;
item.foodServingsMax = foodServingsForSize(item.toolSize);
item.foodServingsRemaining = item.foodServingsMax;
item.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval());
item.amount = item.foodServingsRemaining;
}
return item;
}
if (typeof window !== "undefined") window.initializeItemTypeState = initializeItemTypeState;

200
js/item_update_scheduler.js Normal file
View file

@ -0,0 +1,200 @@
"use strict";
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = [
"ball", "genkotsu", "firecracker", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "water_bowl",
];
function itemUpdateInterval(item) {
if (!item || item.dead) return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return 0;
if (PIN_ITEM_TYPES.has(item.type)) return 0;
if (item.isStructure && item.type === "plushie") return 0;
if (item.isStructure && item.type === "grass_bed") return 3.0;
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi") return 2.4;
if (item.type === "grass") return Infinity;
if (item.type === "duplicator") return 2.0;
if (item.type === "water") return 1.8;
if (item.type === "trace" || item.type === "splat") return 1.2;
if (item.type === "ant_corpse") return 3.5;
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0;
return Infinity;
}
function heapPush(heap, entry) {
heap.push(entry);
let i = heap.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (heap[p].time <= entry.time) break;
heap[i] = heap[p];
i = p;
}
heap[i] = entry;
}
function heapPop(heap) {
if (!heap.length) return null;
const root = heap[0];
const last = heap.pop();
if (heap.length && last) {
let i = 0;
while (true) {
let c = i * 2 + 1;
if (c >= heap.length) break;
if (c + 1 < heap.length && heap[c + 1].time < heap[c].time) c++;
if (heap[c].time >= last.time) break;
heap[i] = heap[c];
i = c;
}
heap[i] = last;
}
return root;
}
function schedulerSignature(worldRef) {
return [
(worldRef.items || []).length,
worldRef.itemBucketRebuildsTotal || 0,
worldRef.itemBucketsDirty ? 1 : 0,
].join(":");
}
function ensureBuckets(worldRef) {
if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-scheduler");
}
function candidateItems(worldRef) {
if (!worldRef?.itemsOfType) return (worldRef.items || []).filter(it => it && !it.dead && it.type !== "grass");
const out = [];
for (const type of SCHEDULED_ITEM_TYPES) {
const bucket = worldRef.itemsOfType(type) || [];
for (const it of bucket) if (it && !it.dead) out.push(it);
}
return out;
}
function scheduleItem(state, item, now, interval = itemUpdateInterval(item)) {
if (!item || item.dead || !Number.isFinite(interval)) return false;
if (interval <= 0) {
state.realtime.push(item);
item._schedulerRealtime = true;
return true;
}
const token = (item._schedulerToken || 0) + 1;
item._schedulerToken = token;
item._schedulerRealtime = false;
if (!Number.isFinite(item._schedulerLastAt)) item._schedulerLastAt = now;
heapPush(state.heap, { time: now + interval, item, token });
return true;
}
function rebuild(worldRef, reason = "manual") {
ensureBuckets(worldRef);
const now = worldRef.time || 0;
const state = {
heap: [],
realtime: [],
signature: schedulerSignature(worldRef),
rebuilds: ((worldRef._itemUpdateScheduler?.rebuilds || 0) + 1),
reason,
};
for (const item of candidateItems(worldRef)) {
item._schedulerLastAt = Number.isFinite(item._schedulerLastAt) ? item._schedulerLastAt : now;
scheduleItem(state, item, now, itemUpdateInterval(item));
}
worldRef._itemUpdateScheduler = state;
worldRef._itemUpdateRealtime = state.realtime;
return state;
}
function ensure(worldRef) {
const sig = schedulerSignature(worldRef);
if (!worldRef._itemUpdateScheduler || worldRef._itemUpdateScheduler.signature !== sig) return rebuild(worldRef, "signature");
return worldRef._itemUpdateScheduler;
}
function runRealtime(worldRef, state, dt) {
let ran = 0;
const now = worldRef.time || 0;
const nextRealtime = [];
for (const item of state.realtime || []) {
if (!item || item.dead) continue;
const interval = itemUpdateInterval(item);
if (!Number.isFinite(interval)) {
item._schedulerRealtime = false;
continue;
}
if (interval <= 0) {
item.update(dt, worldRef);
item._schedulerLastAt = now;
ran += 1;
if (Number.isFinite(itemUpdateInterval(item)) && itemUpdateInterval(item) <= 0) nextRealtime.push(item);
else scheduleItem(state, item, now, itemUpdateInterval(item));
} else {
scheduleItem(state, item, now, interval);
}
}
state.realtime = nextRealtime;
worldRef._itemUpdateRealtime = state.realtime;
return ran;
}
function runDue(worldRef, state) {
let ran = 0;
const now = worldRef.time || 0;
const maxDue = Math.max(24, Math.ceil((state.heap.length || 0) * 0.08));
let dueRuns = 0;
while (state.heap.length && state.heap[0].time <= now + 0.0001 && dueRuns < maxDue) {
const entry = heapPop(state.heap);
const item = entry?.item;
if (!item || item.dead || item._schedulerToken !== entry.token) continue;
const interval = itemUpdateInterval(item);
if (!Number.isFinite(interval)) continue;
if (interval <= 0) {
state.realtime.push(item);
item._schedulerRealtime = true;
continue;
}
const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval)));
item.update(elapsed, worldRef);
item._schedulerLastAt = now;
ran += 1;
dueRuns += 1;
scheduleItem(state, item, now, itemUpdateInterval(item));
}
return ran;
}
function run(worldRef, dt) {
const end = global.TarinaiPerf?.begin?.("update.items") || null;
try {
const state = ensure(worldRef);
let ran = runRealtime(worldRef, state, dt);
ran += runDue(worldRef, state);
state.signature = schedulerSignature(worldRef);
worldRef._itemUpdateSchedulerStats = {
heap: state.heap.length,
realtime: state.realtime.length,
rebuilds: state.rebuilds,
lastReason: state.reason,
lastRan: ran,
};
return ran;
} finally {
if (end) end();
}
}
function trackedDynamicItems(worldRef) {
return worldRef?._itemUpdateRealtime || [];
}
global.TarinaiItemUpdateScheduler = Object.freeze({ run, rebuild, ensure, itemUpdateInterval, trackedDynamicItems });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,144 +0,0 @@
"use strict";
(function (global) {
const raw = String.raw`{
"water": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"]
},
"zunda_juice": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"]
},
"mercury": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"]
},
"water_bowl": {
"renderer": "water_bowl",
"shape": "bowl_with_horizontal_oval_water",
"palette": {
"bowlFill": "rgba(171,120,76,0.72)",
"bowlStroke": "rgba(94,69,48,0.55)",
"waterFill": "rgba(86,157,224,0.58)",
"waterStroke": "rgba(62,113,178,0.36)",
"highlight": "rgba(255,255,255,0.54)"
},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"]
},
"sleep_drug": {
"renderer": "sleep_tablet",
"shape": "moon_tablet",
"palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "moon", "purple"]
},
"laxative": {
"renderer": "laxative_tablet",
"shape": "rounded_tablet_zunchi_motif",
"palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "zunchi motif", "tan"]
},
"mystery_drug": {
"renderer": "mystery_tablet",
"shape": "question_mark_tablet",
"palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "question mark", "purple"]
},
"ammo": {
"renderer": "bullet",
"shape": "bullet",
"palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bullet", "gold"]
},
"protein": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "orange"]
},
"niteropu": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "purple"]
},
"giant_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" },
"scale": 1.08,
"uiPreviewScale": 1.08,
"fieldScale": 1.08,
"accessibleShapeTraits": ["pill", "large", "orange"]
},
"dwarf_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" },
"scale": 0.92,
"uiPreviewScale": 0.92,
"fieldScale": 0.92,
"accessibleShapeTraits": ["pill", "small", "purple"]
},
"ball": {
"renderer": "emoji",
"shape": "soccer_ball",
"palette": {},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["round", "soccer ball"],
"emojiFallback": "\u26bd"
}
}`;
const defs = Object.freeze(JSON.parse(raw));
function itemVisualDefinition(type = "") {
return defs[String(type || "")] || null;
}
function itemVisualPalette(type = "") {
return itemVisualDefinition(type)?.palette || null;
}
global.TARINAI_ITEM_VISUALS = defs;
global.itemVisualDefinition = itemVisualDefinition;
global.itemVisualPalette = itemVisualPalette;
})(typeof window !== "undefined" ? window : globalThis);

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,6 @@ let last = nowSec();
let statsTimer = 0;
let fpsTimer = 0;
let fpsFrames = 0;
let perfResizeTimer = 0;
let lastPerfLevel = 0;
window.__tarinaiFps = 0;
const LOADING_SPRITES = [
@ -80,27 +78,24 @@ function loop() {
const rawDt = t - last;
const dt = clamp(rawDt, 0, 0.05);
last = t;
window.TarinaiPerf?.beginFrame?.();
fpsTimer += rawDt;
fpsFrames += 1;
if (fpsTimer >= 0.5) {
window.__tarinaiFps = Math.round(fpsFrames / Math.max(fpsTimer, 0.001));
window.TarinaiPerformance?.updateFps?.(window.__tarinaiFps, world);
fpsTimer = 0;
fpsFrames = 0;
}
const endUpdate = window.TarinaiPerf?.begin?.("update.total") || null;
world.update(dt);
if (endUpdate) endUpdate();
const endRender = window.TarinaiPerf?.begin?.("render.total") || null;
render();
if (endRender) endRender();
window.TarinaiPerf?.endFrame?.(rawDt, window.__tarinaiFps || 0);
if (window.TarinaiPerf?.consumeResizeRequest?.() && typeof resizeCanvas === "function") resizeCanvas();
statsTimer += dt;
const perf = world.performanceLevel ? world.performanceLevel() : 0;
perfResizeTimer += rawDt;
if (perfResizeTimer > 1.5) {
if (perf !== lastPerfLevel) {
lastPerfLevel = perf;
resizeCanvas();
}
perfResizeTimer = 0;
}
const statsInterval = world.performanceQuality?.().statsInterval ?? (perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48);
const statsInterval = 2.5;
if (statsTimer > statsInterval) {
renderStats();
statsTimer = 0;
@ -139,4 +134,3 @@ if ("serviceWorker" in navigator) {
});
}

115
js/perf_profiler.js Normal file
View file

@ -0,0 +1,115 @@
"use strict";
(function (global) {
const nowMs = () => (global.performance?.now ? global.performance.now() : Date.now());
const buckets = new Map();
let frameOpen = null;
let frameCount = 0;
let lastQualityCheckAt = 0;
let tier = "high";
let dprScale = 1;
let resizeRequested = false;
function bucketFor(label) {
let b = buckets.get(label);
if (!b) {
b = { label, ms: 0, avg: 0, max: 0, calls: 0, last: 0 };
buckets.set(label, b);
}
return b;
}
function begin(label) {
const start = nowMs();
return () => {
const elapsed = Math.max(0, nowMs() - start);
const b = bucketFor(label);
b.ms += elapsed;
b.calls += 1;
b.last = elapsed;
if (elapsed > b.max) b.max = elapsed;
return elapsed;
};
}
function beginFrame() {
frameOpen = nowMs();
return frameOpen;
}
function smoothBuckets() {
for (const b of buckets.values()) {
b.avg = b.avg ? b.avg * 0.82 + b.ms * 0.18 : b.ms;
b.ms = 0;
b.calls = 0;
}
}
function qualityFromFps(fps) {
const f = Number(fps) || 0;
if (f > 55) return { tier: "high", dpr: 1 };
if (f < 32) return { tier: "low", dpr: 0.72 };
if (f < 45) return { tier: "mid", dpr: 0.86 };
return { tier: tier === "low" ? "mid" : tier, dpr: tier === "low" ? 0.86 : dprScale };
}
function endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) {
frameCount += 1;
if (frameOpen != null) {
const b = bucketFor("frame.total");
const elapsed = Math.max(0, nowMs() - frameOpen);
b.ms += elapsed;
b.calls += 1;
b.last = elapsed;
if (elapsed > b.max) b.max = elapsed;
frameOpen = null;
}
const t = nowMs();
if (t - lastQualityCheckAt >= 750) {
lastQualityCheckAt = t;
smoothBuckets();
const next = qualityFromFps(fps);
const nextDpr = next.dpr;
if (next.tier !== tier || Math.abs(nextDpr - dprScale) > 0.04) {
tier = next.tier;
dprScale = nextDpr;
resizeRequested = true;
}
}
}
function renderQualityTier() {
return tier;
}
function dprScaleValue() {
return dprScale;
}
function consumeResizeRequest() {
const v = resizeRequested;
resizeRequested = false;
return v;
}
function snapshot() {
const entries = [...buckets.values()].map(b => ({
label: b.label,
avg: Number((b.avg || 0).toFixed(2)),
max: Number((b.max || 0).toFixed(2)),
last: Number((b.last || 0).toFixed(2)),
calls: b.calls || 0,
})).sort((a, b) => b.avg - a.avg);
return { tier, dprScale, frameCount, entries };
}
global.TarinaiPerf = Object.freeze({
begin,
beginFrame,
endFrame,
renderQualityTier,
dprScale: dprScaleValue,
consumeResizeRequest,
snapshot,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,63 +0,0 @@
"use strict";
class TarinaiPerformanceManager {
constructor() {
this.fps = 60;
this.level = 0;
this.lastLevel = 0;
this.samples = [];
this.sampleLimit = 20;
}
updateFps(fps, worldRef = null) {
const n = Number(fps);
if (!Number.isFinite(n) || n <= 0) return this.level;
this.samples.push(n);
if (this.samples.length > this.sampleLimit) this.samples.shift();
const avg = this.samples.reduce((a, b) => a + b, 0) / this.samples.length;
this.fps = Math.round(avg);
const next = this.computeLevel(avg);
if (next !== this.level) {
this.lastLevel = this.level;
this.level = next;
window.TarinaiEvents?.emit("performance:level", { level: this.level, previous: this.lastLevel, fps: this.fps, world: worldRef || null });
}
return this.level;
}
computeLevel(fps = this.fps) {
if (!fps || !Number.isFinite(fps)) return 0;
if (fps < 22) return 3;
if (fps < 34) return 2;
if (fps < 48) return 1;
return 0;
}
quality(worldRef = null) {
const level = this.level;
let antCount = 0;
for (const ant of worldRef?.ants || []) if (ant && !ant.dead) antCount += 1;
const itemCount = (worldRef?.items || []).length || 0;
const effectCount = (worldRef?.effects || []).length || 0;
const antStride = antCount >= 95 || level >= 3 ? 3 : (antCount >= 35 || level >= 2 ? 2 : 1);
return {
fps: this.fps,
level,
antStride,
itemCount,
antCount,
effectCount,
effectLimit: level >= 3 ? 34 : level >= 2 ? 52 : level >= 1 ? 68 : 88,
statsInterval: level >= 3 ? 1.20 : level === 2 ? 0.92 : level === 1 ? 0.68 : 0.48,
terrainRedrawInterval: level >= 3 ? 1.30 : level === 2 ? 1.05 : level === 1 ? 0.82 : 0.62,
backgroundLightStep: level >= 3 ? 64 : level === 2 ? 96 : 180,
uiUpdateInterval: level >= 3 ? 1.2 : level === 2 ? 0.9 : level === 1 ? 0.65 : 0.45,
};
}
debugSnapshot(worldRef = null) {
return { version: window.TARINAI_VERSION || "", fps: this.fps, level: this.level, quality: this.quality(worldRef) };
}
}
window.TarinaiPerformance = window.TarinaiPerformance || new TarinaiPerformanceManager();

View file

@ -1,86 +0,0 @@
"use strict";
(function (global) {
const MAX_PENDING_LOSSES = 16;
function relationWithDead(observer, dead, worldRef) {
if (!observer || !dead || observer === dead) return "";
const deadFamily = worldRef?.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || "");
const observerFamily = observer.familyKey || observer.archiveKey || observer.id || "";
const deadId = dead.id || dead.releasedLiveId || "";
const observerId = observer.id || observer.liveId || "";
if (deadFamily && (observer.parents || []).includes(deadFamily)) return "親";
if (deadFamily && (observer.children || []).includes(deadFamily)) return "子";
if (observerFamily && (dead.parents || []).includes(observerFamily)) return "子";
if (observerFamily && (dead.children || []).includes(observerFamily)) return "親";
const rel = deadId ? observer.relationships?.[deadId] : null;
const reverse = observerId ? dead.relationships?.[observerId] : null;
if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "友達";
return "";
}
function applyLiveDeathShock(observer, dead, relation, worldRef) {
const strong = relation === "親" || relation === "子";
if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("身近な個体の死に気づいた");
const stress = strong ? 18 : 11;
const fear = strong ? 2.1 : 1.45;
if (observer.enterPanic) observer.enterPanic({ threat: dead, reason: `${relation}が死んだ`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" });
else {
observer.sleeping = false;
observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true });
observer.fearTimer = Math.max(observer.fearTimer || 0, fear);
if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 });
observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)");
}
observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress });
observer.addRecord?.(`${dead.name || "身近な個体"}の死に気づいてパニックになった。`, "death");
}
function notifyDeathToRelations(dead, reason = "") {
const worldRef = this || global.world;
if (!worldRef || !dead) return;
const deadFamily = worldRef.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || "");
const deadId = dead.id || dead.releasedLiveId || "";
let liveHits = 0;
for (const other of worldRef.tarinai || []) {
if (!other || other === dead || other.dead) continue;
const relation = relationWithDead(other, dead, worldRef);
if (!relation) continue;
applyLiveDeathShock(other, dead, relation, worldRef);
liveHits += 1;
}
for (const entry of (global.TarinaiFreezeSystem?.frozenList?.(worldRef) || [])) {
const data = entry.data || entry;
const relation = relationWithDead(data, dead, worldRef);
if (!relation) continue;
if (!Array.isArray(entry.pendingLosses)) entry.pendingLosses = [];
const duplicate = entry.pendingLosses.some(l => (deadFamily && l.familyKey === deadFamily) || (deadId && l.liveId === deadId));
if (!duplicate) {
entry.pendingLosses.unshift({
relation,
name: dead.name || "たりない",
familyKey: deadFamily,
liveId: deadId,
reason: reason || dead.deathReason || "死亡",
time: worldRef.time || 0,
});
if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES;
}
}
if (liveHits) worldRef.log?.(`${dead.name || "たりない"}の死に、身近な個体が動揺した。`, "death", { participants: [dead] });
global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef);
global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef);
}
function patchWorldPrototype() {
const apply = (proto) => { proto.notifyDeathToRelations = notifyDeathToRelations; };
if (global.TarinaiPatcher?.patchWorld?.("relationEvents", apply)) return;
const World = global.World;
if (!World) return;
apply(World.prototype);
}
global.TarinaiRelationEvents = { relationWithDead, applyLiveDeathShock, notifyDeathToRelations, patchWorldPrototype };
patchWorldPrototype();
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -147,7 +147,7 @@ function placementPreviewFor(world) {
return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) };
}
if (type === "grass") {
blocked = !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55));
blocked = !(world.canAddGrass?.(1) ?? true) || !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55));
}
if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp);
return { type, x, y, r, blocked };
@ -227,18 +227,20 @@ const backgroundCache = {
function backgroundCacheKey(world, lighting) {
const weather = world.weather || "sunny";
const field = world.fieldType || "garden";
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180;
const progressBucket = Math.round((lighting.progress || 0) * step);
const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2));
return `${field}:${weather}:${progressBucket}:${weatherBucket}`;
const ground = world.groundType || "soil";
// Keep the heavy garden/sky redraw coarse. Fine light changes are handled by overlays.
const lightBucket = Math.round((lighting.light || 0) * 8);
const warmthBucket = Math.round((lighting.goldenStrength || 0) * 5);
const weatherBucket = Math.floor((world.time || 0) / 8);
return `${field}:${ground}:${weather}:${lightBucket}:${warmthBucket}:${weatherBucket}`;
}
// Cached low-priority terrain layer: grass, footprints and splats are mostly
// static visual noise. Rendering them into an offscreen layer avoids repeating
// thousands of small strokes every frame while preserving simulation behavior.
// static visual noise. Rendering them into visible chunks avoids repeating
// thousands of small strokes every frame and avoids redrawing the entire field
// when a small terrain area changes.
const terrainCache = {
canvas: null,
ctx: null,
@ -247,6 +249,12 @@ const terrainCache = {
key: "",
};
const terrainChunkCache = {
size: 256,
key: "",
chunks: new Map(),
};
function isCachedTerrainItem(it) {
if (!it || it.dead) return false;
if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true;
@ -273,16 +281,104 @@ function renderLayerKindRank(entity) {
}
function terrainCacheKey(worldRef, lighting) {
const perf = worldRef.performanceLevel ? worldRef.performanceLevel() : 0;
const lightBucket = Math.round((lighting.light || 0) * (perf >= 2 ? 8 : 14));
const version = worldRef.terrainVersion || 0;
return `${worldRef.fieldType || "garden"}:${worldRef.w}x${worldRef.h}:${lightBucket}:v${version}`;
const lightBucket = Math.round((lighting.light || 0) * 6);
return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}:${worldRef.w}x${worldRef.h}:${lightBucket}`;
}
function terrainChunkKey(cx, cy) {
return `${cx}:${cy}`;
}
function terrainChunkFor(cx, cy, worldRef) {
const key = terrainChunkKey(cx, cy);
let chunk = terrainChunkCache.chunks.get(key);
const size = terrainChunkCache.size;
const x = cx * size;
const y = cy * size;
const w = Math.max(1, Math.min(size, Math.ceil((worldRef.w || 1) - x)));
const h = Math.max(1, Math.min(size, Math.ceil((worldRef.h || 1) - y)));
if (!chunk) {
const canvas = document.createElement("canvas");
chunk = { canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" };
terrainChunkCache.chunks.set(key, chunk);
}
if (chunk.w !== w || chunk.h !== h) {
chunk.w = w;
chunk.h = h;
chunk.dirty = true;
}
return chunk;
}
function redrawTerrainChunk(chunk, worldRef, lighting) {
const c = chunk.ctx;
chunk.canvas.width = Math.max(1, chunk.w);
chunk.canvas.height = Math.max(1, chunk.h);
c.clearRect(0, 0, chunk.w, chunk.h);
c.save();
c.translate(-chunk.x, -chunk.y);
const oldPointer = worldRef.pointer;
try {
if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false };
const cx = chunk.x + chunk.w / 2;
const cy = chunk.y + chunk.h / 2;
const radius = Math.hypot(chunk.w, chunk.h) / 2 + 90;
const candidates = worldRef.nearbyItems ? worldRef.nearbyItems(cx, cy, radius, false) : (worldRef.items || []);
const rect = { left: chunk.x - 80, top: chunk.y - 80, right: chunk.x + chunk.w + 80, bottom: chunk.y + chunk.h + 80 };
for (const it of candidates || []) {
if (!isCachedTerrainItem(it) || !isEntityVisibleInRect(it, rect, 80)) continue;
it.draw(c, worldRef.time || 0, lighting);
}
} catch (err) {
console.error("terrain chunk redraw failed", err);
} finally {
if (oldPointer) worldRef.pointer = oldPointer;
c.restore();
}
chunk.dirty = false;
chunk.key = terrainChunkCache.key;
}
function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) {
const end = window.TarinaiPerf?.begin?.("render.terrain") || null;
try {
const key = terrainCacheKey(worldRef, lighting);
if (terrainChunkCache.key !== key || worldRef.terrainDirtyGlobal || (worldRef.terrainDirty && !worldRef.terrainDirtyChunks?.size)) {
terrainChunkCache.key = key;
terrainChunkCache.chunks.clear();
worldRef.terrainDirty = false;
worldRef.terrainDirtyGlobal = false;
worldRef.terrainDirtyChunks?.clear?.();
} else if (worldRef.terrainDirty && worldRef.terrainDirtyChunks?.size) {
for (const chunkKey of worldRef.terrainDirtyChunks) {
const chunk = terrainChunkCache.chunks.get(chunkKey);
if (chunk) chunk.dirty = true;
}
worldRef.terrainDirty = false;
worldRef.terrainDirtyChunks.clear();
}
const size = terrainChunkCache.size;
const minX = Math.max(0, Math.floor((visibleRect.left || 0) / size));
const maxX = Math.min(Math.floor(((worldRef.w || 1) - 1) / size), Math.floor((visibleRect.right || 0) / size));
const minY = Math.max(0, Math.floor((visibleRect.top || 0) / size));
const maxY = Math.min(Math.floor(((worldRef.h || 1) - 1) / size), Math.floor((visibleRect.bottom || 0) / size));
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const chunk = terrainChunkFor(cx, cy, worldRef);
if (chunk.dirty || chunk.key !== terrainChunkCache.key) redrawTerrainChunk(chunk, worldRef, lighting);
ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h);
}
}
} finally {
if (end) end();
}
}
function ensureTerrainCache(worldRef, lighting) {
// Compatibility fallback for older callers. The main renderer uses chunks.
const w = Math.max(1, Math.floor(worldRef.w || 1));
const h = Math.max(1, Math.floor(worldRef.h || 1));
const key = terrainCacheKey(worldRef, lighting);
const key = `${terrainCacheKey(worldRef, lighting)}:fallback:v${worldRef.terrainVersion || 0}`;
if (!terrainCache.canvas) {
terrainCache.canvas = document.createElement("canvas");
terrainCache.ctx = terrainCache.canvas.getContext("2d");
@ -297,11 +393,8 @@ function ensureTerrainCache(worldRef, lighting) {
c.clearRect(0, 0, w, h);
const oldPointer = worldRef.pointer;
try {
// Pointer hover rings should not be baked into the cache.
if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false };
for (const it of worldRef.items || []) {
if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting);
}
for (const it of worldRef.items || []) if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting);
worldRef.terrainDirty = false;
return terrainCache.canvas;
} catch (err) {
@ -348,6 +441,113 @@ function isEntityVisibleInRect(entity, rect, extra = 0) {
return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom;
}
function renderStackSignature(worldRef) {
return [
(worldRef.items || []).length,
(worldRef.tarinai || []).length,
(worldRef.ants || []).length,
worldRef.terrainVersion || 0,
].join(":");
}
function ensureWorldRenderStack(worldRef) {
if (!worldRef._renderStack) {
worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
}
const stack = worldRef._renderStack;
const signature = renderStackSignature(worldRef);
if (!worldRef.drawListDirty && stack.signature === signature) return stack;
stack.backItems.length = 0;
stack.layered.length = 0;
stack.carriedPlushies.length = 0;
stack.lodgedPins.length = 0;
for (const it of worldRef.items || []) {
if (!it || it.dead || isCachedTerrainItem(it)) continue;
if (isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
continue;
}
if (it.isStructure && it.type === "plushie" && it.carriedById) {
stack.carriedPlushies.push(it);
continue;
}
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
}
for (const t of worldRef.tarinai || []) {
if (t && !t.dead) stack.layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
}
for (const a of worldRef.ants || []) {
if (a && !a.dead) stack.layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
}
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
stack.signature = signature;
worldRef.drawListDirty = false;
return stack;
}
function collectVisibleRenderStack(worldRef, visibleRect) {
const end = window.TarinaiPerf?.begin?.("render.stackBuild") || null;
try {
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
const stack = worldRef._visibleRenderStack;
stack.backItems.length = 0;
stack.layered.length = 0;
stack.carriedPlushies.length = 0;
stack.lodgedPins.length = 0;
const seen = new Set();
const addItem = (it) => {
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it) || !isEntityVisibleInRect(it, visibleRect)) return;
seen.add(it);
if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
return;
}
if (it.isStructure && it.type === "plushie" && it.carriedById) {
stack.carriedPlushies.push(it);
return;
}
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
};
const addLayered = (entity) => {
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
seen.add(entity);
stack.layered.push({ entity, y: renderLayerSortY(entity), rank: renderLayerKindRank(entity) });
};
worldRef.ensureSpatial?.("render-visible");
if (worldRef.spatial?.nearbyRectInto) {
const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []);
itemScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
for (const it of itemScratch) addItem(it);
const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
tarinaiScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
for (const t of tarinaiScratch) addLayered(t);
const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []);
antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
for (const a of antScratch) addLayered(a);
} else {
for (const it of worldRef.items || []) addItem(it);
for (const t of worldRef.tarinai || []) addLayered(t);
for (const a of worldRef.ants || []) addLayered(a);
}
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
return stack;
} finally {
if (end) end();
}
}
function ensureBackgroundCache(w, h, lighting) {
const key = backgroundCacheKey(world, lighting);
if (!backgroundCache.canvas) {
@ -435,10 +635,11 @@ function drawGardenBackdrop(w, h, lighting) {
function drawGardenBed(w, h, lighting) {
const light = lighting.light;
const fieldType = world.fieldType || "garden";
const groundType = world.groundType || "soil";
const x = 18, y = 22, bw = w - 36, bh = h - 44;
const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
const borderTop = groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#b78a62" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#815d43" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72");
border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68");
activeCtx.fillStyle = border;
@ -454,11 +655,17 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14);
const palette = fieldType === "park"
? ["#cfe5a8", "#b8d990", "#91bd74"]
: fieldType === "cage"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
const palette = groundType === "concrete"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: groundType === "blanket"
? ["#ffe1ec", "#f6c6dc", "#e3a2c6"]
: groundType === "foot_massage"
? ["#e6c59c", "#cfa677", "#a77a52"]
: fieldType === "park"
? ["#cfe5a8", "#b8d990", "#91bd74"]
: fieldType === "cage"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6");
soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd");
soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af");
@ -471,19 +678,34 @@ function drawGardenBed(w, h, lighting) {
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
activeCtx.clip();
const speckCount = fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
const speckCount = groundType === "blanket" ? 28 : groundType === "foot_massage" ? 86 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
for (let i = 0; i < speckCount; i++) {
const px = randSeed(900 + i, x + 24, x + bw - 24);
const py = randSeed(1200 + i, y + 22, y + bh - 24);
const r = randSeed(1600 + i, 1.2, 3.8);
const a = randSeed(1900 + i, 0.03, 0.09) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70);
activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`;
activeCtx.beginPath();
activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2);
activeCtx.fill();
if (groundType === "foot_massage") {
activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 118, 166))}, ${Math.round(randSeed(2300 + i, 75, 112))}, ${Math.round(randSeed(2600 + i, 48, 78))}, ${a * 1.7})`;
activeCtx.beginPath();
activeCtx.arc(px, py, r * 1.25, 0, Math.PI * 2);
activeCtx.fill();
} else {
activeCtx.fillStyle = groundType === "blanket" ? `rgba(255,255,255,${a * 1.35})` : `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`;
activeCtx.beginPath();
activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2);
activeCtx.fill();
}
}
if (fieldType === "cage") {
if (groundType === "blanket") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.16)";
activeCtx.lineWidth = 1.25;
for (let gy = y + 38; gy < y + bh - 26; gy += 32) {
activeCtx.beginPath(); activeCtx.moveTo(x + 28, gy); activeCtx.lineTo(x + bw - 28, gy + Math.sin(gy * 0.05) * 3); activeCtx.stroke();
}
activeCtx.restore();
} else if (fieldType === "cage") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)";
activeCtx.lineWidth = 1.1;
@ -506,7 +728,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const patchCount = fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
const patchCount = groundType === "concrete" ? 3 : groundType === "blanket" ? 0 : fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
for (let i = 0; i < patchCount; i++) {
const px = randSeed(3300 + i, x + 40, x + bw - 40);
const py = randSeed(3600 + i, y + 44, y + bh - 46);
@ -519,7 +741,7 @@ function drawGardenBed(w, h, lighting) {
}
// edge grasses and tiny flowers
const edgeGrassCount = fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
const edgeGrassCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
for (let i = 0; i < edgeGrassCount; i++) {
const side = i % 2;
const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
@ -539,7 +761,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const flowerCount = fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
const flowerCount = groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
for (let i = 0; i < flowerCount; i++) {
const px = randSeed(7000 + i, x + 44, x + bw - 44);
const py = randSeed(7400 + i, y + 46, y + bh - 44);
@ -558,7 +780,7 @@ function drawGardenBed(w, h, lighting) {
}
}
const leafCount = fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
const leafCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
for (let i = 0; i < leafCount; i++) {
const px = randSeed(7800 + i, x + 36, x + bw - 36);
const py = randSeed(8200 + i, y + 34, y + bh - 34);
@ -655,8 +877,7 @@ function drawAtmosphericLighting(ctx, w, h, lighting) {
function drawRain(ctx, w, h, t) {
if (world.weather !== "light_rain") return;
ctx.save();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const count = perf >= 2 ? 64 : 138;
const count = 96;
const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites.
const slant = Math.tan(angle);
for (let i = 0; i < count; i++) {
@ -680,7 +901,7 @@ function drawRain(ctx, w, h, t) {
ctx.moveTo(x - dx * 0.5, y - dy * 0.5);
ctx.lineTo(x + dx * 0.5, y + dy * 0.5);
ctx.stroke();
if (perf < 3 && i % 4 === 0) {
if (i % 4 === 0) {
ctx.globalAlpha = 0.13 * alpha;
ctx.strokeStyle = "rgba(255,255,255,0.70)";
ctx.lineWidth = 0.7;
@ -746,60 +967,31 @@ function render() {
}
ctx.restore();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting);
drawLightRays(ctx, screenW, screenH, lighting);
const visibleRect = visibleWorldRect(world, 180);
const terrainLayer = ensureTerrainCache(world, lighting);
beginFieldTransform();
drawPlacementPreview(ctx, world);
if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH);
drawTerrainLayer(ctx, world, lighting, visibleRect);
// Low bedding is drawn one step behind the live sprite stack.
const backItems = [];
const layered = [];
for (const it of world.items) {
if (!it || it.dead || isCachedTerrainItem(it)) continue;
if (isPinType(it.type) && it.pinState === "lodged") continue;
if (it.isStructure && it.type === "plushie" && it.carriedById) continue;
if (!isEntityVisibleInRect(it, visibleRect)) continue;
if (isBehindSpriteLayerItem(it)) backItems.push(it);
else layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
const renderStack = collectVisibleRenderStack(world, visibleRect);
for (const it of renderStack.backItems) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
for (const it of backItems) it.draw(ctx, world.time, lighting);
ctx.save();
ctx.lineWidth = 1.15;
ctx.setLineDash([4, 4]);
ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)";
for (const t of world.tarinai) {
if (!isEntityVisibleInRect(t, visibleRect, 80)) continue;
const p = t.parentToFollow?.();
if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue;
ctx.beginPath();
ctx.moveTo(t.x, t.y);
ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y);
ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
// Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai.
for (const t of world.tarinai || []) {
if (t && !t.dead && isEntityVisibleInRect(t, visibleRect)) layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
for (const entry of renderStack.layered) {
if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting);
}
for (const a of world.ants || []) {
if (a && !a.dead && isEntityVisibleInRect(a, visibleRect)) layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
}
layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
for (const entry of layered) entry.entity.draw(ctx, world.time, lighting);
for (const it of world.items) {
if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
for (const it of renderStack.carriedPlushies) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const it of world.items) {
if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
for (const it of renderStack.lodgedPins) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const ef of world.effects) {
if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
@ -869,30 +1061,51 @@ function render() {
}
function drawPointerItemTooltip(ctx, world) {
const nest = world.pointerNestBoxInfo?.();
const bedInfo = !nest ? world.pointerOwnedBedInfo?.() : null;
let target = null;
let lines = [];
if (nest?.box) {
target = nest.box;
const names = (nest.occupants || []).map(t => t?.name).filter(Boolean).slice(0, 5);
lines = ["\u5de3\u7bb1"];
if (names.length) lines.push(`${"\u5229\u7528\u4e2d"}: ${names.join("\u3001")}`);
else lines.push("\u5229\u7528\u4e2d: \u306a\u3057");
} else if (bedInfo?.bed) {
target = bedInfo.bed;
lines = ["\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", `${"\u6301\u3061\u4e3b"}: ${bedInfo.owner?.name || "\u4e0d\u660e"}`];
}
if (!target || !lines.length) return;
const info = world.pointerItemTooltipInfo?.();
const target = info?.target || null;
const rawLines = (info?.lines || []).filter(Boolean);
if (!target || !rawLines.length) return;
ctx.save();
ctx.font = "12px Yomogi, sans-serif";
const maxTextW = 238;
const ellipsize = (text, maxW) => {
let s = String(text || "");
if (ctx.measureText(s).width <= maxW) return s;
while (s.length > 1 && ctx.measureText(`${s}`).width > maxW) s = s.slice(0, -1);
return `${s}`;
};
const wrapLine = (text, maxW) => {
const s = String(text || "");
if (ctx.measureText(s).width <= maxW) return [s];
if (s.includes("、")) {
const parts = s.split("、");
const out = [];
let line = "";
for (const part of parts) {
const candidate = line ? `${line}${part}` : part;
if (ctx.measureText(candidate).width <= maxW) line = candidate;
else {
if (line) out.push(line);
line = part;
}
}
if (line) out.push(line);
return out.slice(0, 3).map(line => ellipsize(line, maxW));
}
return [ellipsize(s, maxW)];
};
let lines = [];
for (const line of rawLines) lines.push(...wrapLine(line, maxTextW));
if (lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("、"), maxTextW)];
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
const lineH = 17;
const w = Math.min(280, Math.max(92, textW + 22));
const h = Math.max(38, 16 + lines.length * lineH);
const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y };
const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8);
const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, ctx.canvas.height - h - 8);
const canvasW = world.viewportW || world.w || ctx.canvas.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas.height || 1;
const x = clamp(pos.x - w / 2, 8, canvasW - w - 8);
const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, canvasH - h - 8);
ctx.fillStyle = "rgba(46,38,30,0.66)";
ctx.strokeStyle = "rgba(255,248,220,0.74)";
ctx.lineWidth = 1.2;

35
js/restore_coordinator.js Normal file
View file

@ -0,0 +1,35 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for restore_coordinator.js");
function renderWorldAfterRestore(worldRef = global.world) {
if (!worldRef) return;
if (typeof renderLog === "function") renderLog(worldRef.logs || []);
if (typeof resetArchiveRenderState === "function") resetArchiveRenderState();
if (typeof renderArchive === "function") renderArchive();
if (typeof renderSelected === "function") renderSelected();
if (typeof renderStats === "function") renderStats();
if (typeof render === "function") render();
if (typeof syncTopButtons === "function") syncTopButtons();
}
function syncRestoreDependents(worldRef = global.world, context = {}) {
global.TarinaiFreezeSystem?.afterWorldRestored?.(worldRef, context);
worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" });
renderWorldAfterRestore(worldRef);
}
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
const result = Snapshot.restoreSnapshot(snapshot, worldRef);
if (options.syncUi !== false) syncRestoreDependents(worldRef, { source: options.source || "snapshot", snapshotVersion: result?.snapshotVersion || Snapshot.version });
return result;
}
global.TarinaiRestoreCoordinator = {
restoreSnapshot,
syncRestoreDependents,
renderWorldAfterRestore,
};
})(typeof window !== "undefined" ? window : globalThis);

139
js/save_codec.js Normal file
View file

@ -0,0 +1,139 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
const EXPORT_PREFIX = "TN3!";
const SAVE_TEXT_ALPHABET = (() => {
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (ch === "*" || ch === "'" || ch === "_") continue;
out += ch;
}
return out;
})();
const SAVE_TEXT_REVERSE = (() => {
const map = new Map();
for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i);
return map;
})();
if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars");
function base91Encode(bytes) {
let b = 0;
let n = 0;
let out = "";
for (const byte of bytes || []) {
b |= (byte & 255) << n;
n += 8;
if (n > 13) {
let v = b & 8191;
if (v > 88) {
b >>= 13;
n -= 13;
} else {
v = b & 16383;
b >>= 14;
n -= 14;
}
out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)];
}
}
if (n) {
out += SAVE_TEXT_ALPHABET[b % 91];
if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)];
}
return out;
}
function base91Decode(text) {
const clean = String(text || "").trim();
let v = -1;
let b = 0;
let n = 0;
const out = [];
for (const ch of clean) {
const c = SAVE_TEXT_REVERSE.get(ch);
if (c == null) continue;
if (v < 0) {
v = c;
} else {
v += c * 91;
b |= v << n;
n += (v & 8191) > 88 ? 13 : 14;
do {
out.push(b & 255);
b >>= 8;
n -= 8;
} while (n > 7);
v = -1;
}
}
if (v >= 0) out.push((b | (v << n)) & 255);
return new Uint8Array(out);
}
async function compressBytes(bytes) {
if (typeof CompressionStream === "function") {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function decompressBytes(bytes, codec = "R") {
if (codec === "D") {
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function encodeSnapshot(snapshot) {
if (!snapshot || snapshot.v !== Snapshot.version) throw new Error("snapshot version mismatch");
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
let codec = "R";
let packed = bytes;
try {
const compressed = await compressBytes(bytes);
if (compressed.length < bytes.length) {
codec = "D";
packed = compressed;
}
} catch (error) {
console.warn("save compression fallback", error);
}
return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`;
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw) throw new Error("empty import text");
if (raw.startsWith("{") || raw.startsWith("[")) {
const data = JSON.parse(raw);
if (data?.v !== Snapshot.version) throw new Error("unsupported save version");
return data;
}
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text");
const codec = raw.charAt(EXPORT_PREFIX.length) || "R";
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = base91Decode(payload);
const bytes = await decompressBytes(packed, codec);
const json = new TextDecoder().decode(bytes);
const data = JSON.parse(json);
if (data?.v !== Snapshot.version) throw new Error("unsupported save version");
return data;
}
global.TarinaiSaveCodec = {
EXPORT_PREFIX,
SAVE_TEXT_ALPHABET,
base91Encode,
base91Decode,
encodeSnapshot,
decodeSnapshot,
};
})(typeof window !== "undefined" ? window : globalThis);

47
js/save_storage.js Normal file
View file

@ -0,0 +1,47 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_storage.js");
const STORAGE_PREFIX = "tarinai_save_slot_v3_";
const SLOT_COUNT = 9;
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
function writeSlot(slot, snapshot) {
if (!snapshot || snapshot.v !== Snapshot.version) throw new Error("unsupported slot version");
global.localStorage?.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
}
function readSlot(slot) {
const raw = global.localStorage?.getItem(slotKey(slot));
if (!raw) return null;
try {
const data = JSON.parse(raw);
return data?.v === Snapshot.version ? data : null;
} catch (_) {
return null;
}
}
function requireSlot(slot) {
const snapshot = readSlot(slot);
if (!snapshot) throw new Error("empty slot");
return snapshot;
}
function deleteSlot(slot) {
global.localStorage?.removeItem(slotKey(slot));
}
global.TarinaiSaveStorage = {
STORAGE_PREFIX,
SLOT_COUNT,
slotKey,
writeSlot,
readSlot,
requireSlot,
deleteSlot,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,75 +1,52 @@
"use strict";
(function (global) {
const STORAGE_PREFIX = "tarinai_save_slot_v1_";
const EXPORT_PREFIX = "TARINAI_SAVE_V1:";
const SLOT_COUNT = 9;
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js");
const createSnapshot = (...args) => Snapshot.createSnapshot(...args);
const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args);
function bytesToBase64(bytes) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function base64ToBytes(text) {
let b64 = String(text || "").trim().replace(/^#+/, "").replace(EXPORT_PREFIX, "").replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function encodeSnapshot(snapshot) {
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
return EXPORT_PREFIX + bytesToBase64(bytes);
}
function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw) throw new Error("empty import text");
if (raw.startsWith("{") || raw.startsWith("[")) return JSON.parse(raw);
const bytes = base64ToBytes(raw);
const json = new TextDecoder().decode(bytes);
return JSON.parse(json);
}
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
function saveSlot(slot) {
const snapshot = createSnapshot(global.world);
localStorage.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
}
function loadSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) throw new Error("empty slot");
return restoreSnapshot(JSON.parse(raw), global.world);
}
function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); }
function readSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) return null;
try { return JSON.parse(raw); } catch (_) { return null; }
}
const Codec = global.TarinaiSaveCodec;
const Storage = global.TarinaiSaveStorage;
const Restore = global.TarinaiRestoreCoordinator;
if (!Snapshot || !Codec || !Storage || !Restore) throw new Error("save_system.js dependencies are not available");
const SLOT_COUNT = Storage.SLOT_COUNT;
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
function createSnapshot(worldRef = global.world) {
return Snapshot.createSnapshot(worldRef);
}
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
return Restore.restoreSnapshot(snapshot, worldRef, { source: options.source || "save", syncUi: options.syncUi });
}
async function encodeSnapshot(snapshot = createSnapshot(global.world)) {
return Codec.encodeSnapshot(snapshot);
}
async function decodeSnapshot(text) {
return Codec.decodeSnapshot(text);
}
async function saveSlot(slot) {
const snapshot = createSnapshot(global.world);
return Storage.writeSlot(slot, snapshot);
}
async function loadSlot(slot) {
const snapshot = Storage.requireSlot(slot);
return restoreSnapshot(snapshot, global.world, { source: "slot" });
}
function deleteSlot(slot) { Storage.deleteSlot(slot); }
function readSlot(slot) { return Storage.readSlot(slot); }
function formatSlotSummary(snapshot) {
if (!snapshot) return "空き";
const d = snapshot.summary || {};
const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明";
return `${date} / ${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""} / ${d.population || 0}`;
if (!snapshot) return "\u7a7a\u304d";
const m = snapshot.m || [];
const date = snapshot.c ? new Date(snapshot.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e";
const day = m[0] || 1;
const population = m[1] || 0;
const fieldType = m[2] || "\u5ead";
const timeText = Number.isFinite(m[3]) ? `${Math.floor((m[3] / 10) % 24).toString().padStart(2, "0")}:00` : "";
return `${date} / ${fieldType} / ${day}\u65e5\u76ee ${timeText} / ${population}\u5339`;
}
function ensureDialog() {
@ -141,7 +118,9 @@
openDialog();
});
const dialog = ensureDialog();
dialog.addEventListener("click", (e) => {
if (dialog.dataset.saveDialogBound === "1") return;
dialog.dataset.saveDialogBound = "1";
dialog.addEventListener("click", async (e) => {
if (e.target === dialog) { closeDialog(); return; }
const saveBtn = e.target.closest("[data-save-slot]");
const loadBtn = e.target.closest("[data-load-slot]");
@ -149,61 +128,61 @@
if (saveBtn) {
const slot = Number(saveBtn.dataset.saveSlot);
try {
saveSlot(slot);
await saveSlot(slot);
renderSlotList();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u4fdd\u5b58しました。`);
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
global.showToast?.("\u4fdd\u5b58に失敗しました。空き容量を確認してください。");
global.showToast?.("\u4fdd\u5b58\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u7a7a\u304d\u5bb9\u91cf\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
}
return;
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みます。現在の状態は上書きされます。`)) return;
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`)) return;
try {
loadSlot(slot);
await loadSlot(slot);
closeDialog();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みました。`);
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
global.showToast?.("読み込みに失敗しました。");
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
}
return;
}
if (deleteBtn) {
const slot = Number(deleteBtn.dataset.deleteSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u524a\u9664しますか`)) return;
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f`)) return;
deleteSlot(slot);
renderSlotList();
global.audio?.delete?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u524a\u9664しました。`);
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3057\u305f\u3002`);
return;
}
if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) {
const area = dialog.querySelector("#saveHashText");
if (area) {
area.value = encodeSnapshot(createSnapshot(global.world));
area.value = await encodeSnapshot(createSnapshot(global.world));
area.focus();
area.select();
}
global.showToast?.("現在の状態をハッシュテキストに書き出しました。");
global.showToast?.("\u73fe\u5728\u306e\u72b6\u614b\u3092\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306b\u66f8\u304d\u51fa\u3057\u307e\u3057\u305f\u3002");
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; }
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307fます。現在の状態は上書きされます。")) return;
if (!text.trim()) { global.showToast?.("\u8aad\u307f\u8fbc\u3080\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return;
try {
restoreSnapshot(decodeSnapshot(text), global.world);
restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" });
closeDialog();
global.showToast?.("ハッシュテキストから読み込みました。");
global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
} catch (error) {
console.warn(error);
global.showToast?.("読み込みに失敗しました。テキストを確認してください。");
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30c6\u30ad\u30b9\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
}
}
});

View file

@ -122,11 +122,6 @@ function scaleForGrowth(adultScale = 0.28, growth = 1) {
const PERSONALITIES = Object.freeze({});
const PERSONALITY_IDS = [];
function personalityForSeed(seed) {
return "";
}
const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"];
const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 };
const FRIEND_AFFINITY_THRESHOLD = 14;
@ -371,13 +366,6 @@ function relationDefaults() {
return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 };
}
function isParentChild(a, b) {
if (!a || !b) return false;
const aKey = a.familyKey || a.id;
const bKey = b.familyKey || b.id;
return Boolean(a.parents?.includes(bKey) || b.parents?.includes(aKey));
}
function relationDisplayName(worldRef, id) {
if (!id) return "\u306a\u3057";
const live = worldRef?.tarinai?.find(t => t.id === id || t.familyKey === id);
@ -539,20 +527,64 @@ class SpatialGrid {
constructor(cellSize = 96) {
this.cellSize = cellSize;
this.itemCells = new Map();
this.staticItemCells = new Map();
this.dynamicItemCells = new Map();
this.tarinaiCells = new Map();
this.antCells = new Map();
this.obstacleCells = new Map();
this.foodCells = new Map();
this.hazardCells = new Map();
this.staticObstacleCells = new Map();
this.dynamicObstacleCells = new Map();
this.staticFoodCells = new Map();
this.dynamicFoodCells = new Map();
this.staticHazardCells = new Map();
this.dynamicHazardCells = new Map();
}
clear() {
this.clearItems();
this.clearTarinai();
this.clearAnts();
}
clearItems() {
this.itemCells.clear();
this.tarinaiCells.clear();
this.antCells.clear();
this.staticItemCells.clear();
this.dynamicItemCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.staticObstacleCells.clear();
this.dynamicObstacleCells.clear();
this.staticFoodCells.clear();
this.dynamicFoodCells.clear();
this.staticHazardCells.clear();
this.dynamicHazardCells.clear();
}
clearStaticItems(opts = {}) {
this.staticItemCells.clear();
this.staticObstacleCells.clear();
this.staticFoodCells.clear();
this.staticHazardCells.clear();
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
clearDynamicItems(opts = {}) {
this.dynamicItemCells.clear();
this.dynamicObstacleCells.clear();
this.dynamicFoodCells.clear();
this.dynamicHazardCells.clear();
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
clearTarinai() {
this.tarinaiCells.clear();
}
clearAnts() {
this.antCells.clear();
}
keyFor(x, y) {
@ -572,24 +604,115 @@ class SpatialGrid {
bucket.push(entity);
}
isDynamicItem(it) {
if (!it || it.dead) return false;
const type = it.type || "";
if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if ((it.dropTimer || 0) > 0) return true;
if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true;
if (it.isStructure && it.type === "plushie" && it.carriedById) return true;
return false;
}
addItemToTraitCells(it, dynamic = this.isDynamicItem(it)) {
const type = it.type || "";
const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells;
const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells;
const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells;
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(obstacleMap, it);
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(foodMap, it);
if (typeof itemHasTrait === "function" && itemHasTrait(type, "hazard")) this.add(hazardMap, it);
}
classifyItem(it) {
if (!it || it.dead) return;
const dynamic = this.isDynamicItem(it);
const target = dynamic ? this.dynamicItemCells : this.staticItemCells;
this.add(target, it);
this.add(this.itemCells, it);
const type = it.type || "";
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(this.obstacleCells, it);
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(this.foodCells, it);
if (itemHasTrait(type, "hazard")) this.add(this.hazardCells, it);
this.addItemToTraitCells(it, dynamic);
}
rebuildItems(items) {
this.clearItems();
for (const it of items || []) this.classifyItem(it);
this.rebuildCombinedItemCells();
}
rebuildStaticItems(items, opts = {}) {
this.staticItemCells.clear();
this.staticObstacleCells.clear();
this.staticFoodCells.clear();
this.staticHazardCells.clear();
for (const it of items || []) {
if (!it || it.dead || this.isDynamicItem(it)) continue;
this.add(this.staticItemCells, it);
this.addItemToTraitCells(it, false);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
rebuildDynamicItems(items, opts = {}) {
this.dynamicItemCells.clear();
this.dynamicObstacleCells.clear();
this.dynamicFoodCells.clear();
this.dynamicHazardCells.clear();
for (const it of items || []) {
if (!it || it.dead || !this.isDynamicItem(it)) continue;
this.add(this.dynamicItemCells, it);
this.addItemToTraitCells(it, true);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
copyCells(src, dst) {
for (const [key, bucket] of src.entries()) {
let out = dst.get(key);
if (!out) {
out = [];
dst.set(key, out);
}
for (const entity of bucket) out.push(entity);
}
}
rebuildCombinedItemCells() {
this.itemCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.copyCells(this.staticItemCells, this.itemCells);
this.copyCells(this.dynamicItemCells, this.itemCells);
this.copyCells(this.staticObstacleCells, this.obstacleCells);
this.copyCells(this.dynamicObstacleCells, this.obstacleCells);
this.copyCells(this.staticFoodCells, this.foodCells);
this.copyCells(this.dynamicFoodCells, this.foodCells);
this.copyCells(this.staticHazardCells, this.hazardCells);
this.copyCells(this.dynamicHazardCells, this.hazardCells);
}
rebuildTarinai(tarinai) {
this.tarinaiCells.clear();
for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t);
}
rebuildAnts(ants = []) {
this.antCells.clear();
for (const a of ants || []) if (!a.dead) this.add(this.antCells, a);
}
rebuild(items, tarinai, ants = []) {
this.clear();
for (const it of items || []) this.classifyItem(it);
for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t);
for (const a of ants || []) if (!a.dead) this.add(this.antCells, a);
this.rebuildItems(items);
this.rebuildTarinai(tarinai);
this.rebuildAnts(ants);
}
nearby(map, x, y, radius, out = [], filterDistance = false) {
out.length = 0;
return this.nearbyInto(map, x, y, radius, out, filterDistance);
}
nearbyInto(map, x, y, radius, out = [], filterDistance = false) {
const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
const minX = Math.floor((x - r) / this.cellSize);
const maxX = Math.floor((x + r) / this.cellSize);
@ -612,6 +735,29 @@ class SpatialGrid {
}
return out;
}
nearbySplitInto(staticMap, dynamicMap, x, y, radius, out = [], filterDistance = false) {
out.length = 0;
if (staticMap) this.nearbyInto(staticMap, x, y, radius, out, filterDistance);
if (dynamicMap) this.nearbyInto(dynamicMap, x, y, radius, out, filterDistance);
return out;
}
nearbyRectInto(map, rect, out = []) {
if (!rect) return out;
const minX = Math.floor((rect.left || 0) / this.cellSize);
const maxX = Math.floor((rect.right || 0) / this.cellSize);
const minY = Math.floor((rect.top || 0) / this.cellSize);
const maxY = Math.floor((rect.bottom || 0) / this.cellSize);
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const bucket = map.get(cx + cy * 100000);
if (!bucket) continue;
for (const entity of bucket) out.push(entity);
}
}
return out;
}
}
function drawHeartShape(ctx, x, y, size, opts = {}) {

View file

@ -1,7 +1,8 @@
"use strict";
(function (global) {
const SNAPSHOT_VERSION = 1;
const SNAPSHOT_VERSION = 3;
const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]);
function isPlainObject(value) {
@ -56,6 +57,52 @@
return target;
}
function q(value, scale = 1, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? Math.round(n * scale) : fallback;
}
function u(value, scale = 1, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n / scale : fallback;
}
function needsToArray(needs = {}) {
return NEED_KEYS.map(k => q(needs[k], 1));
}
function arrayToNeeds(arr = []) {
const out = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
NEED_KEYS.forEach((k, i) => { out[k] = u(arr[i], 1); });
return out;
}
function flagPack(t = {}) {
let flags = 0;
if (t.dead) flags |= 1 << 0;
if (t.favorite) flags |= 1 << 1;
if (t.zunchiDisease) flags |= 1 << 2;
if (t.sleepDisease) flags |= 1 << 3;
if (t.explosionDisease) flags |= 1 << 4;
if (t.fightDisease) flags |= 1 << 5;
if (t.isZunchiSlave) flags |= 1 << 6;
if (t.zunchiSlaveLocked) flags |= 1 << 7;
if (t.birthRitualLeader) flags |= 1 << 8;
return flags;
}
function flagApply(t, flags = 0) {
t.dead = !!(flags & (1 << 0));
t.favorite = !!(flags & (1 << 1));
t.zunchiDisease = !!(flags & (1 << 2));
t.sleepDisease = !!(flags & (1 << 3));
t.explosionDisease = !!(flags & (1 << 4));
t.fightDisease = !!(flags & (1 << 5));
t.isZunchiSlave = !!(flags & (1 << 6));
t.zunchiSlaveLocked = !!(flags & (1 << 7));
t.birthRitualLeader = !!(flags & (1 << 8));
}
function refFor(worldRef, value) {
if (!value || typeof value !== "object") return null;
if ((worldRef?.tarinai || []).includes(value)) return { kind: "tarinai", id: value.id || "", token: value.liveToken || 0, familyKey: value.familyKey || "" };
@ -75,77 +122,307 @@
}
function worldData(worldRef) {
const keys = [
"w", "h", "viewportW", "viewportH", "time", "day", "paused", "speed", "tool", "toolSize", "toolSizes", "frozenTarinai",
"fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount",
"liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "familyVersion", "familyTreeDirtyReason",
"relationNotices", "resolvedFightIds", "eventCounters", "foodSpoilagePenalty", "foodSpoilageEvents",
"lastPhase", "colonyMood", "lastColonyMoodDay", "antUpdatePhase", "shakeTimer", "shakeDuration", "shakeStrength"
return {
fieldType: worldRef?.fieldType || "garden",
groundType: worldRef?.groundType || "soil",
time: Number(worldRef?.time) || 0,
day: Number(worldRef?.day) || 1,
cameraX: Number(worldRef?.cameraX) || 0,
cameraY: Number(worldRef?.cameraY) || 0,
weather: worldRef?.weather || "sunny",
nextWeatherChange: Number(worldRef?.nextWeatherChange) || 0,
deadCount: Number(worldRef?.deadCount) || 0,
liveIdNext: Number(worldRef?.liveIdNext) || 1,
liveIdSerial: Number(worldRef?.liveIdSerial) || 1,
lastBirthAt: Number(worldRef?.lastBirthAt) || -999,
maxGeneration: Number(worldRef?.maxGeneration) || 1,
colonyMood: worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed",
};
}
function compactWorld(worldRef) {
const mood = worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed";
return [
worldRef?.fieldType || "garden",
q(worldRef?.time, 10),
q(worldRef?.day, 1, 1),
q(worldRef?.cameraX, 1),
q(worldRef?.cameraY, 1),
worldRef?.weather || "sunny",
q(worldRef?.nextWeatherChange, 10),
q(worldRef?.deadCount, 1),
q(worldRef?.liveIdNext, 1, 1),
q(worldRef?.liveIdSerial, 1, 1),
q(worldRef?.lastBirthAt, 10, -9990),
q(worldRef?.maxGeneration, 1, 1),
mood,
worldRef?.groundType || "soil",
];
const out = {};
for (const key of keys) {
const cloned = clonePlain(worldRef?.[key]);
if (cloned !== undefined) out[key] = cloned;
}
function applyCompactWorld(worldRef, arr = []) {
worldRef.fieldType = arr[0] || "garden";
worldRef.time = u(arr[1], 10);
worldRef.day = u(arr[2], 1, 1);
worldRef.cameraX = u(arr[3], 1);
worldRef.cameraY = u(arr[4], 1);
worldRef.weather = arr[5] || "sunny";
worldRef.nextWeatherChange = u(arr[6], 10);
worldRef.deadCount = u(arr[7], 1);
worldRef.liveIdNext = Math.max(1, u(arr[8], 1, 1));
worldRef.liveIdSerial = Math.max(1, u(arr[9], 1, 1));
worldRef.lastBirthAt = u(arr[10], 10, -999);
worldRef.maxGeneration = Math.max(1, u(arr[11], 1, 1));
const moodId = arr[12] || "relaxed";
worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || worldRef.colonyMoodDefinition?.("relaxed") || { id: moodId, label: moodId, effects: { personality: {} } };
worldRef.setGroundType?.(arr[13] || "soil", { silent: true, force: true });
}
function compactBehaviorTarget(ref = null) {
if (!ref || typeof ref !== "object") return null;
const id = ref.id != null ? String(ref.id) : "";
if ((ref.kind === "tarinai" || ref.familyKey || ref.name) && id) return ["t", id];
if ((ref.kind === "item" || ref.type) && id) return ["i", id];
if (Number.isFinite(ref.x) && Number.isFinite(ref.y)) return ["p", q(ref.x, 1), q(ref.y, 1)];
return null;
}
function expandBehaviorTarget(row = null) {
if (!Array.isArray(row) || !row[0]) return null;
if (row[0] === "t") return { kind: "tarinai", id: String(row[1] || "") };
if (row[0] === "i") return { kind: "item", id: String(row[1] || "") };
if (row[0] === "p") return { kind: "position", x: u(row[1], 1), y: u(row[2], 1) };
return null;
}
function compactBehavior(b = null, tarinaiIndex = new Map(), itemIndex = new Map()) {
if (!b || typeof b !== "object") return null;
const target = compactBehaviorTarget(b.targetRef || b.target || null);
const forced = typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.source === "forced" || b.forcedId || b.forcedRequest);
const source = forced ? "forced" : String(b.source || "need");
return [
b.actionId || "", b.need || "", b.subNeed || "", b.phase || "", source,
b.reason || "", b.text || "", b.label || "", b.forcedId || "", target,
].filter((v, i) => i < 5 || v);
}
function expandBehavior(arr = null) {
if (!Array.isArray(arr) || !arr[0]) return null;
return {
actionId: arr[0] || "",
need: arr[1] || "",
subNeed: arr[2] || "",
phase: arr[3] || "",
source: arr[4] || "need",
reason: arr[5] || "",
text: arr[6] || arr[5] || "",
label: arr[7] || "",
forcedId: arr[8] || "",
target: expandBehaviorTarget(arr[9]),
};
}
function compactRelationships(t, tarinaiIndex) {
const out = [];
for (const [id, rel] of Object.entries(t.relationships || {})) {
const idx = tarinaiIndex.get(id);
if (!Number.isInteger(idx) || idx < 0) continue;
const affinity = Number(rel?.affinity) || 0;
const fear = Number(rel?.fear) || 0;
const wins = Number(rel?.fightsWon) || 0;
const losses = Number(rel?.fightsLost) || 0;
if (Math.abs(affinity) < 1 && fear < 1 && wins < 1 && losses < 1) continue;
out.push([idx, q(affinity, 10), q(fear, 10), q(wins, 1), q(losses, 1)]);
}
return out;
}
function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) {
const data = copyOwnData(entity, skip);
const targetRef = refFor(worldRef, entity?.target);
const panicTargetRef = refFor(worldRef, entity?.panicTarget);
const targetRefObject = refFor(worldRef, entity?.targetRef);
if (targetRef) data.__targetRef = targetRef;
if (panicTargetRef) data.__panicTargetRef = panicTargetRef;
if (targetRefObject) data.__targetRefObject = targetRefObject;
return data;
function expandRelationships(rows = [], tarinaiList = [], worldRef = null) {
const out = {};
for (const row of rows || []) {
if (!Array.isArray(row)) continue;
const other = tarinaiList[row[0]];
if (!other?.id) continue;
out[other.id] = {
affinity: u(row[1], 10),
fear: u(row[2], 10),
fightsWon: u(row[3], 1),
fightsLost: u(row[4], 1),
lastEvent: (u(row[3], 1) || u(row[4], 1)) ? "fight" : "",
lastTime: worldRef?.time || 0,
};
}
return out;
}
function compactTarinai(t, idx, familyIndex, tarinaiIndex, itemIndex) {
const parentIdx = (t.parents || []).map(id => familyIndex.get(id)).filter(Number.isInteger);
const childIdx = (t.children || []).map(id => familyIndex.get(id)).filter(Number.isInteger);
const timers = [
q(t.reproductionTimer, 10), q(t.fightCooldown, 10), q(t.loveMochiTimer, 10), q(t.fightMochiTimer, 10),
q(t.zunchiDiseaseSeverity, 10), q(t.zunchiStain, 10), q(t.postBirthPeaceTimer, 10), q(t.awakeLockTimer, 10),
q(t.itemEffectTimers?.laxative, 10), q(t.itemEffectTimers?.ammo, 10), q(t.mercuryLifeMultiplier, 100),
];
const modes = [t.powerItemMode || "", t.sizeItemMode || "", t.lifeItemMode || ""];
const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1;
const nestIdx = itemIndex.get(t.insideNestBoxId) ?? -1;
return [
t.name || "", t.type || "smile", t.birthPersonality || null, t.currentPersonality || null, t.genetics || null,
q(t.x, 1), q(t.y, 1), q(t.vx, 10), q(t.vy, 10), q(t.scale, 1000), q(t.adultScale, 1000),
q(t.age, 10), q(t.lifeSpan, 10), q(t.birthTime, 10), q(t.generation, 1, 1), t.hasPaired ? 1 : 0,
q(t.hunger, 1), q(t.loneliness, 1), q(t.energy, 1), q(t.circadianSleepPressure, 1), needsToArray(t.needs || t.needRaw || {}),
t.state || "idle", flagPack(t), parentIdx, childIdx, compactRelationships(t, tarinaiIndex), timers, modes,
pinIdx, nestIdx, q(t.totalFightWins, 1), q(t.totalFightLosses, 1), t.deathReason || "", compactBehavior(t.behavior, tarinaiIndex, itemIndex),
];
}
function itemExtra(item, tarinaiIndex = new Map()) {
const type = item?.type || "";
if (item?.isStructure) return ["S", q(item.hp, 10), q(item.maxHp, 10), q(item.attachment, 10), q(item.usedCount, 1), tarinaiIndex.get(item.ownerId) ?? -1, item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1, item.onHead ? 1 : 0, item.plushieSpriteId || ""];
if (type === "grass") return [q(item.growth, 1000), q(item.health, 1000), q(item.seedTimer, 10), q(item.eatenAmount, 10), q(item.fertilityBoost, 1000), q(item.lifeSpan, 10), q(item.wither, 1000), item.grassLimitExempt || item.manualGrass || item.placedByPlayer ? 1 : 0];
if (type === "bed") return [q(item.comfort, 1000), q(item.wear, 1000)];
if (type === "signboard") return [item.text || ""];
if (type === "duplicator") return [item.storedFoodType || "", item.storedFoodLabel || ""];
if (type === "ball") return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000)];
if (type === "zunchi") return [item.stage || "fresh", q(item.stageTimer, 10), q(item.fertility, 1000), q(item.freshness, 1000), item.zunchiVariant || ""];
if (type === "ant_nest") return [q(item.antCount, 1), Array.isArray(item.antWorkers) ? item.antWorkers.map(v => q(v, 10)) : [], q(item.queenSpawnAt, 10)];
if (type === "ant_corpse") return [item.workerSprite || "ant_worker", q(item.decayTimer, 10)];
if (type === "firecracker") return [q(item.fuseTimer, 10), q(item.fuseMax, 10)];
if (typeof isPinType === "function" && isPinType(type)) return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000), item.pinState || "loose", tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return [item.toolSize || "medium", q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
return null;
}
function compactItem(item, index, tarinaiIndex = new Map()) {
return [
item?.type || "", q(item?.x, 1), q(item?.y, 1), q(item?.r, 10), q(item?.amount, 10), q(item?.age, 10), q(item?.seed, 10), item?.dead ? 1 : 0, itemExtra(item, tarinaiIndex),
];
}
function applyItemExtra(item, extra, tarinaiList = []) {
if (!Array.isArray(extra)) return;
const type = item.type || "";
if (extra[0] === "S") {
item.isStructure = true;
item.hp = u(extra[1], 10);
item.maxHp = u(extra[2], 10);
item.attachment = u(extra[3], 10);
item.usedCount = u(extra[4], 1);
item.ownerId = tarinaiList[extra[5]]?.id || "";
item.carriedById = tarinaiList[extra[6]]?.id || "";
item.onHead = !!extra[7];
item.plushieSpriteId = extra[8] || item.plushieSpriteId || "";
const def = global.StructureRegistry?.get?.(type);
if (def) {
item.label = def.label;
item.roles = clonePlain(def.roles) || item.roles || {};
item.needEffects = clonePlain(def.needEffects) || item.needEffects || {};
}
return;
}
if (type === "grass") {
item.growth = u(extra[0], 1000); item.health = u(extra[1], 1000); item.seedTimer = u(extra[2], 10); item.eatenAmount = u(extra[3], 10); item.fertilityBoost = u(extra[4], 1000); item.lifeSpan = u(extra[5], 10); item.wither = u(extra[6], 1000); item.grassLimitExempt = false; item.manualGrass = false;
global.TarinaiGrass?.normalize?.(item);
} else if (type === "bed") {
item.comfort = u(extra[0], 1000); item.wear = u(extra[1], 1000);
} else if (type === "signboard") {
item.text = String(extra[0] || "");
} else if (type === "duplicator") {
item.storedFoodType = extra[0] || ""; item.storedFoodLabel = extra[1] || "";
item.roles.food = Boolean(item.storedFoodType);
} else if (type === "ball") {
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000);
} else if (type === "zunchi") {
item.stage = extra[0] || "fresh"; item.stageTimer = u(extra[1], 10); item.fertility = u(extra[2], 1000); item.freshness = u(extra[3], 1000); item.zunchiVariant = extra[4] || item.zunchiVariant || "zunchi";
} else if (type === "ant_nest") {
item.antCount = u(extra[0], 1); item.antWorkers = Array.isArray(extra[1]) ? extra[1].map(v => u(v, 10)) : []; item.queenSpawnAt = u(extra[2], 10);
} else if (type === "ant_corpse") {
item.workerSprite = extra[0] || "ant_worker"; item.decayTimer = u(extra[1], 10);
} else if (type === "firecracker") {
item.fuseTimer = u(extra[0], 10); item.fuseMax = u(extra[1], 10);
} else if (typeof isPinType === "function" && isPinType(type)) {
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000); item.pinState = extra[4] || "loose"; item.pinTargetId = tarinaiList[extra[5]]?.id || ""; item.pinAttachAngle = u(extra[6], 1000); item.pinAttachDistance = u(extra[7], 10); item.pinOffsetY = u(extra[8], 10);
} else if (typeof isServingFoodType === "function" && isServingFoodType(type)) {
item.toolSize = extra[0] || "medium"; item.foodServingsRemaining = u(extra[1], 10); item.foodServingsMax = u(extra[2], 10); item.amount = item.foodServingsRemaining;
}
}
function compactAnt(ant, itemIndex = new Map(), tarinaiIndex = new Map()) {
return [ant.kind || "worker", itemIndex.get(ant.homeId) ?? -1, tarinaiIndex.get(ant.targetId) ?? -1, q(ant.x, 1), q(ant.y, 1), q(ant.vx, 10), q(ant.vy, 10), q(ant.hp, 10), q(ant.maxHp, 10), ant.state || "search", q(ant.age, 10), q(ant.seed, 10), q(ant.returnTimer, 10), q(ant.foundingX, 1), q(ant.foundingY, 1), q(ant.foundingDelayUntil, 10), q(ant.foundingAttempts, 1), ant.dead ? 1 : 0];
}
function createSnapshot(worldRef = global.world) {
if (!worldRef) throw new Error("world is not ready");
const selectedRef = refFor(worldRef, worldRef.selected);
const tarinaiLive = (worldRef.tarinai || []).filter(t => t && !t.dead);
const itemsLive = (worldRef.items || []).filter(Boolean).filter(it => it && !it.dead);
const tarinaiIndex = new Map();
const familyIndex = new Map();
tarinaiLive.forEach((t, i) => {
if (t.id) tarinaiIndex.set(t.id, i);
if (t.familyKey) familyIndex.set(t.familyKey, i);
});
const itemIndex = new Map();
itemsLive.forEach((it, i) => { if (it.id) itemIndex.set(it.id, i); });
const now = Date.now();
return {
version: SNAPSHOT_VERSION,
app: "tarinai_colony_game",
createdAt: Date.now(),
summary: {
day: worldRef.day || 1,
time: worldRef.clockString ? worldRef.clockString() : "",
fieldType: worldRef.fieldType || "garden",
population: (worldRef.tarinai || []).filter(t => t && !t.dead).length,
items: (worldRef.items || []).filter(it => it && !it.dead).length,
},
world: worldData(worldRef),
tarinai: (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)),
items: (worldRef.items || []).filter(Boolean).map(it => snapshotEntity(worldRef, it)),
ants: (worldRef.ants || []).filter(Boolean).map(a => snapshotEntity(worldRef, a)),
effects: (worldRef.effects || []).filter(Boolean).map(ef => snapshotEntity(worldRef, ef)),
logs: clonePlain((worldRef.logs || []).slice(0, 100)) || [],
selectedRef,
v: SNAPSHOT_VERSION,
a: "tcg",
c: now,
m: [worldRef.day || 1, (worldRef.tarinai || []).filter(t => t && !t.dead).length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemsLive.length, worldRef.groundType || "soil"],
w: compactWorld(worldRef),
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
i: itemsLive.map((it, i) => compactItem(it, i, tarinaiIndex)),
n: (worldRef.ants || []).filter(a => a && !a.dead).map(a => compactAnt(a, itemIndex, tarinaiIndex)),
s: null,
};
}
function restoreTarinaiData(worldRef, data, opts = {}) {
function expandTarinai(row, index, worldRef) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
const t = new TarinaiClass(worldRef, data || {});
applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"]));
t.world = worldRef;
if (opts.clearTransient !== false) {
t.target = null;
t.panicTarget = null;
t.sleeping = false;
t.insideNestBoxId = "";
}
const opts = {
familyKey: `sf${index}`,
id: `st${index}`,
liveToken: 1,
name: row[0] || undefined,
type: row[1] || "smile",
birthPersonality: row[2] || undefined,
currentPersonality: row[3] || undefined,
genetics: row[4] || undefined,
x: u(row[5], 1), y: u(row[6], 1), vx: u(row[7], 10), vy: u(row[8], 10),
scale: u(row[9], 1000, 0.28), adultScale: u(row[10], 1000, 0.28),
age: u(row[11], 10), lifeSpan: u(row[12], 10, 1600), birthTime: u(row[13], 10), generation: Math.max(1, u(row[14], 1, 1)),
hasPaired: !!row[15], hunger: u(row[16], 1), loneliness: u(row[17], 1), energy: u(row[18], 1), circadianSleepPressure: u(row[19], 1),
needs: arrayToNeeds(row[20]), state: row[21] || "idle", behavior: expandBehavior(row[33]),
};
const t = new TarinaiClass(worldRef, opts);
t.familyKey = `sf${index}`;
t.id = `st${index}`;
t.liveToken = 1;
t.needRaw = { ...t.needs };
t.needDisplay = { ...t.needs };
t.previousNeeds = { ...t.needs };
flagApply(t, Number(row[22]) || 0);
t.__savedParentIdx = Array.isArray(row[23]) ? row[23] : [];
t.__savedChildIdx = Array.isArray(row[24]) ? row[24] : [];
t.__savedRelationships = Array.isArray(row[25]) ? row[25] : [];
const timers = Array.isArray(row[26]) ? row[26] : [];
t.reproductionTimer = u(timers[0], 10); t.fightCooldown = u(timers[1], 10); t.loveMochiTimer = u(timers[2], 10); t.fightMochiTimer = u(timers[3], 10);
t.zunchiDiseaseSeverity = u(timers[4], 10); t.zunchiStain = u(timers[5], 10); t.postBirthPeaceTimer = u(timers[6], 10); t.awakeLockTimer = u(timers[7], 10);
t.itemEffectTimers = { ...(t.itemEffectTimers || {}), laxative: u(timers[8], 10), ammo: u(timers[9], 10) };
t.mercuryLifeMultiplier = u(timers[10], 100, t.mercuryLifeMultiplier || 1);
const modes = Array.isArray(row[27]) ? row[27] : [];
t.powerItemMode = modes[0] || ""; t.sizeItemMode = modes[1] || ""; t.lifeItemMode = modes[2] || "";
t.__savedPinIdx = row[28]; t.__savedNestIdx = row[29];
t.totalFightWins = u(row[30], 1); t.totalFightLosses = u(row[31], 1); t.deathReason = row[32] || "";
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
function restoreSnapshot(snapshot, worldRef = global.world) {
if (!snapshot || typeof snapshot !== "object") throw new Error("invalid save data");
if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid save data");
if (!worldRef) throw new Error("world is not ready");
const data = snapshot.version ? snapshot : JSON.parse(JSON.stringify(snapshot));
const fieldType = data.world?.fieldType || "garden";
const fieldType = snapshot.w?.[0] || "garden";
if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType);
if (typeof worldRef.reset === "function") worldRef.reset(0, fieldType);
@ -153,92 +430,92 @@
worldRef.items = [];
worldRef.ants = [];
worldRef.effects = [];
worldRef.logs = Array.isArray(data.logs) ? clonePlain(data.logs) || [] : [];
worldRef.logs = [];
worldRef.selected = null;
applyOwnData(worldRef, data.world || {}, new Set(["tarinai", "items", "ants", "effects", "logs", "selected", "liveTarinai", "events", "spatial"]));
worldRef.events = global.TarinaiEvents || null;
worldRef.liveTarinai = new Map();
worldRef.liveIdFree = [];
applyCompactWorld(worldRef, snapshot.w || []);
const items = Array.isArray(data.items) ? data.items : [];
for (const saved of items) {
if (!saved || !saved.type) continue;
const item = new Item(saved.type, Number(saved.x) || 0, Number(saved.y) || 0);
applyOwnData(item, saved, new Set(["target", "targetRef", "world"]));
const tarRows = Array.isArray(snapshot.t) ? snapshot.t : [];
for (let idx = 0; idx < tarRows.length; idx++) {
const t = expandTarinai(tarRows[idx] || [], idx, worldRef);
worldRef.tarinai.push(t);
if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 1, target: t });
}
for (const [idx, t] of worldRef.tarinai.entries()) {
t.parents = (t.__savedParentIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean);
t.children = (t.__savedChildIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean);
delete t.__savedParentIdx; delete t.__savedChildIdx;
}
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
for (let idx = 0; idx < itemRows.length; idx++) {
const row = itemRows[idx] || [];
const type = row[0] || "";
if (!type) continue;
let item = null;
if (Array.isArray(row[8]) && row[8][0] === "S" && global.StructureRegistry?.get?.(type)) {
const owner = worldRef.tarinai[row[8][5]] || null;
item = global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef);
} else {
item = new Item(type, u(row[1], 1), u(row[2], 1));
}
item.id = `si${idx}`;
item.world = worldRef;
item.r = u(row[3], 10, item.r || 12);
item.amount = u(row[4], 10, item.amount || 0);
item.age = u(row[5], 10, 0);
item.seed = u(row[6], 10, item.seed || 0);
item.dead = !!row[7];
applyItemExtra(item, row[8], worldRef.tarinai);
worldRef.items.push(item);
}
const tarinaiList = Array.isArray(data.tarinai) ? data.tarinai : [];
for (const saved of tarinaiList) {
if (!saved) continue;
const t = restoreTarinaiData(worldRef, saved, { clearTransient: false });
worldRef.tarinai.push(t);
if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 0, target: t });
for (const t of worldRef.tarinai) {
if (Number.isInteger(t.__savedPinIdx) && t.__savedPinIdx >= 0) t.stuckPushpinId = worldRef.items[t.__savedPinIdx]?.id || null;
if (Number.isInteger(t.__savedNestIdx) && t.__savedNestIdx >= 0) t.insideNestBoxId = worldRef.items[t.__savedNestIdx]?.id || null;
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships;
}
const ants = Array.isArray(data.ants) ? data.ants : [];
for (const saved of ants) {
if (!saved) continue;
const ant = new AntActor(worldRef, saved);
applyOwnData(ant, saved, new Set(["world", "targetRef"]));
ant.world = worldRef;
const antRows = Array.isArray(snapshot.n) ? snapshot.n : [];
for (let idx = 0; idx < antRows.length; idx++) {
const row = antRows[idx] || [];
const ant = new AntActor(worldRef, {
id: `sa${idx}`,
kind: row[0] || "worker",
homeId: worldRef.items[row[1]]?.id || "",
targetId: worldRef.tarinai[row[2]]?.id || "",
targetToken: worldRef.tarinai[row[2]]?.liveToken || 0,
x: u(row[3], 1), y: u(row[4], 1), vx: u(row[5], 10), vy: u(row[6], 10), hp: u(row[7], 10), maxHp: u(row[8], 10), state: row[9] || "search", age: u(row[10], 10), seed: u(row[11], 10), returnTimer: u(row[12], 10), foundingX: u(row[13], 1), foundingY: u(row[14], 1), foundingDelayUntil: u(row[15], 10), foundingAttempts: u(row[16], 1), dead: !!row[17],
});
ant.targetRef = ant.targetId ? worldRef.liveTarinaiById?.(ant.targetId, ant.targetToken) || null : null;
worldRef.ants.push(ant);
}
const effects = Array.isArray(data.effects) ? data.effects : [];
for (const saved of effects) {
if (!saved || !saved.type) continue;
const ef = new Effect(saved.type, Number(saved.x) || 0, Number(saved.y) || 0, saved);
applyOwnData(ef, saved, new Set(["world", "target", "targetRef"]));
worldRef.effects.push(ef);
}
const itemById = new Map(worldRef.items.map(it => [it.id, it]));
const antById = new Map(worldRef.ants.map(a => [a.id, a]));
const tarinaiById = new Map(worldRef.tarinai.map(t => [t.id, t]));
const resolve = (ref) => {
if (!ref) return null;
if (ref.kind === "item") return itemById.get(ref.id) || null;
if (ref.kind === "ant") return antById.get(ref.id) || null;
if (ref.kind === "tarinai") return tarinaiById.get(ref.id) || worldRef.tarinai.find(t => t.familyKey === ref.familyKey) || null;
return resolveRef(worldRef, ref);
};
for (const [i, saved] of tarinaiList.entries()) {
const t = worldRef.tarinai[i];
if (!t) continue;
t.target = resolve(saved.__targetRef);
t.panicTarget = resolve(saved.__panicTargetRef);
}
for (const [i, saved] of ants.entries()) {
const ant = worldRef.ants[i];
if (!ant) continue;
ant.targetRef = resolve(saved.__targetRefObject);
}
worldRef.selected = resolve(data.selectedRef);
worldRef.family = clonePlain(data.world?.family) || worldRef.family || {};
worldRef.relationNotices = clonePlain(data.world?.relationNotices) || {};
worldRef.resolvedFightIds = clonePlain(data.world?.resolvedFightIds) || {};
worldRef.eventCounters = clonePlain(data.world?.eventCounters) || {};
worldRef.colonyMood = clonePlain(data.world?.colonyMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "のんびり", effects: { personality: {} } };
worldRef.family = {};
worldRef.familyVersion = (worldRef.familyVersion || 0) + 1;
for (const t of worldRef.tarinai) worldRef.recordFamily?.(t);
worldRef.familyCleanVersion = null;
worldRef.familyTreeDirty = true;
worldRef.drawListDirty = true;
worldRef.terrainDirty = true;
worldRef.relationNotices = {};
worldRef.resolvedFightIds = {};
worldRef.eventCounters = {};
if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts();
if (typeof worldRef.enforceGrassLimit === "function") {
worldRef.enforceGrassLimit("load-grass-limit");
worldRef.compactItems?.();
worldRef.updateItemCounts?.();
}
if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts();
if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true);
if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load");
if (typeof worldRef.clampCamera === "function") worldRef.clampCamera();
if (typeof renderLog === "function") renderLog(worldRef.logs || []);
if (typeof resetArchiveRenderState === "function") resetArchiveRenderState();
if (typeof renderArchive === "function") renderArchive();
if (typeof renderSelected === "function") renderSelected();
if (typeof renderStats === "function") renderStats();
if (typeof render === "function") render();
if (typeof syncTopButtons === "function") syncTopButtons();
global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef);
return true;
worldRef.emit?.("snapshot:restored", { snapshotVersion: SNAPSHOT_VERSION });
return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION };
}
global.TarinaiSnapshot = {
@ -249,9 +526,7 @@
refFor,
resolveRef,
worldData,
snapshotEntity,
createSnapshot,
restoreSnapshot,
restoreTarinaiData,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -3,7 +3,7 @@
(function (global) {
// External audio replacement point. Add files and edit paths here; audio.js consumes this pack at startup.
global.TARINAI_SOUND_PACK = Object.freeze({
version: "1.1",
version: "1.2",
categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 },
idGain: {
sfx_notify: 2.60,
@ -30,6 +30,7 @@
sfx_ant_die: 0.14,
sfx_notify: 0.10,
sfx_log_push: 0.10,
voice_sunbath: 0.95,
},
samplePaths: {
voice_hau: "assets/sounds/voice_001_hau.wav",
@ -38,6 +39,7 @@
sfx_eat: "assets/sounds/voice_004_eat.wav",
voice_stress: "assets/sounds/voice_005_stress.wav",
sfx_sleep: "assets/sounds/voice_006_sleep.wav",
voice_sunbath: "assets/sounds/voice_007_sunbath_pokapoka.wav",
sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3"
},
categories: {
@ -46,7 +48,7 @@
ops: "\u64cd\u4f5c"
},
soundMap: {
voice_hau: "voice", voice_flee: "voice", voice_poop: "voice", voice_stress: "voice",
voice_hau: "voice", voice_flee: "voice", voice_poop: "voice", voice_stress: "voice", voice_sunbath: "voice",
sfx_eat: "voice", sfx_sleep: "voice", sfx_wake: "voice", sfx_birth: "voice", sfx_death: "voice",
sfx_damage_soft: "voice", sfx_damage_heavy: "voice", sfx_fight_hit: "voice", sfx_fight_finish: "voice",
sfx_disease: "voice", sfx_heal: "voice", sfx_ant_drag: "voice", sfx_ant_die: "voice", sfx_queen_ant: "voice",

90
js/structure_lifecycle.js Normal file
View file

@ -0,0 +1,90 @@
"use strict";
(function (global) {
function markWorldAfterStructureChange(worldRef, reason = "structure-change") {
if (!worldRef) return;
worldRef.drawListDirty = true;
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.markTerrainDirty?.(reason);
}
function markStructureGone(structure, reason = "structure-removed") {
if (!structure || structure.dead) return false;
structure.dead = true;
structure.hp = 0;
structure.amount = 0;
structure.removedReason = String(reason || "structure-removed");
return true;
}
function releaseStructureUsers(worldRef, structure, opts = {}) {
if (!worldRef || !structure) return 0;
const reason = opts.reason || "なくなった";
const wake = opts.wake !== false;
let released = 0;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead) continue;
const targetMatch = t.target === structure;
const sleepTargetMatch = t.sleepSession?.targetId && t.sleepSession.targetId === structure.id;
const nestMatch = structure.type === "nest_box" && t.insideNestBoxId === structure.id;
if (!targetMatch && !sleepTargetMatch && !nestMatch) continue;
if (nestMatch) {
t.insideNestBoxId = null;
t.nestBoxSleepTimer = 0;
}
if (sleepTargetMatch && t.sleepSession) {
t.sleepSession.targetId = null;
t.sleepSession.consumesGrassBedOnWake = false;
}
if (targetMatch) t.target = null;
if (t.state === "sleep" || t.sleeping || t.state === "seek_bed" || nestMatch) {
t.setActionState?.("idle", { clearTarget: true, reason, wake, sleeping: false });
}
released += 1;
}
return released;
}
function deleteStructure(worldRef, structure, opts = {}) {
if (!structure || structure.dead) return false;
const reason = opts.reason || "structure-deleted";
releaseStructureUsers(worldRef, structure, { reason: opts.userReason || "置きものがなくなった", wake: opts.wake !== false });
const changed = markStructureGone(structure, reason);
if (!changed) return false;
markWorldAfterStructureChange(worldRef, reason);
worldRef?.emit?.("structure:deleted", { structure, type: structure.type, reason });
return true;
}
function consumeGrassBedOnWake(worldRef, bed, user = null, reason = "wake") {
if (!bed || bed.dead || bed.type !== "grass_bed") return false;
bed.singleUsePending = false;
bed.singleUseWakeReason = String(reason || "wake");
bed.singleUseConsumedById = user?.id || bed.singleUseConsumedById || null;
if (user?.target === bed) user.target = null;
const changed = markStructureGone(bed, "grass-bed-wake-used");
if (!changed) return false;
markWorldAfterStructureChange(worldRef, "grass-bed-wake-used");
worldRef?.emit?.("structure:usedUp", { structure: bed, user, reason: "wake" });
return true;
}
function deleteItem(worldRef, item, opts = {}) {
if (!item || item.dead) return false;
if (item.isStructure) return deleteStructure(worldRef, item, { reason: opts.reason || "delete-tool", userReason: opts.userReason, wake: opts.wake });
item.amount = 0;
if (item.type !== "grass") item.dead = Boolean(opts.killNonStructure);
markWorldAfterStructureChange(worldRef, opts.reason || "delete-tool");
return true;
}
global.TarinaiStructureLifecycle = Object.freeze({
markWorldAfterStructureChange,
releaseStructureUsers,
deleteStructure,
consumeGrassBedOnWake,
deleteItem,
});
})(window);

View file

@ -22,13 +22,17 @@
this.usedCount = 0;
this.x = x;
this.y = y;
this.r = definition.type === "plushie" ? 13 : (definition.type === "grass_bed" ? 18 : 24);
this.r = definition.type === "plushie" ? 6.5 : (definition.type === "grass_bed" ? 18 : 24);
this.amount = this.hp;
this.seed = Math.random() * 1000;
this.roles = clonePlain(definition.roles);
this.needEffects = clonePlain(definition.needEffects);
this.carriedById = definition.type === "plushie" ? this.ownerId : "";
this.onHead = definition.type === "plushie";
if (definition.type === "plushie") {
const sprites = typeof spawnableSprites === "function" ? spawnableSprites().map(s => s.id).filter(Boolean) : [];
this.plushieSpriteId = sprites.length ? sprites[Math.floor(Math.random() * sprites.length)] : (owner?.type || "smile");
}
this.dead = false;
this.world = world || null;
}
@ -50,15 +54,27 @@
return;
}
this.x = owner.x;
this.y = owner.y - Math.max(18, (owner.radius || 24) * 0.78);
this.y = owner.y - Math.max(15, (owner.radius || 24) * 0.70);
if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8);
}
if (this.type === "grass_bed") {
const owner = this.owner(worldRef);
if (!owner) {
this.unownedDecayTimer = (this.unownedDecayTimer || 0) + dt;
const rainBoost = worldRef?.weather === "light_rain" ? 1.65 : 1;
const ageBoost = 1 + Math.min(1.8, this.unownedDecayTimer / 360);
this.damage(dt * 0.070 * rainBoost * ageBoost, worldRef, null);
if (this.dead) return;
} else {
this.unownedDecayTimer = 0;
}
}
this.amount = Math.max(0.001, this.hp);
}
use(user, worldRef = this.world) {
use(user, worldRef = this.world, options = {}) {
const definition = global.StructureRegistry?.get?.(this.type);
return definition?.onUse?.(user, this, worldRef) || false;
return definition?.onUse?.(user, this, worldRef, options) || false;
}
damage(amount = 1, worldRef = this.world, breaker = null) {
@ -78,7 +94,7 @@
ctx.translate(this.x, this.y);
const night = (lighting?.light || 1) < 0.42;
if (this.type === "grass_bed") {
// かんたんベッド: 緑色の小さい干草寝床。通常の干草寝床より一回り小さく、草束感を強める。
// \u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9: \u7dd1\u8272\u306e\u5c0f\u3055\u3044\u5e72\u8349\u5bdd\u5e8a\u3002\u901a\u5e38\u306e\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u56de\u308a\u5c0f\u3055\u304f\u3001\u8349\u675f\u611f\u3092\u5f37\u3081\u308b\u3002
const grassFill = night ? "rgba(78,132,67,0.90)" : "rgba(84,177,72,0.93)";
const grassStroke = night ? "rgba(39,78,43,0.76)" : "rgba(45,112,48,0.78)";
const strawStroke = night ? "rgba(168,207,119,0.42)" : "rgba(205,234,128,0.66)";
@ -106,13 +122,14 @@
ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "plushie") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : global.images?.get?.("smile");
const spriteId = this.plushieSpriteId || "smile";
const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, "smile") : (global.images?.get?.(spriteId) || global.images?.get?.("smile"));
ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05);
if (img) {
const w = this.r * 2.2;
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("smile") : null;
const h = w * (metrics?.ratio || 0.92);
ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h);
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(spriteId) || getImageMetrics("smile") : null;
const w = this.r * 3.0;
const h = w * (metrics?.ratio || 0.74);
ctx.drawImage(img, -w * 0.58, -h * 0.60, w, h);
} else {
ctx.fillStyle = "#f7dfc0";
ctx.strokeStyle = "rgba(86,62,48,0.70)";
@ -174,19 +191,33 @@
buildNeed: "fulfill",
buildTime: 4,
maxHp: 100,
materials: { grassMaterial: 1 },
materials: { grassMaterial: 4 },
roles: { sleepPlace: true, ownedStructure: true },
needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 },
create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); },
onUse(user, structure) {
onUse(user, structure, worldRef, options = {}) {
if (user?.id && !structure.ownerId) {
structure.ownerId = user.id;
structure.ownerName = user.name || "";
structure.attachment = Math.max(structure.attachment || 0, 24);
worldRef?.markItemBucketsDirty?.("grass-bed-owner-adopted");
worldRef?.markTerrainDirty?.("grass-bed-owner-adopted");
worldRef?.emit?.("structure:ownerAdopted", { structure, owner: user });
}
const ownerUse = user?.id && user.id === structure.ownerId;
if (ownerUse) structure.attachment = Math.min(100, (structure.attachment || 0) + 2);
structure.usedCount = (structure.usedCount || 0) + 1;
if (user) {
user.needShock = user.needShock || {};
user.needShock.sleep = Math.min(0, (user.needShock.sleep || 0) - (ownerUse ? 18 : 8));
user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - (ownerUse ? 12 : 4));
user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - (ownerUse ? 18 : 6));
if (options?.purpose === "sleep" || options?.sleep === true) {
// かんたんベッドは「寝た瞬間」ではなく「起きた時」に消える。
// 睡眠中は寝場所として残すことで、見た目とホバー情報が自然になる。
structure.singleUsePending = true;
structure.singleUseConsumedById = user?.id || "";
structure.singleUseConsumedAt = worldRef?.time || 0;
structure.singleUseWakeReason = "sleep";
structure.amount = Math.max(0.001, structure.hp || structure.amount || 1);
worldRef?.markItemBucketsDirty?.("grass-bed-sleep-reserved");
worldRef?.markTerrainDirty?.("grass-bed-sleep-reserved");
worldRef?.emit?.("structure:useReserved", { structure, user });
}
return true;
},

View file

@ -16,18 +16,17 @@ function tarinaiRoundRectPath(ctx, x, y, w, h, r) {
class Tarinai { constructor(world, opts = {}) {
this.world = world;
this.familyKey = opts.familyKey || opts.archiveKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
this.familyKey = opts.familyKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
this.id = opts.id || "";
this.liveToken = opts.liveToken || 0;
this.name = opts.name || makeName(world);
// Legacy string personality is intentionally ignored. The game now uses only the four-axis personality model.
this.personality = "";
this.type = opts.type || baseSpriteId();
const typeMeta = SPRITES.find(s => s.id === this.type);
if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId();
this.birthPersonality = normalizePersonality(opts.birthPersonality || opts.personalityBirth);
if (!opts.birthPersonality && !opts.personalityBirth) this.birthPersonality = randomBirthPersonality(this.familyKey);
this.currentPersonality = normalizePersonality(opts.currentPersonality || opts.personalityCurrent || this.birthPersonality);
this.birthPersonality = normalizePersonality(opts.birthPersonality);
if (!opts.birthPersonality) this.birthPersonality = randomBirthPersonality(this.familyKey);
this.currentPersonality = normalizePersonality(opts.currentPersonality || this.birthPersonality);
this.personalityDaily = opts.personalityDaily && typeof opts.personalityDaily === "object" ? JSON.parse(JSON.stringify(opts.personalityDaily)) : null;
ensurePersonality(this);
this.genetics = normalizeGenetics(opts.genetics, this.familyKey);
@ -38,6 +37,11 @@ class Tarinai { constructor(world, opts = {}) {
const growthAtConstruct = generationOpt > 1 ? clamp(((this.world?.time || 0) - this.birthTime) / Math.max(1, CONFIG.childGrowTime || 120), 0, 1) : 1;
this.x = opts.x ?? rand(100, world.w - 100);
this.y = opts.y ?? rand(100, world.h - 100);
this._lastValidX = Number.isFinite(this.x) ? this.x : null;
this._lastValidY = Number.isFinite(this.y) ? this.y : null;
this._lastSunbathDrawX = Number.isFinite(opts._lastSunbathDrawX) ? opts._lastSunbathDrawX : null;
this._lastSunbathDrawY = Number.isFinite(opts._lastSunbathDrawY) ? opts._lastSunbathDrawY : null;
this._activeSunbathSpriteId = typeof opts._activeSunbathSpriteId === "string" ? opts._activeSunbathSpriteId : "";
this.vx = opts.vx ?? rand(-12, 12);
this.vy = opts.vy ?? rand(-12, 12);
this.impulseVx = opts.impulseVx ?? 0;
@ -67,11 +71,13 @@ class Tarinai { constructor(world, opts = {}) {
this.lastSatisfactionSource = opts.lastSatisfactionSource || "";
this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needRaw || this.needs) : 0;
this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...defaultNeeds, ...opts.previousNeeds } : { ...this.needs };
this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : null;
this.currentAction = opts.currentAction && typeof opts.currentAction === "object" ? { ...opts.currentAction } : null;
this.activeBehavior = opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null;
this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : [];
this.need = "";
this.state = opts.state || "idle";
this.behaviorSerial = opts.behaviorSerial ?? 0;
this.behavior = typeof globalThis.normalizeTarinaiBehaviorState === "function"
? globalThis.normalizeTarinaiBehaviorState(opts, this)
: (opts.behavior && typeof opts.behavior === "object" ? { ...opts.behavior } : null);
this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : [];
this.actionCooldowns = opts.actionCooldowns && typeof opts.actionCooldowns === "object" ? { ...opts.actionCooldowns } : {};
this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {};
this.buildPlan = opts.buildPlan && typeof opts.buildPlan === "object" ? { ...opts.buildPlan } : null;
@ -79,9 +85,8 @@ class Tarinai { constructor(world, opts = {}) {
this.hpBarTimer = opts.hpBarTimer ?? 0;
this.stressBarTimer = opts.stressBarTimer ?? 0;
this.affection = opts.affection ?? rand(0, 30);
this.need = "";
this.state = opts.state || "idle";
this.dead = !!opts.dead;
this.lifeStatus = opts.lifeStatus || (this.dead ? "dead" : "alive");
this.deathReason = opts.deathReason || "";
this.lastDamageCause = opts.lastDamageCause || "";
this.lastDamageAmount = opts.lastDamageAmount ?? 0;
@ -123,6 +128,8 @@ class Tarinai { constructor(world, opts = {}) {
this.sunbathCooldown = opts.sunbathCooldown ?? 0;
this.sunbathFrameTimer = opts.sunbathFrameTimer ?? 0;
this.sunbathSpriteVariant = opts.sunbathSpriteVariant ?? 1;
this.sunbathAnchorX = Number.isFinite(opts.sunbathAnchorX) ? opts.sunbathAnchorX : null;
this.sunbathAnchorY = Number.isFinite(opts.sunbathAnchorY) ? opts.sunbathAnchorY : null;
this.intimidateTimer = opts.intimidateTimer ?? 0;
this.intimidateTargetId = opts.intimidateTargetId || null;
this.intimidatedTimer = opts.intimidatedTimer ?? 0;
@ -174,7 +181,10 @@ class Tarinai { constructor(world, opts = {}) {
this.fightTargetId = opts.fightTargetId || null;
this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []);
this.nextHeadbutt = opts.nextHeadbutt ?? 0;
this.digest = opts.digest ?? rand(0, 2.4);
const savedDigest = Number(opts.digest);
this.digest = Number.isFinite(savedDigest)
? (savedDigest > 3 ? Math.min(2.99, savedDigest / 12.4 * 3) : Math.max(0, savedDigest))
: 0;
this.poopCount = opts.poopCount ?? 0;
this.oshiriByoZunchiStock = opts.oshiriByoZunchiStock ?? 0;
this.stretchTimer = opts.stretchTimer ?? 0;

View file

@ -0,0 +1,558 @@
"use strict";
// ActionSpec factories and TARINAI_ACTIONS registry.
function selectRoleTarget(role, range) {
return function selectTarget(tarinai, world) {
return findNearestItemWithRole(world, tarinai, role, range);
};
}
function startMoveToTarget(state, fallbackLabel = "行動している") {
return function start(tarinai, world, ctx = {}) {
const target = ctx.target ?? this.selectTarget?.(tarinai, world, ctx) ?? null;
return moveToOrUse(tarinai, target, state, this.label || fallbackLabel);
};
}
function selectOwnedStructure(type, range = Infinity) {
return function selectTarget(tarinai, world) {
return findOwnedStructure(world, tarinai, type, range);
};
}
function selectMateTarget(tarinai, world, range = 440) {
return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null;
}
function basicCanStartWithTarget(t, world, ctx = {}) {
return !!(ctx.target ?? this.selectTarget?.(t, world, ctx));
}
function createConsumableActionSpec({ id, subNeed = "", state, label, phrase, weight, role, range }) {
return {
id,
need: role === "medicine" ? "health" : "food",
subNeed,
state,
label,
phrase,
weight,
timerBacked: true,
selectTarget: selectRoleTarget(role, range),
canStart: basicCanStartWithTarget,
start: startMoveToTarget(state, label),
tick(t, world, dt) { return updateConsumableBehavior(t, world, dt, role); },
finish(t) {
if (role === "drink" && typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: 8, health: 2 }, "drink");
return true;
},
fail(t) {
if (t) t.target = null;
return false;
},
};
}
function createSleepInBedActionSpec() {
return {
id: "sleep_in_bed",
need: "sleep",
state: "seek_bed",
label: "寝る場所を探している",
phrase: "寝る場所へ戻った",
weight: 42,
timerBacked: true,
selectTarget(t, world) { return findNearestSleepPlace(world, t, 760); },
canStart: basicCanStartWithTarget,
start(t, world, ctx = {}) {
const bed = ctx.target ?? this.selectTarget(t, world, ctx);
if (!bed) return false;
if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label);
const isEasyBed = bed.type === "grass_bed";
bed.use?.(t, world, isEasyBed ? { purpose: "sleep" } : undefined);
t.startSleeping?.(bed, bed.type === "nest_box" ? "巣箱の中で寝ている" : (isEasyBed ? "かんたんベッドで寝ている" : "ベッドで寝ている"));
return true;
},
tick(t) {
if (t?.state === "sleep" || t?.sleeping) return true;
return basicCanStartWithTarget.call(this, t, t?.world || null, {}) ? true : false;
},
};
}
function createSleepBuildGrassBedActionSpec() {
return {
id: "sleep_build_grass_bed",
need: "sleep",
subNeed: "bed",
state: "build",
label: "寝る場所がないのでかんたんベッドを作っている",
phrase: "かんたんベッドを作った",
weight: 18,
timerBacked: true,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) {
return canBuildOwnedStructureByAge(t)
&& !findNearestSleepPlace(world, t, 760)
&& !findOwnedStructure(world, t, "grass_bed")
&& !!(ctx.target ?? this.selectTarget(t, world, ctx))
&& !["fight", "panic", "intimidate", "birth_ritual"].includes(t.state);
},
start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); },
tick(t, world, dt) {
if (findOwnedStructure(world, t, "grass_bed")) return "finished";
return continueBuildPlan(t, world, dt) ? true : false;
},
};
}
function createSleepAnywhereActionSpec() {
return {
id: "sleep_anywhere",
need: "sleep",
state: "sleep",
label: "なにもない所で寝ている",
phrase: "そのまま寝た",
weight: 8,
timerBacked: true,
canStart(t, world) {
if (findNearestSleepPlace(world, t, 760)) {
if (t) t.noBedNoBuildSince = NaN;
return false;
}
const now = world?.time || 0;
const failedRecently = (t?.lastFailedGrassBedBuildAt || -999) + 14 > now;
const buildTarget = this.selectBuildTarget ? this.selectBuildTarget(t, world, {}) : findNearbyMaterial(world, t, "grassMaterial", 520);
const canTryBuild = canBuildOwnedStructureByAge(t) && !!buildTarget && !(Number(t?.actionIdCooldowns?.sleep_build_grass_bed || 0) > 0.01);
if (canTryBuild && !failedRecently) {
if (t) t.noBedNoBuildSince = NaN;
return false;
}
if (t && !Number.isFinite(t.noBedNoBuildSince)) t.noBedNoBuildSince = now;
const since = Number.isFinite(t?.noBedNoBuildSince) ? t.noBedNoBuildSince : now;
const waited = now - since;
const urgent = (t?.energy || 100) < 18 || (t?.needRaw?.sleep || t?.needs?.sleep || 0) >= 92;
return failedRecently || urgent || waited >= 6.0;
},
selectBuildTarget: selectRoleTarget("grassMaterial", 520),
start(t) { t.startSleeping?.(null, this.label); return true; },
tick(t) { return (t?.state === "sleep" || t?.sleeping) ? true : false; },
};
}
function createRestActionSpec() {
return {
id: "rest_to_recover",
need: "health",
state: "idle",
label: "元気になるまで休んでいる",
phrase: "休んだ",
weight: 24,
canStart() { return true; },
start(t) {
t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false });
t.energy = clamp((t.energy || 0) + 1.6, 0, 100);
return true;
},
tick(t) {
t.energy = clamp((t.energy || 0) + 0.8, 0, 100);
return true;
},
};
}
function createSunbathActionSpec() {
return {
id: "sunbath",
need: "health",
subNeed: "sunbath",
state: "sunbath",
label: "日光浴している",
phrase: "日光浴している",
weight: 32,
timerBacked: true,
canStart(t, world, ctx = {}) {
const healthNeed = Number(t?.needRaw?.health ?? t?.needs?.health ?? 0) || 0;
return (ctx.leisure || healthNeed >= needThreshold("health", "start"))
&& !!t?.canStartSunbath?.({ leisure: !!ctx.leisure });
},
start(t, world, ctx = {}) {
return !!t?.startSunbath?.({ leisure: !!ctx.leisure });
},
tick(t) {
return (t?.sunbathTimer || 0) > 0.04 ? true : "finished";
},
finish(t) {
if ((t?.sunbathTimer || 0) <= 0 && t?.state === "sunbath") t.finishSunbath?.();
return true;
},
fail(t) {
if (t && t.state === "sunbath") {
t.sunbathTimer = 0;
t.goIdle?.("日光浴をやめた");
}
return false;
},
};
}
function createWanderActionSpec() {
function targetFor(t, world) {
const radius = rand(80, 180);
return {
x: clamp(t.x + Math.cos(t.wanderAngle) * radius, t.radius || 20, (world?.w || 1600) - (t.radius || 20)),
y: clamp(t.y + Math.sin(t.wanderAngle) * radius, t.radius || 20, (world?.h || 900) - (t.radius || 20)),
dead: false,
detour: true,
};
}
return {
id: "wander_lightly",
need: "fulfill",
state: "wander",
label: "少し歩いている",
phrase: "少し歩いた",
weight: 14,
canStart() { return true; },
selectTarget: targetFor,
start(t, world, ctx = {}) {
t.wanderAngle += rand(-1.2, 1.2);
const target = ctx.target && Number.isFinite(ctx.target.x) ? ctx.target : targetFor(t, world);
t.wanderTarget = target;
t.setActionState?.("wander", { target, reason: this.label, sleeping: false });
return true;
},
tick(t, world) {
const target = t?.wanderTarget || currentBehaviorTarget(t) || null;
if (!target || !Number.isFinite(target.x) || dist(t, target) <= Math.max(28, (t.radius || 20) + 10)) return "finished";
moveToOrUse(t, target, "wander", this.label);
return true;
},
};
}
function selectFightRivalTarget(tarinai, world, range = 360) {
const candidate = conflictTargetFor(tarinai, world, (tarinai?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(tarinai) ? 18 : 34);
const current = currentBehaviorTarget(tarinai);
return isLiveTarinaiEntity(candidate?.target)
? candidate.target
: (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(tarinai, world, isCurrentBehaviorForced(tarinai) ? 760 : range));
}
function createPanicEscapeActionSpec() {
return {
id: "panic_escape",
need: "safety",
subNeed: "panic",
state: "panic",
label: "怖くて逃げている",
phrase: "逃げた",
weight: 62,
timerBacked: true,
selectTarget(t, world) {
return t.lastNeedShockBreaker || findNearbyDanger(world, t, 240) || ((t.defeatedById && world?.tarinai) ? world.liveTarinaiById?.(t.defeatedById) : null);
},
canStart(t, world, ctx = {}) {
const threat = ctx.target ?? this.selectTarget(t, world, ctx);
const safety = Number(t?.needRaw?.safety ?? t?.needs?.safety ?? 0) || 0;
// Do not start panic from ordinary safety pressure alone. Panic needs a
// concrete trigger, lingering fear, defeat, or an extreme safety spike.
return !!threat
|| (t?.defeatedTimer || 0) > 0.04
|| (safety >= 92 && !!activePanicBreaker(t, world, 260));
},
start(t, world, ctx = {}) {
const threat = ctx.target ?? this.selectTarget(t, world, ctx);
t.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false });
t.fearTimer = Math.max(t.fearTimer || 0, 1.6);
return true;
},
tick: updatePanicBehavior,
};
}
function createFleeActionSpec() {
return {
id: "flee",
need: "safety",
subNeed: "avoid",
state: "flee",
label: "危ないものから離れている",
phrase: "離れた",
weight: 40,
selectTarget(t, world) { return findNearbyDanger(world, t, 220); },
canStart: basicCanStartWithTarget,
start(t, world, ctx = {}) {
const danger = ctx.target ?? this.selectTarget(t, world, ctx);
t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false });
return true;
},
tick: updatePanicBehavior,
};
}
function createApproachFriendActionSpec() {
return {
id: "approach_friend",
need: "social",
subNeed: "bond",
state: "seek_friend",
label: "仲間に近づいている",
phrase: "仲間に近づいた",
weight: 46,
selectTarget(t, world) { return t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420) || null; },
canStart: basicCanStartWithTarget,
start: startMoveToTarget("seek_friend", "仲間に近づいている"),
tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "bond"); },
};
}
function createApproachFamilyActionSpec() {
return {
id: "approach_parent_or_child",
need: "social",
subNeed: "family",
state: "follow_parent",
label: "家族に近づいている",
phrase: "家族に近づいた",
weight: 28,
selectTarget(t) { return t.parentToFollow?.() || null; },
canStart: basicCanStartWithTarget,
start: startMoveToTarget("follow_parent", "家族に近づいている"),
tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "family"); },
};
}
function createApproachMateActionSpec() {
return {
id: "approach_mate",
need: "social",
subNeed: "mate",
state: "seek_friend",
label: "繁殖できる相手に近づいている",
phrase: "繁殖できる相手に近づいている",
weight: 48,
selectTarget(t, world) { return selectMateTarget(t, world, 520); },
canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start(t, world, ctx = {}) {
const other = ctx.target ?? this.selectTarget(t, world, ctx);
if (!other) return false;
if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true;
return moveToOrUse(t, other, "seek_friend", this.label);
},
tick: updateMateBehavior,
};
}
function createFightRivalActionSpec() {
return {
id: "fight_rival",
need: "social",
subNeed: "conflict",
state: "seek_enemy",
label: "気に入らない相手に向かっている",
phrase: "喧嘩を始めた",
weight: 18,
timerBacked: true,
selectTarget(t, world) { return selectFightRivalTarget(t, world); },
canStart(t, world, ctx = {}) {
if ((t.fightCooldown || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false;
const forced = (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t);
const candidate = conflictTargetFor(t, world, forced ? 24 : 42);
if (!forced) {
const parts = t?.socialReasonParts || {};
const conflict = Number(parts.conflict || 0) || 0;
const socialPull = Math.max(Number(parts.bond || 0) || 0, Number(parts.mate || 0) || 0, Number(parts.family || 0) || 0);
if (!candidate?.target || conflict < 30 || conflict < socialPull * 0.82) return false;
}
return !!(ctx.target ?? candidate?.target ?? this.selectTarget(t, world, ctx));
},
start(t, world, ctx = {}) {
const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t) ? 18 : 34);
const rival = ctx.target ?? this.selectTarget(t, world, ctx);
if (!isLiveTarinaiEntity(rival)) return false;
t.conflictTargetId = rival.id;
t.conflictUrge = Math.max(t.conflictUrge || 0, candidate?.score || 48);
if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", this.label);
if (candidate?.forced || isCurrentBehaviorForced(t) || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival);
world.startConflict?.(t, rival);
return true;
},
tick: updateFightBehavior,
};
}
function createIntimidateEnemyActionSpec() {
return {
id: "intimidate_enemy",
need: "social",
subNeed: "conflict",
state: "intimidate",
label: "気になる相手を威嚇している",
phrase: "威嚇した",
weight: 8,
timerBacked: true,
selectTarget(t) { return t.recentFightRival?.(80, 180) || null; },
canStart(t, world, ctx = {}) {
if ((t.intimidateTimer || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false;
const parts = t?.socialReasonParts || {};
if ((t.fightMochiTimer || 0) <= 0.04 && Number(parts.conflict || 0) < 24) return false;
return basicCanStartWithTarget.call(this, t, world, ctx);
},
start(t, world, ctx = {}) {
const rival = ctx.target ?? this.selectTarget(t, world, ctx);
t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false });
t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85);
return true;
},
tick(t, world) {
if ((t.intimidateTimer || 0) > 0.04) return true;
const rival = currentBehaviorTarget(t) || t.recentFightRival?.(80, 180);
if (!rival) return false;
return world?.startIntimidation?.(t, rival) || true;
},
};
}
function createBirthRitualActionSpec() {
return {
id: "birth_ritual",
need: "social",
subNeed: "mate",
label: "繁殖の前ぶれをしている",
phrase: "繁殖の前ぶれをしている",
weight: 0,
state: "birth_ritual",
timerBacked: true,
canStart(t) { return (t.birthRitualTimer || 0) > 0.04; },
start() { return true; },
tick(t) { return (t.birthRitualTimer || 0) > 0.04 ? true : "finished"; },
};
}
const TARINAI_ACTION_DEFINITIONS = [
createConsumableActionSpec({
id: "eat_food",
subNeed: "meal",
state: "seek_food",
label: "食べ物を探している",
phrase: "食べ物を食べている",
weight: 62,
role: "food",
range: 760,
}),
createConsumableActionSpec({
id: "drink_water",
subNeed: "water",
state: "seek_water",
label: "水を探している",
phrase: "水を飲んだ",
weight: 24,
role: "drink",
range: 560,
}),
createSleepInBedActionSpec(),
createSleepBuildGrassBedActionSpec(),
createSleepAnywhereActionSpec(),
createConsumableActionSpec({
id: "use_medicine",
subNeed: "medicine",
state: "seek_food",
label: "元気になるものを探している",
phrase: "元気になった",
weight: 34,
role: "medicine",
range: 620,
}),
createRestActionSpec(),
createSunbathActionSpec(),
createPanicEscapeActionSpec(),
createFleeActionSpec(),
{
id: "hide_at_owned_structure",
need: "safety",
state: "seek_bed",
label: "自分の場所に隠れている",
phrase: "自分の場所へ戻った",
weight: 30,
selectTarget: selectOwnedStructure("grass_bed", 620),
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("seek_bed", "自分の場所に隠れている"),
},
createApproachFriendActionSpec(),
createApproachFamilyActionSpec(),
createApproachMateActionSpec(),
createFightRivalActionSpec(),
createIntimidateEnemyActionSpec(),
{
id: "play",
need: "fulfill",
state: "play_ball",
label: "遊んでいる",
phrase: "遊んだ",
weight: 24,
selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; },
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("play_ball", "遊んでいる"),
},
{
id: "build_grass_bed",
need: "fulfill",
state: "build",
label: "かんたんベッドを作っている",
phrase: "かんたんベッドを作った",
weight: 34,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); },
},
{
id: "build_plushie",
need: "fulfill",
state: "build",
label: "ぬいぐるみを作っている",
phrase: "ぬいぐるみを作った",
weight: 28,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
start(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); },
},
{
id: "return_owned_structure",
need: "fulfill",
state: "seek_bed",
label: "自分の場所へ戻っている",
phrase: "自分の場所へ戻った",
weight: 22,
selectTarget: selectOwnedStructure("grass_bed", 620),
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("seek_bed", "自分の場所へ戻っている"),
},
{
id: "use_plushie",
need: "fulfill",
state: "idle",
label: "ぬいぐるみを抱えている",
phrase: "ぬいぐるみを抱えた",
weight: 18,
selectTarget: selectOwnedStructure("plushie", Infinity),
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start(t, world, ctx = {}) {
const plushie = ctx.target ?? this.selectTarget(t, world, ctx);
plushie?.use?.(t, world);
t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false });
return true;
},
},
createBirthRitualActionSpec(),
createWanderActionSpec(),
];
const TARINAI_ACTIONS = typeof registerTarinaiActionSpecs === "function"
? registerTarinaiActionSpecs(TARINAI_ACTION_DEFINITIONS)
: TARINAI_ACTION_DEFINITIONS;
if (typeof window !== "undefined") Object.assign(window, { TARINAI_ACTION_DEFINITIONS, TARINAI_ACTIONS });

193
js/tarinai_action_spec.js Normal file
View file

@ -0,0 +1,193 @@
"use strict";
(function (global) {
const registry = new Map();
function isObject(value) {
return Boolean(value && typeof value === "object");
}
function finiteOr(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function toList(value) {
if (Array.isArray(value)) return value.filter(Boolean).map(String);
if (value == null || value === "") return [];
return [String(value)];
}
function hasOwnFn(obj, key) {
return typeof obj?.[key] === "function";
}
function resolveActionSpec(action) {
if (!action) return null;
return typeof action === "string" ? getTarinaiActionSpec(action) : action;
}
function buildActionContext(tarinai, world, ctx = {}, spec = null) {
const base = isObject(ctx) ? { ...ctx } : {};
if (base.target === undefined && spec && hasOwnFn(spec, "selectTarget")) {
base.target = spec.selectTarget(tarinai, world, base);
}
return base;
}
function normalizeTarinaiActionSpec(input = {}) {
if (!isObject(input)) throw new TypeError("ActionSpec must be an object");
const id = String(input.id || input.actionId || "").trim();
if (!id) throw new TypeError("ActionSpec.id is required");
// Public lifecycle: canStart -> selectTarget -> start -> tick -> finish/fail -> text.
const spec = {
...input,
id,
kind: String(input.kind || "need_action"),
need: String(input.need || "fulfill"),
subNeed: String(input.subNeed || ""),
label: String(input.label || input.phrase || id),
phrase: String(input.phrase || input.label || id),
weight: finiteOr(input.weight, 1),
priority: finiteOr(input.priority, 0),
tags: toList(input.tags),
timerBacked: Boolean(input.timerBacked),
interruptible: input.interruptible !== false,
};
const originalCanStart = hasOwnFn(input, "canStart") ? input.canStart : null;
const originalSelectTarget = hasOwnFn(input, "selectTarget") ? input.selectTarget : null;
const originalStart = hasOwnFn(input, "start") ? input.start : null;
const originalTick = hasOwnFn(input, "tick") ? input.tick : null;
const originalFinish = hasOwnFn(input, "finish") ? input.finish : null;
const originalFail = hasOwnFn(input, "fail") ? input.fail : null;
const originalText = hasOwnFn(input, "text") ? input.text : null;
spec.selectTarget = function selectTarget(tarinai, world, ctx = {}) {
if (originalSelectTarget) return originalSelectTarget.call(spec, tarinai, world, ctx);
return ctx.target ?? tarinai?.target ?? null;
};
spec.canStart = function canStart(tarinai, world, ctx = {}) {
if (originalCanStart) return Boolean(originalCanStart.call(spec, tarinai, world, ctx));
return true;
};
spec.start = function start(tarinai, world, ctx = {}) {
if (originalStart) return originalStart.call(spec, tarinai, world, ctx);
return true;
};
spec.tick = function tick(tarinai, world, dt = 0, needs = null, ctx = {}) {
if (originalTick) return originalTick.call(spec, tarinai, world, dt, needs, ctx);
return undefined;
};
spec.finish = function finish(tarinai, world, ctx = {}) {
return originalFinish ? originalFinish.call(spec, tarinai, world, ctx) : true;
};
spec.fail = function fail(tarinai, world, ctx = {}) {
return originalFail ? originalFail.call(spec, tarinai, world, ctx) : false;
};
spec.text = function text(tarinai, world, ctx = {}) {
if (originalText) return String(originalText.call(spec, tarinai, world, ctx) || "");
if (ctx.phase === "finished") return spec.phrase;
return spec.label;
};
Object.defineProperty(spec, "__actionSpec", {
value: true,
enumerable: false,
configurable: false,
});
return spec;
}
function registerTarinaiActionSpecs(definitions = []) {
const specs = [];
for (const definition of definitions || []) {
const spec = normalizeTarinaiActionSpec(definition);
registry.set(spec.id, spec);
specs.push(spec);
}
return specs;
}
function registerTarinaiActionSpec(definition = {}) {
const spec = normalizeTarinaiActionSpec(definition);
registry.set(spec.id, spec);
return spec;
}
function getTarinaiActionSpec(id = "") {
return registry.get(String(id || "")) || null;
}
function listTarinaiActionSpecs() {
return Array.from(registry.values());
}
function selectTarinaiActionTarget(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return null;
return hasOwnFn(spec, "selectTarget") ? spec.selectTarget(tarinai, world, ctx) : (ctx.target ?? tarinai?.target ?? null);
}
function canStartTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return false;
return hasOwnFn(spec, "canStart") ? Boolean(spec.canStart(tarinai, world, isObject(ctx) ? ctx : {})) : true;
}
function startTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return false;
const canCtx = isObject(ctx) ? ctx : {};
if (hasOwnFn(spec, "canStart") && !spec.canStart(tarinai, world, canCtx)) return false;
const nextCtx = buildActionContext(tarinai, world, canCtx, spec);
return hasOwnFn(spec, "start") ? spec.start(tarinai, world, nextCtx) : spec.run?.(tarinai, world, nextCtx);
}
function tickTarinaiAction(action, tarinai, world, dt = 0, needs = null, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return undefined;
const nextCtx = buildActionContext(tarinai, world, ctx, spec);
return hasOwnFn(spec, "tick") ? spec.tick(tarinai, world, dt, needs, nextCtx) : spec.update?.(tarinai, world, dt, needs, nextCtx);
}
function finishTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
return hasOwnFn(spec, "finish") ? spec.finish(tarinai, world, ctx) : true;
}
function failTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
return hasOwnFn(spec, "fail") ? spec.fail(tarinai, world, ctx) : false;
}
function textTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return "";
return hasOwnFn(spec, "text") ? String(spec.text(tarinai, world, isObject(ctx) ? ctx : {}) || "") : String(spec.label || spec.phrase || spec.id || "");
}
Object.assign(global, {
normalizeTarinaiActionSpec,
createTarinaiActionSpec: normalizeTarinaiActionSpec,
registerTarinaiActionSpec,
registerTarinaiActionSpecs,
getTarinaiActionSpec,
listTarinaiActionSpecs,
selectTarinaiActionTarget,
canStartTarinaiAction,
startTarinaiAction,
tickTarinaiAction,
finishTarinaiAction,
failTarinaiAction,
textTarinaiAction,
TarinaiActionSpecRegistry: registry,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -7,11 +7,14 @@
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
setActionState(state, opts = {}) {
if (this.dead) return false;
const wasSleeping = this.state === "sleep" || this.sleeping;
const nextState = state || "idle";
this.state = nextState;
if ("target" in opts) this.target = opts.target;
if (opts.reason != null) this.thought = String(opts.reason || "");
if (opts.clearTarget) this.target = null;
const wakingNow = Boolean(opts.wake) || (wasSleeping && nextState !== "sleep" && opts.sleeping === false);
if (wakingNow) this.consumePendingGrassBedOnWake?.(opts.reason || "wake");
if (opts.sleeping != null) this.sleeping = Boolean(opts.sleeping);
if (opts.wake) {
this.sleeping = false;
@ -20,17 +23,74 @@
this.insideNestBoxId = "";
}
if (nextState !== "sleep" && opts.sleeping === false) this.sleepSession = null;
const reason = String(opts.reason ?? this.thought ?? "");
if (nextState === "idle") {
if (typeof clearBehavior === "function") clearBehavior(this, reason || "待っている");
else if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { clearTarget: !!opts.clearTarget });
return true;
}
const stateActionIds = {
seek_food: "eat_food",
eat: "eat_food",
seek_water: "drink_water",
seek_bed: "sleep_in_bed",
sleep: "sleep_anywhere",
panic: "panic_escape",
flee: "flee",
fight: "fight_rival",
intimidate: "intimidate_enemy",
birth_ritual: "birth_ritual",
sunbath: "sunbath",
wander: "wander_lightly",
play_ball: "play",
seek_material: "seek_material",
seek_enemy: "fight_rival",
build: "build_structure",
};
const actionId = String(opts.actionId || opts.behaviorId || stateActionIds[nextState] || nextState);
const target = opts.clearTarget ? null : ("target" in opts ? opts.target : this.target);
const phase = opts.phase || (nextState === "sleep" || nextState === "eat" || nextState === "fight" || nextState === "intimidate" || nextState === "birth_ritual" || nextState === "sunbath" ? "acting" : "approaching");
if (typeof setBehaviorText === "function") {
setBehaviorText(this, {
need: opts.need || null,
subNeed: opts.subNeed || "",
actionId,
actionLabel: opts.actionLabel || reason || nextState,
reasonText: reason || opts.actionLabel || nextState,
target,
phase,
source: opts.source || "state",
forced: opts.forced || null,
forcedId: opts.forcedId || "",
sourceReasonText: opts.sourceReasonText || opts.forcedReasonText || "",
causeText: opts.causeText || "",
});
} else if (typeof patchTarinaiBehavior === "function") {
patchTarinaiBehavior(this, {
actionId,
phase,
target,
source: opts.source || "state",
reason,
label: opts.actionLabel || reason || nextState,
text: reason || opts.actionLabel || nextState,
need: opts.need || "fulfill",
subNeed: opts.subNeed || "",
});
}
return true;
},
beginEating(item = null, reason = "") {
const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている");
const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b");
const ok = this.setActionState("eat", {
target: item || null,
reason: text,
sleeping: false,
});
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "食べている", reasonText: text, target: item || null, phase: "perform", source: "behavior" });
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: text, target: item || null, phase: "perform", source: "behavior" });
return ok;
},
@ -48,8 +108,7 @@
},
goIdle(reason = "") {
if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "待っている");
return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", sleeping: false });
return this.setActionState("idle", { target: null, reason: reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b", wake: this.state === "sleep" || this.sleeping, sleeping: false });
},
startSleeping(target = null, reason = "") {
@ -62,15 +121,38 @@
targetEnergy: inFurniture ? rand(84, 94) : rand(74, 86),
maxDuration: inFurniture ? rand(42, 68) : rand(28, 48),
targetId: target?.id || null,
consumesGrassBedOnWake: target?.type === "grass_bed",
};
}
this.intent = this.intent || { need: "sleep", tiedNeeds: ["sleep"], actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: reason || "眠っている", reasonText: "ねむいので、眠っている。", startedAt: now };
this.currentAction = this.currentAction || { id: this.intent.actionId || "sleep", need: "sleep", startedAt: this.sleepSession.startedAt, minDuration: this.sleepSession.minDuration, lockSeconds: this.sleepSession.minDuration };
const text = reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている");
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "眠っている", reasonText: text, target, phase: "perform", source: "behavior" });
if (target?.type === "grass_bed") {
this.sleepSession.targetId = target.id || this.sleepSession.targetId || null;
this.sleepSession.consumesGrassBedOnWake = true;
this.pendingGrassBedWakeId = target.id || this.pendingGrassBedWakeId || "";
target.singleUsePending = true;
target.singleUseConsumedById = this.id || target.singleUseConsumedById || "";
target.singleUseWakeReason = "sleep";
}
const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b");
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" });
return this.setActionState("sleep", { target, reason: text, sleeping: true });
},
consumePendingGrassBedOnWake(reason = "wake") {
const worldRef = this.world || null;
const sessionTargetId = this.sleepSession?.targetId || this.pendingGrassBedWakeId || null;
const bySession = sessionTargetId && worldRef?.itemById ? worldRef.itemById(sessionTargetId) : null;
const byItems = sessionTargetId ? (worldRef?.items || []).find(it => it && it.id === sessionTargetId) : null;
const candidates = [this.target, bySession, byItems];
let bed = candidates.find(it => it && !it.dead && it.type === "grass_bed" && (it.singleUsePending || it.singleUseConsumedById === this.id || (this.sleepSession?.consumesGrassBedOnWake && it.id === sessionTargetId)));
if (!bed && (this.sleepSession?.consumesGrassBedOnWake || sessionTargetId)) {
bed = (worldRef?.items || []).find(it => it && !it.dead && it.type === "grass_bed"
&& (it.id === sessionTargetId || (it.singleUsePending && (it.singleUseConsumedById === this.id || it.ownerId === this.id))));
}
if (!bed) return false;
this.pendingGrassBedWakeId = "";
return window.TarinaiStructureLifecycle?.consumeGrassBedOnWake?.(worldRef, bed, this, reason) || false;
},
seekTarget(state, target = null, reason = "") {
return this.setActionState(state, { target, reason, sleeping: false });
},
@ -81,8 +163,8 @@
},
currentBehaviorText() {
if (typeof activeBehaviorText === "function") return activeBehaviorText(this);
return String(this.activeBehavior?.presentText || this.activeBehavior?.reasonText || this.thought || "").trim();
if (typeof behaviorText === "function") return behaviorText(this);
return String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(this) : "") || this.thought || "").trim();
},
enterPanic(opts = {}) {
@ -100,18 +182,23 @@
if (healthShock > 0) this.needShock.health = Math.max(this.needShock.health || 0, healthShock);
}
let handled = false;
if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "パニックになっている");
if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b");
if (!handled) {
let target = null;
if ("target" in opts) target = opts.target;
else if (opts.destination) target = opts.destination;
else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination);
const panicReason = opts.reason || this.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
this.setActionState("panic", {
target,
reason: opts.reason || this.thought || "パニックになっている",
reason: panicReason,
wake: opts.wake !== false,
sleeping: false,
});
if (typeof setBehaviorText === "function") {
const causeText = this.lastPanicDetail || (String(panicReason).includes("\u3001") ? String(panicReason).split("\u3001")[0].replace(/\u3066$/, "\u305f") : "\u6016\u3044");
setBehaviorText(this, { need: "safety", actionId: "panic_escape", actionLabel: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", reasonText: panicReason, causeText, target, phase: "perform", source: "behavior" });
}
this.fearTimer = Math.max(this.fearTimer || 0, 2.4);
}
if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer);
@ -124,6 +211,8 @@
if (opts.bubble) this.bubble?.(opts.bubble, opts.bubbleCooldown ?? 0.9, opts.bubbleColor || "rgba(84,66,86,0.80)");
this.lastPanicCause = opts.cause || opts.reason || this.thought || "panic";
this.lastPanicAt = this.world?.time || 0;
if (this.state === "panic" && !Number.isFinite(this.panicStartedAt)) this.panicStartedAt = this.lastPanicAt;
if (this.state === "panic") this.panicHardStopAt = Math.max(this.panicHardStopAt || 0, this.lastPanicAt + 7.5);
return true;
},
}));

View file

@ -0,0 +1,246 @@
"use strict";
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js");
const PHASES = new Set(["starting", "seeking", "approaching", "acting", "recovering", "finished", "failed", "idle", "start", "active", "perform"]);
function isObject(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function finiteOr(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function nowFor(tarinai = null) {
return finiteOr(tarinai?.world?.time, 0);
}
function normalizePhase(value, fallback = "acting") {
const phase = String(value || fallback || "acting");
return PHASES.has(phase) ? phase : phase;
}
function targetIdFor(target = null) {
if (!target) return null;
if (target.id != null) return String(target.id);
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`;
return null;
}
function normalizeBehaviorTarget(input = undefined, fallback = undefined) {
const target = input !== undefined ? input : fallback;
if (!target) return null;
if (target.kind) {
const out = { kind: String(target.kind) };
if (target.id != null) out.id = String(target.id);
if (Number.isFinite(Number(target.x))) out.x = Number(target.x);
if (Number.isFinite(Number(target.y))) out.y = Number(target.y);
return out;
}
if (target.id != null) {
const type = String(target.type || "");
const kind = target.kind === "tarinai" || target.familyKey || type === "tarinai" ? "tarinai" : "item";
return { kind, id: String(target.id) };
}
if (Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y))) {
return { kind: "position", x: Number(target.x), y: Number(target.y) };
}
return null;
}
function normalizeForcedRequestValue(value = null, fallback = null) {
const src = isObject(value) ? value : {};
const prev = isObject(fallback) ? fallback : {};
const uid = String(src.uid || src.forcedId || prev.uid || prev.forcedId || "");
const id = String(src.id || src.actionId || prev.id || prev.actionId || "");
if (!uid && !id) return null;
return {
uid,
id,
source: String(src.source || prev.source || "external"),
priority: finiteOr(src.priority, finiteOr(prev.priority, 0)),
status: String(src.status || prev.status || "active"),
reasonText: String(src.reasonText || src.reason || prev.reasonText || prev.reason || ""),
causeText: String(src.causeText || prev.causeText || ""),
targetId: src.targetId !== undefined ? src.targetId : (prev.targetId !== undefined ? prev.targetId : null),
createdAt: finiteOr(src.createdAt, finiteOr(prev.createdAt, 0)),
startedAt: finiteOr(src.startedAt, finiteOr(src.activeSince, finiteOr(prev.startedAt, finiteOr(prev.activeSince, 0)))),
activeSince: finiteOr(src.activeSince, finiteOr(src.startedAt, finiteOr(prev.activeSince, finiteOr(prev.startedAt, 0)))),
expiresAt: Number.isFinite(Number(src.expiresAt)) ? Number(src.expiresAt) : (Number.isFinite(Number(prev.expiresAt)) ? Number(prev.expiresAt) : undefined),
minDuration: finiteOr(src.minDuration, finiteOr(prev.minDuration, 0)),
duration: finiteOr(src.duration, finiteOr(prev.duration, 0)),
attempt: finiteOr(src.attempt ?? src.attempts, finiteOr(prev.attempt ?? prev.attempts, 0)),
failReason: String(src.failReason || prev.failReason || ""),
};
}
function isBehaviorForcedValue(behavior = null) {
if (!isObject(behavior)) return false;
return Boolean(behavior.source === "forced" || behavior.forcedId || behavior.forcedRequest || behavior.data?.forcedSource || behavior.forced === true);
}
function forcedBehaviorSource(behavior = null) {
if (!isObject(behavior)) return "";
return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || "");
}
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
if (!isObject(input)) return null;
const now = nowFor(tarinai);
const previousActionId = previous?.actionId || "";
const actionId = String(input.actionId || previousActionId || tarinai?.state || "idle");
const targetInput = input.target !== undefined ? input.target : (input.targetRef !== undefined ? input.targetRef : undefined);
const target = normalizeBehaviorTarget(targetInput, tarinai?.target);
const forcedRequest = normalizeForcedRequestValue(
input.forcedRequest || input.request || null,
(input.forced || input.forcedId || previous?.forcedRequest)
? { ...(previous?.forcedRequest || {}), uid: input.forcedId || previous?.forcedId || previous?.forcedRequest?.uid || "", id: actionId, source: input.sourceDetail || input.source || previous?.data?.forcedSource || previous?.forcedRequest?.source || "external", priority: input.priority ?? previous?.priority ?? 0, reasonText: input.sourceReasonText || input.forcedReasonText || input.reason || input.reasonText || previous?.sourceReasonText || previous?.forcedRequest?.reasonText || "", causeText: input.causeText || previous?.causeText || "", targetId: input.targetId ?? targetIdFor(target) }
: null
);
const forced = Boolean(forcedRequest || input.forced || input.forcedId || previous?.forcedId && input.source === "forced");
const source = forced ? "forced" : String(input.source || previous?.source || "need");
const data = isObject(previous?.data) ? { ...previous.data } : {};
if (isObject(input.data)) Object.assign(data, input.data);
if (forcedRequest) data.forcedSource = forcedRequest.source || data.forcedSource || "external";
else if (forced && input.source && input.source !== "forced") data.forcedSource = String(input.source);
const reason = String(input.reason ?? input.reasonText ?? input.presentText ?? previous?.reason ?? previous?.reasonText ?? previous?.presentText ?? input.label ?? previous?.label ?? "");
const label = String(input.label || input.actionLabel || previous?.label || actionId || "行動している");
const need = String(input.need || previous?.need || "fulfill");
const tiedNeeds = Array.isArray(input.tiedNeeds) && input.tiedNeeds.length
? [...input.tiedNeeds].filter(Boolean).map(String)
: (Array.isArray(previous?.tiedNeeds) && previous.tiedNeeds.length ? [...previous.tiedNeeds] : [need]);
const behavior = {
actionId,
phase: normalizePhase(input.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "acting")),
target,
source,
reason,
priority: finiteOr(input.priority, finiteOr(previous?.priority, 0)),
startedAt: finiteOr(input.startedAt, finiteOr(previous?.startedAt, now)),
elapsed: finiteOr(input.elapsed, Math.max(0, now - finiteOr(input.startedAt ?? previous?.startedAt, now))),
timeout: Number.isFinite(Number(input.timeout ?? input.deadlineAt)) ? Number(input.timeout ?? input.deadlineAt) : (previous?.timeout ?? null),
interruptible: input.interruptible !== undefined ? input.interruptible !== false : previous?.interruptible !== false,
data,
need,
subNeed: String(input.subNeed || previous?.subNeed || ""),
tiedNeeds,
label,
text: String(input.text ?? input.presentText ?? input.reasonText ?? previous?.text ?? previous?.presentText ?? reason ?? label ?? ""),
lockSeconds: finiteOr(input.lockSeconds, finiteOr(previous?.lockSeconds, 1.0)),
minDuration: finiteOr(input.minDuration, finiteOr(previous?.minDuration, 0.45)),
deadlineAt: Number.isFinite(Number(input.deadlineAt)) ? Number(input.deadlineAt) : previous?.deadlineAt,
forcedId: forcedRequest?.uid || String(input.forcedId || previous?.forcedId || ""),
forcedRequest,
sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || forcedRequest?.reasonText || previous?.sourceReasonText || ""),
causeText: String(input.causeText || forcedRequest?.causeText || previous?.causeText || ""),
specId: String(input.specId || input.actionSpecId || previous?.specId || actionId),
specKind: String(input.specKind || previous?.specKind || "need_action"),
};
if (Number.isFinite(Number(input.serial))) behavior.serial = Number(input.serial);
return behavior;
}
function normalizeBehaviorState(opts = {}, tarinai = null) {
const previous = isObject(tarinai?.behavior) ? tarinai.behavior : null;
return normalizeBehaviorValue(opts.behavior || null, tarinai, previous);
}
function ensureBehavior(tarinai) {
if (!tarinai || !isObject(tarinai.behavior)) return null;
const b = tarinai.behavior;
return b;
}
function assignBehavior(tarinai, value) {
if (!tarinai) return null;
const next = normalizeBehaviorValue(value, tarinai, tarinai.behavior || null);
tarinai.behavior = next;
return next;
}
function patchTarinaiBehavior(tarinai, fields = {}) {
if (!tarinai || !isObject(fields)) return ensureBehavior(tarinai);
const previous = ensureBehavior(tarinai) || {};
const next = normalizeBehaviorValue({ ...previous, ...fields }, tarinai, previous);
tarinai.behavior = next;
return next;
}
function clearTarinaiBehavior(tarinai, opts = {}) {
if (!tarinai) return null;
tarinai.behavior = null;
if (opts.clearTarget) tarinai.target = null;
return null;
}
function getTarinaiBehavior(tarinai) { return ensureBehavior(tarinai); }
function getTarinaiBehaviorId(tarinai, fallback = "") { return String(ensureBehavior(tarinai)?.actionId || fallback || ""); }
function getTarinaiBehaviorNeed(tarinai, fallback = "") { return String(ensureBehavior(tarinai)?.need || fallback || ""); }
function getTarinaiBehaviorTiedNeeds(tarinai, fallbackNeed = "fulfill") {
const b = ensureBehavior(tarinai);
if (Array.isArray(b?.tiedNeeds) && b.tiedNeeds.length) return [...b.tiedNeeds];
return [String(b?.need || fallbackNeed || "fulfill")];
}
function getTarinaiBehaviorText(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.text || b?.reason || b?.label || fallback || "").trim();
}
function setTarinaiForcedBehavior(tarinai, value = null) {
if (!tarinai) return null;
const b = ensureBehavior(tarinai);
if (!b) return null;
if (!value) {
const data = isObject(b.data) ? { ...b.data } : {};
delete data.forcedSource;
const next = normalizeBehaviorValue({ ...b, source: b.source === "forced" ? "need" : (b.source || "need"), forcedId: "", forcedRequest: null, sourceReasonText: "", data }, tarinai, null);
tarinai.behavior = next;
return next;
}
const request = normalizeForcedRequestValue(value, { uid: b.forcedId || "", id: b.actionId || "idle", source: b.data?.forcedSource || "external", priority: b.priority || 0, reasonText: b.sourceReasonText || b.reason || "", causeText: b.causeText || "", targetId: targetIdFor(b.target) });
return patchTarinaiBehavior(tarinai, {
...b,
actionId: request?.id || value.actionId || value.id || b.actionId || "idle",
source: "forced",
priority: finiteOr(request?.priority, finiteOr(value.priority, finiteOr(b.priority, 0))),
forcedId: String(request?.uid || value.uid || value.forcedId || b.forcedId || ""),
forcedRequest: request,
sourceReasonText: request?.reasonText || b.sourceReasonText || "",
causeText: b.causeText || request?.causeText || "",
data: { ...(b.data || {}), forcedSource: request?.source || value.source || "external" },
});
}
function getTarinaiForcedBehavior(tarinai) {
const b = ensureBehavior(tarinai);
if (!isBehaviorForcedValue(b)) return null;
return b.forcedRequest || { uid: b.forcedId || "", id: b.actionId || "", source: forcedBehaviorSource(b) || "external", priority: finiteOr(b.priority, 0), reasonText: b.sourceReasonText || b.reason || "", causeText: b.causeText || "" };
}
Object.assign(global, {
normalizeTarinaiBehaviorState: normalizeBehaviorState,
normalizeTarinaiBehaviorValue: normalizeBehaviorValue,
getTarinaiBehavior,
currentTarinaiBehavior: getTarinaiBehavior,
getTarinaiBehaviorId,
getTarinaiBehaviorNeed,
getTarinaiBehaviorTiedNeeds,
getTarinaiBehaviorText,
patchTarinaiBehavior,
setTarinaiBehavior: assignBehavior,
clearTarinaiBehavior,
normalizeTarinaiForcedRequest: normalizeForcedRequestValue,
getTarinaiForcedBehavior,
setTarinaiForcedBehavior,
clearTarinaiForcedBehavior: function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); },
isTarinaiBehaviorForced: function isTarinaiBehaviorForced(tarinai) { return isBehaviorForcedValue(ensureBehavior(tarinai)); },
isTarinaiBehaviorValueForced: isBehaviorForcedValue,
tarinaiBehaviorForcedSource: forcedBehaviorSource,
});
})(typeof window !== "undefined" ? window : globalThis);

168
js/tarinai_behavior_text.js Normal file
View file

@ -0,0 +1,168 @@
"use strict";
function fulfillPrimaryReason(tarinai, action = null) {
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
const actionId = action?.id || behavior?.actionId || "";
if (actionId === "build_grass_bed") return "\u5bdd\u5e8a\u304c\u307b\u3057\u3044";
if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044";
if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b";
if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044";
if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b";
if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b";
const parts = tarinai?.fulfillReasonParts || {};
const candidates = [
["bed", "\u5bdd\u5e8a\u304c\u307b\u3057\u3044", parts.bed],
["plushie", "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044", parts.plushie],
["boredom", "\u9000\u5c48\u3057\u3066\u3044\u308b", parts.boredom],
["material", "\u8349\u3067\u306a\u306b\u304b\u4f5c\u308c\u305d\u3046", parts.material],
["openness", "\u65b0\u3057\u3044\u3053\u3068\u304c\u6c17\u306b\u306a\u308b", parts.openness],
].filter(([, , v]) => Number(v || 0) > 0);
candidates.sort((a, b) => Number(b[2] || 0) - Number(a[2] || 0));
return candidates[0]?.[1] || TARINAI_NEED_PHRASES.fulfill || "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044";
}
function needPhraseForReason(need, tarinai = null, action = null) {
if (need === "fulfill") return fulfillPrimaryReason(tarinai, action);
if (need === "social" && action?.subNeed === "mate") return "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u304c\u6c17\u306b\u306a\u308b";
if (need === "social" && action?.id === "birth_ritual") return "\u76f8\u624b\u3068\u7e41\u6b96\u3057\u3066\u3044\u308b";
if (need === "social" && action?.subNeed === "conflict") return "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b";
if (need === "social" && action?.subNeed === "family") return "\u5bb6\u65cf\u304c\u6c17\u306b\u306a\u308b";
if (need === "social" && action?.subNeed === "bond") return "\u4ef2\u9593\u304c\u6c17\u306b\u306a\u308b";
if (need === "safety" && action?.id === "panic_escape") return "\u6016\u3044";
return TARINAI_NEED_PHRASES[need] || need;
}
function actionTextForReason(action = null, tarinai = null) {
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
const id = action?.id || behavior?.actionId || "";
const target = tarinai?.target || currentBehaviorTarget?.(tarinai) || null;
const name = target?.name || "\u76f8\u624b";
if (id === "birth_ritual") return target?.name ? `${name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
if (id === "fight_rival") {
const cause = String(behavior?.causeText || "");
if (cause.includes("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")) return "\u53cd\u6483\u3057\u3066\u3044\u308b";
return target?.name ? `${name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b";
}
if (id === "panic_escape" || id === "flee") return "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
return String(action?.label || action?.phrase || behavior?.label || "\u5f85\u3063\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, "");
}
function forcedCausePhrase(source = "", fallback = "") {
const key = String(source || "");
if (key === "love_mochi") return "\u3078\u3053\u9905\u306e\u52b9\u679c";
if (key === "fight_mochi") return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c";
if (key === "sleep_drug") return "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c";
if (key === "drug") return "\u85ac\u306e\u52b9\u679c";
return String(fallback || "").replace(/[\u3002\uff01\uff1f]+$/, "");
}
function formatCauseActionText(cause = "", actionText = "\u884c\u52d5\u3057\u3066\u3044\u308b") {
const c = String(cause || "").replace(/[\u3002\uff01\uff1f]+$/, "").trim();
const a = String(actionText || "\u884c\u52d5\u3057\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, "").trim();
if (!c) return `${a}\u3002`;
if (c.endsWith("\u52b9\u679c") || c.endsWith("\u5f71\u97ff")) return `${c}\u3067\u3001${a}\u3002`;
if (c.endsWith("\u6c17\u4ed8\u3044\u305f")) return `${c.replace(/\u6c17\u4ed8\u3044\u305f$/, "\u6c17\u4ed8\u3044\u3066")}\u3001${a}\u3002`;
if (c.endsWith("\u6c17\u3065\u3044\u305f")) return `${c.replace(/\u6c17\u3065\u3044\u305f$/, "\u6c17\u3065\u3044\u3066")}\u3001${a}\u3002`;
if (c.endsWith("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f")) return `${c.replace(/\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f$/, "\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")}\u3001${a}\u3002`;
if (c.endsWith("\u5a01\u5687\u3055\u308c\u305f")) return `${c.replace(/\u5a01\u5687\u3055\u308c\u305f$/, "\u5a01\u5687\u3055\u308c")}\u3001${a}\u3002`;
return `${c}\u306e\u3067\u3001${a}\u3002`;
}
function causePhraseForReason(chosenNeed, action, tarinai = null, options = {}) {
if (options?.causeText) return String(options.causeText || "").replace(/[\u3002\uff01\uff1f]+$/, "");
const b = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || {};
if (b.causeText) return String(b.causeText).replace(/[\u3002\uff01\uff1f]+$/, "");
if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.forcedId || b.forcedRequest)) || b.source) {
const forced = forcedCausePhrase(b.source || "", b.sourceReasonText || b.sourceReasonText || "");
if (forced) return forced;
}
if (action?.id === "panic_escape" && tarinai?.lastPanicDetail) return String(tarinai.lastPanicDetail).replace(/[\u3002\uff01\uff1f]+$/, "");
if (action?.id === "fight_rival") {
const target = currentBehaviorTarget?.(tarinai) || tarinai?.target || null;
if (tarinai?.counterAttackFromId && target?.id === tarinai.counterAttackFromId) return `${target.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`;
if (target?.name) return `${target.name}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`;
}
if (action?.id === "birth_ritual" || action?.subNeed === "mate") {
if ((tarinai?.loveMochiTimer || 0) > 0.04) return "\u3078\u3053\u9905\u306e\u52b9\u679c";
}
return needPhraseForReason(chosenNeed, tarinai, action);
}
function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null, options = {}) {
const actionText = actionTextForReason(action, tarinai);
const cause = causePhraseForReason(chosenNeed, action, tarinai, options);
const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : [];
const main = formatCauseActionText(cause, actionText);
if (tied.length) {
const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0];
return `${needPhraseForReason(ignored, tarinai, action)}\u3051\u3069\u3001${main}`;
}
return main;
}
function needsForBehaviorText(tarinai, behavior = {}, fallbackNeed = "fulfill") {
const primary = String(behavior?.need || fallbackNeed || "fulfill");
const raw = tarinai?.needRaw || tarinai?.needs || {};
const list = Array.isArray(behavior?.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [primary];
if (!list.includes(primary)) list.unshift(primary);
return list.filter((key, idx) => key && (idx === 0 || Number(raw?.[key] || 0) > 0.5));
}
function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {}) {
const request = behavior?.forcedRequest || (typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(tarinai) : null) || null;
const directCause = options?.causeText || behavior?.causeText || request?.causeText || "";
if (directCause) return String(directCause || "").replace(/[。!?]+$/, "");
if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.forcedId || behavior?.forcedRequest)) || request) {
const source = request?.source || behavior?.source || "external";
const sourceReason = request?.reasonText || behavior?.sourceReasonText || behavior?.sourceReasonText || "";
const forcedCause = forcedCausePhrase(source, sourceReason);
if (forcedCause) return forcedCause;
}
return causePhraseForReason(behavior?.need || action?.need || "fulfill", action, tarinai, options);
}
function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) {
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null;
if (!b) return "";
const stored = String(b.text || b.reason || "").trim();
if (stored && options.regenerate !== true) return stored;
const action = options.action || actionById(b.specId || b.actionId) || actionById(b.actionId) || null;
const need = String(options.need || b.need || action?.need || "fulfill");
const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, need);
const causeText = causeForBehaviorText(tarinai, { ...b, need }, action, options);
const generated = action ? buildReasonText(need, tiedNeeds, action, tarinai, tarinai?.world || null, { ...options, causeText }) : "";
const actionLabel = typeof textTarinaiAction === "function" ? textTarinaiAction(action || b.actionId, tarinai, tarinai?.world || null, { ...options, behavior: b, need, tiedNeeds, causeText, phase: options.phase || b.phase || "active" }) : "";
const rawFallback = String(actionLabel || b.label || b.reason || b.text || "").trim();
const catalog = globalThis.TEXT_CATALOG || (typeof window !== "undefined" ? window.TEXT_CATALOG : null);
const fallback = catalog?.cleanBehaviorText
? catalog.cleanBehaviorText(rawFallback, b.actionId || b.specId || "")
: rawFallback;
return String(generated || fallback || "").trim();
}
function patchBehaviorTextFromComposer(tarinai, behavior = null, options = {}) {
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null;
if (!b) return "";
const prevReason = String(b.reason || "");
const text = composeTarinaiBehaviorText(tarinai, b, { ...options, regenerate: true });
if (!text) return "";
const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, b.need || options.need || "fulfill");
if (text !== b.reason || text !== b.text || JSON.stringify(tiedNeeds) !== JSON.stringify(b.tiedNeeds || [])) {
if (typeof patchTarinaiBehavior === "function") patchTarinaiBehavior(tarinai, { reason: text, text: text, tiedNeeds });
else { b.reason = text; b.text = text; b.tiedNeeds = tiedNeeds; }
}
if (text && (!tarinai.thought || tarinai.thought === prevReason || tarinai.thought === b.label)) tarinai.thought = text;
return text;
}
function refreshBehaviorReasonText(tarinai, action = null) {
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
if (!behavior?.need) return "";
return patchBehaviorTextFromComposer(tarinai, behavior, { action });
}

View file

@ -0,0 +1,116 @@
"use strict";
// Owned-structure building behavior runtime.
function structureBuildProbe(type, x, y) {
const def = globalThis.StructureRegistry?.get?.(type) || {};
const r = type === "plushie" ? 6.5 : (type === "grass_bed" ? 18 : 24);
return { type, x, y, r, dead: false, isStructure: true, roles: def.roles || {}, ownerId: "" };
}
function canBuildOwnedStructureByAge(tarinai) {
if (!tarinai || tarinai.dead) return false;
if (tarinai.generation <= 1) return true;
const growth = Number.isFinite(tarinai.growth)
? tarinai.growth
: clamp(((tarinai.world?.time || 0) - (tarinai.birthTime || 0)) / Math.max(1, CONFIG.childGrowTime || 58), 0, 1);
return growth >= 0.55;
}
function stopFailedBuildPlan(t, world, type = "", reason = "") {
if (!t) return false;
t.buildPlan = null;
t.actionCooldowns = t.actionCooldowns || {};
t.actionIdCooldowns = t.actionIdCooldowns || {};
const actionId = type === "grass_bed" ? "build_grass_bed" : (type === "plushie" ? "build_plushie" : "build");
t.actionCooldowns.fulfill = Math.max(t.actionCooldowns.fulfill || 0, 5.0);
t.actionCooldowns.sleep = Math.max(t.actionCooldowns.sleep || 0, type === "grass_bed" ? 3.8 : 0);
t.actionIdCooldowns[actionId] = Math.max(t.actionIdCooldowns[actionId] || 0, 9.0);
if (type === "grass_bed") {
t.actionIdCooldowns.sleep_build_grass_bed = Math.max(t.actionIdCooldowns.sleep_build_grass_bed || 0, 9.0);
t.lastFailedGrassBedBuildAt = world?.time || 0;
}
t.behaviorSwitchCooldownUntil = Math.max(t.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 1.2);
const text = reason || "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044";
if (typeof clearBehavior === "function") clearBehavior(t, text);
t.goIdle?.(text);
return false;
}
function findStructureBuildSpot(t, world, type) {
const baseX = Number.isFinite(t?.x) ? t.x : 0;
const baseY = Number.isFinite(t?.y) ? t.y : 0;
const candidates = [];
for (let i = 0; i < 28; i++) {
const angle = (i / 28) * Math.PI * 2 + rand(-0.18, 0.18);
const ring = type === "grass_bed" ? rand(34, 72) : rand(18, 42);
candidates.push({ x: clamp(baseX + Math.cos(angle) * ring, CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + Math.sin(angle) * ring, CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) });
}
candidates.unshift({ x: clamp(baseX + rand(-18, 18), CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + rand(16, 28), CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) });
let best = null;
let bestScore = -Infinity;
for (const c of candidates) {
const probe = structureBuildProbe(type, c.x, c.y);
if (world?.placementBlocked?.(probe)) continue;
let nearestSame = Infinity;
if (type === "grass_bed") {
for (const it of world?.nearbyItems?.(c.x, c.y, 160) || world?.items || []) {
if (!it || it.dead || it.type !== "grass_bed") continue;
nearestSame = Math.min(nearestSame, distXY(c.x, c.y, it.x, it.y));
}
}
const score = (Number.isFinite(nearestSame) ? nearestSame : 160) - distXY(c.x, c.y, baseX, baseY) * 0.12;
if (score > bestScore) { best = c; bestScore = score; }
}
return best || candidates[0] || { x: baseX, y: baseY };
}
function buildOwnedStructureFromGrass(t, world, type, label) {
const material = findNearbyMaterial(world, t, "grassMaterial", 520);
if (!material || !globalThis.StructureRegistry) return false;
t.buildPlan = {
type,
materialId: material.id,
timer: Math.max(0.4, t.buildPlan?.type === type ? Number(t.buildPlan.timer || 0) : 4),
};
if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) return moveToOrUse(t, material, "seek_material", label);
t.setActionState?.("build", { target: material, reason: label, sleeping: false });
return true;
}
function continueBuildPlan(t, world, dt) {
const plan = t.buildPlan;
if (!plan?.type || !globalThis.StructureRegistry) return false;
if (findOwnedStructure(world, t, plan.type)) {
t.buildPlan = null;
return false;
}
let material = (world?.itemById?.(plan.materialId) || null);
if (material && (material.dead || !roleMatches(material, "grassMaterial"))) material = null;
if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520);
if (!material) {
return stopFailedBuildPlan(t, world, plan.type, "\u6750\u6599\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}
plan.materialId = material.id;
const label = globalThis.StructureRegistry.get(plan.type)?.label || "\u69cb\u9020\u7269";
if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) {
moveToOrUse(t, material, "seek_material", `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`);
return true;
}
plan.timer = Math.max(0, Number(plan.timer || 0) - Math.max(0.05, dt || 0.1));
t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false });
if (plan.timer > 0) return true;
consumeGrassMaterial(material, plan.type === "grass_bed" ? 4 : 1);
const spot = findStructureBuildSpot(t, world, plan.type);
const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world);
if (!structure) {
return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}
world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure);
world.drawListDirty = true;
structure.use?.(t, world);
t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false });
applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type);
t.buildPlan = null;
return true;
}

View file

@ -0,0 +1,150 @@
"use strict";
// Consumable/drink/medicine behavior runtime.
function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color = "#f1dfb7") {
if (!t || !item || !(eating > 0)) return false;
t.beginEating?.(item, label || "\u98df\u3079\u3066\u3044\u308b");
t.vx *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60);
t.vy *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60);
t.eatTimer = Math.max(t.eatTimer || 0, 1.0);
t.eatCooldown = rand(0.96, 1.12);
t.foodReactTimer = Math.max(t.foodReactTimer || 0, 0.16);
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: Math.max(18, Math.min(54, (Number(eating) || 0) * 1.1)) }, "meal");
t.lastMealColor = color;
if (Math.random() < Math.max(0.1, dt || 0.1) * 2.2) {
const mouth = t.mouthPosition?.(item) || { x: t.x, y: t.y };
t.world?.spawnEatEffect?.(mouth.x, mouth.y, color);
}
if ((t.world?.time || 0) > (t.nextEatSound || -999)) {
audio.eat?.();
t.nextEatSound = (t.world?.time || 0) + 0.42;
}
return true;
}
function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) {
if (!t || !world || !target || target.dead) return false;
const foodType = effectiveFoodTypeForItem(target);
const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269";
const effectiveDt = Math.max(0.10, Number(dt) || 0.12);
if (isDuplicatorFoodSource(target) && (t.eatCooldown || 0) > 0.04) {
t.setActionState?.(role === "medicine" ? "seek_food" : "eat", { target, reason: `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, sleeping: false });
return true;
}
if ((role === "drink" || role === "medicine") && (target.type === "water" || target.type === "water_bowl")) {
const text = target.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b";
t.setActionState?.("seek_water", { target, reason: text, sleeping: false });
t.thought = text;
t.applyWaterEffect?.(0.55, target.type);
if (target.type === "water") target.amount -= 1.2;
if ((world.time || 0) > (t.nextDrinkSound || -999)) {
audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 });
t.nextDrinkSound = (world.time || 0) + 0.72;
}
applyNeedRelief(t, role === "medicine" ? { food: -2, health: -5, safety: -1 } : { food: -4, health: -1 });
return true;
}
if (target.type === "ant_corpse" && (target.amount || 0) > 0) {
const bite = Math.min(target.amount, rand(3.2, 5.4));
if (!(bite > 0)) return false;
target.amount -= bite;
finishBehaviorConsumption(t, target, "ant_corpse", bite, effectiveDt, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b", "#4a3f36");
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.62) : (t.hunger -= bite * 0.62);
t.recoverHealth?.(bite * 0.12);
applyNeedShock(t, { safety: bite * 0.12 });
t.makePoop?.(normalPoopUnitsFor("ant_corpse", bite));
return true;
}
if ((foodType === "sweet" || foodType === "food" || (isDuplicatorFoodSource(target) && foodType === "grass") || (typeof isServingFoodType === "function" && isServingFoodType(foodType)) || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && t.foodItemHasServingLeft?.(target)) {
const eating = t.consumeFoodServing?.(target);
if (!(eating > 0)) return false;
const isParam = typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType);
const isSpecial = foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || isParam;
const color = ({ sweet: "#78b957", love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f" })[foodType] || "#f1dfb7";
finishBehaviorConsumption(t, target, foodType, eating, effectiveDt, `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, color);
const hungerRelief = t.foodHungerReliefFor?.(foodType || target.type, eating, foodType === "sweet" ? 0.75 : (isSpecial ? 0.82 : 0.95)) ?? eating * 0.8;
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(hungerRelief) : (t.hunger -= hungerRelief);
t.recoverHealth?.(eating * (foodType === "sweet" ? 0.28 : (isSpecial ? 0.24 : 0.46)));
t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012;
if (isSpecial && t.applyParamItemEffect) t.applyParamItemEffect(foodType || target.type, target, { source: "eating", nutrition: eating, silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" });
if (foodType === "sweet") {
applyNeedRelief(t, { food: -eating * 0.4, fulfill: -eating * 0.2 });
if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
if (t.fightDisease) t.recoverFightDisease?.("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f");
}
const fromDuplicator = isDuplicatorFoodSource(target);
t.makePoop?.(normalPoopUnitsFor(foodType || target.type, eating));
if (fromDuplicator) {
t.mealCooldownUntil = Math.max(t.mealCooldownUntil || 0, (world.time || 0) + 1.2);
t.behaviorLockTimer = Math.max(0, Math.min(t.behaviorLockTimer || 0, 0.18));
}
return true;
}
if (target.type === "grass" && (target.amount || 0) > 0) {
const bite = window.TarinaiGrass?.regress?.(target, 1) ?? 0;
if (!(bite > 0)) return false;
target.eatenAmount = (target.eatenAmount || 0) + bite;
world.markTerrainDirty?.("grass-eaten-stage");
world.markItemBucketsDirty?.("grass-eaten-stage");
finishBehaviorConsumption(t, target, "grass", bite, effectiveDt, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b", "#5b9d39");
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.46) : (t.hunger -= bite * 0.46);
t.recoverHealth?.(bite * 0.18);
applyNeedRelief(t, { food: -2, fulfill: -1 });
t.makePoop?.(normalPoopUnitsFor("grass", bite));
return true;
}
if (target.type === "zunchi" && (target.amount || 0) > 0) {
const foodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0;
if (zunchiEatingBlocked(t, target, world)) return false;
if (!t.isZunchiSlave && hasBetterFoodThanZunchiNearby(t, world, 240)) return false;
const emergencyHunger = (t.hunger || 0) >= 104 || foodNeed >= 94;
if (!emergencyHunger && !t.isZunchiSlave && target.stage === "fresh" && (target.age || 0) < 150 && foodNeed < 94 && (t.hunger || 0) < 104) return false;
if (!t.isZunchiSlave && foodNeed < 90 && (t.hunger || 0) < 100) return false;
const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1));
const bite = Math.min(target.amount, (t.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale);
if (!(bite > 0)) return false;
target.amount -= bite;
finishBehaviorConsumption(t, target, "zunchi", bite, effectiveDt, t.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b", "#6b8d3f");
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * (t.isZunchiSlave ? 0.66 : 0.18)) : (t.hunger -= bite * (t.isZunchiSlave ? 0.66 : 0.18));
t.recoverHealth?.(bite * (t.isZunchiSlave ? 0.10 : 0.02));
applyNeedShock(t, { health: bite * (t.isZunchiSlave ? 0.2 : 1.2), safety: bite * (t.isZunchiSlave ? 0.1 : 0.6) });
t.makePoop?.(normalPoopUnitsFor("zunchi", bite));
return true;
}
return false;
}
function behaviorFoodTarget(t, world, role = "food", maxDist = 860) {
const best = findNearestItemWithRole(world, t, role, maxDist);
if (role === "food") return best;
const current = currentBehaviorTarget(t) || t.target;
if (current && !current.dead && (roleMatches(current, role) || (role === "food" && current.type === "duplicator" && current.storedFoodType))) return current;
return best;
}
function updateConsumableBehavior(t, world, dt, role = "food") {
if (!t || !world) return false;
const target = behaviorFoodTarget(t, world, role, role === "food" ? 900 : 700);
if (!target || target.dead) return false;
const state = role === "drink" ? "seek_water" : "seek_food";
const label = role === "drink" ? "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : (role === "medicine" ? "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b");
const contactDistance = target.type === "duplicator" ? foodInteractionDistance(t, target) : dist(t, target);
const contactReach = foodInteractionReach(t, target, role);
if (contactDistance > contactReach) {
const approachTarget = target.type === "duplicator" ? { x: foodInteractionPoint(target).x, y: foodInteractionPoint(target).y, id: `${target.id || "duplicator"}:dish`, dead: false, hostItem: target } : target;
t.target = target;
moveToOrUse(t, approachTarget, state, label);
return true;
}
t.target = target;
if (consumeBehaviorTarget(t, world, target, role, dt)) return "finished";
t.setActionState?.(state, { target, reason: label, sleeping: false });
return t.foodItemHasServingLeft?.(target) ? true : false;
}

View file

@ -70,22 +70,13 @@
}
}
},
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.startSleeping(null, "ねむり病で眠り続けている");
this.startSleeping(null, "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b");
this.fightTimer = 0;
this.intimidateTimer = 0;
this.fightTargetIds = [];
@ -104,7 +95,7 @@
this.sleepDiseaseCooldown = 14;
this.sleepDiseaseAnchorX = null;
this.sleepDiseaseAnchorY = null;
this.goIdle(reason || "ねむり病が治った");
this.goIdle(reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f");
this.sleeping = false;
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10);
audio.heal?.();
@ -181,7 +172,7 @@
const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy);
if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x;
if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y;
this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている");
this.startSleeping(null, beingDraggedByAnt ? "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b" : "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b");
this.vx = 0;
this.vy = 0;
if (beingDraggedByAnt) {
@ -204,15 +195,10 @@
if (!id) return null;
return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null;
},
isInsideNestBox() {
return Boolean(this.insideNestBoxId);
},
wantsNestBox(box = null) {
if (!box || box.dead || box.type !== "nest_box") return false;
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) return true;
if (!(this.state === "sleep" || this.state === "seek_bed")) return false;
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true;
if (!(this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return false;
return this.target === box || this.insideNestBoxId === box.id;
},
@ -228,7 +214,7 @@
}
this.insideNestBoxId = box.id;
this.target = box;
if (this.state === "seek_bed") this.startSleeping(box, "巣箱の中で眠っている");
if (this.state === "seek_bed") this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
if (this.state === "sleep") this.sleeping = true;
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y };
const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1);
@ -278,8 +264,8 @@
}
if (currentBox) {
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) {
this.startSleeping(box, "巣箱の中で眠っている");
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) {
this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
this.sleeping = true;
this.target = currentBox;
}

View file

@ -0,0 +1,466 @@
"use strict";
function actionById(id = "") {
const key = String(id || "");
return (typeof getTarinaiActionSpec === "function" ? getTarinaiActionSpec(key) : null)
|| (Array.isArray(TARINAI_ACTIONS) ? TARINAI_ACTIONS.find(action => action && action.id === key) || null : null);
}
function behaviorTargetId(target = null) {
if (!target) return null;
if (target.id != null) return target.id;
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`;
return null;
}
function currentBehaviorTarget(tarinai, behavior = null) {
if (!tarinai) return null;
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || null;
if (!b) return tarinai.target || null;
const ref = b.target || null;
const id = ref?.id || b.targetId || null;
if (tarinai.target && !tarinai.target.dead && (!id || behaviorTargetId(tarinai.target) === id)) return tarinai.target;
if (id) {
return tarinai.world?.liveTarinaiById?.(id)
|| tarinai.world?.itemById?.(id)
|| (tarinai.world?.items || []).find(item => item && !item.dead && item.id === id)
|| null;
}
if (Number.isFinite(ref?.x) && Number.isFinite(ref?.y)) return { x: ref.x, y: ref.y, dead: false };
return null;
}
function isLiveTarinaiEntity(value) {
return Boolean(value && !value.dead && typeof value.relationTo === "function" && Number.isFinite(value.x) && Number.isFinite(value.y));
}
function relationFearSafe(holder, other) {
if (!holder || !other || typeof holder.relationTo !== "function") return 0;
return Number(holder.relationTo(other.id)?.fear || 0) || 0;
}
function nearestForcedFightTarget(tarinai, world, maxDist = 720) {
if (!tarinai || !world) return null;
const current = currentBehaviorTarget(tarinai) || tarinai.target || null;
const ok = o => isLiveTarinaiEntity(o) && o !== tarinai && !!o.isZunchiSlave === !!tarinai.isZunchiSlave && !world.areParentChild?.(tarinai, o) && !world.areCoParents?.(tarinai, o) && !o.sleepDisease;
if (ok(current) && dist(tarinai, current) <= maxDist + 120) return current;
const conflict = conflictTargetFor(tarinai, world, 6);
if (ok(conflict?.target)) return conflict.target;
return world.nearestOther?.(tarinai, maxDist, ok) || null;
}
function resolveForcedBehaviorTarget(tarinai, world, entry = {}, action = null) {
if (!tarinai || !world) return null;
if (entry.targetId) {
const found = world.liveTarinaiById?.(entry.targetId)
|| world.itemById?.(entry.targetId)
|| (world.items || []).find(item => item && !item.dead && item.id === entry.targetId)
|| null;
if (found) return found;
}
const id = String(entry.id || action?.id || "");
if (id === "fight_rival" || id === "intimidate_enemy") return nearestForcedFightTarget(tarinai, world, Number(entry.searchRange || 760));
if (id === "approach_mate" || id === "birth_ritual") return mateTargetFor(tarinai, world, Number(entry.searchRange || 760));
if (id === "eat_food") return behaviorFoodTarget(tarinai, world, "food", Number(entry.searchRange || 900));
if (id === "drink_water") return behaviorFoodTarget(tarinai, world, "drink", Number(entry.searchRange || 680));
if (id === "use_medicine") return behaviorFoodTarget(tarinai, world, "medicine", Number(entry.searchRange || 760));
if (id === "sleep_in_bed") return findNearestSleepPlace(world, tarinai, Number(entry.searchRange || 760));
return null;
}
function forcedBehaviorSourcePriority(source = "external") {
return Number(TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY[String(source || "external")] || TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY.external || 130) || 130;
}
function forcedBehaviorQueueOf(tarinai) {
if (!tarinai) return [];
if (!Array.isArray(tarinai.forcedBehaviorQueue)) tarinai.forcedBehaviorQueue = [];
return tarinai.forcedBehaviorQueue;
}
function setForcedBehaviorQueue(tarinai, queue = []) {
if (!tarinai) return [];
tarinai.forcedBehaviorQueue = (Array.isArray(queue) ? queue : []).filter(Boolean);
return tarinai.forcedBehaviorQueue;
}
function normalizeForcedBehaviorEntry(tarinai, actionId, opts = {}) {
const now = tarinai?.world?.time || 0;
const source = opts.source || "external";
const basePriority = forcedBehaviorSourcePriority(source);
const raw = {
uid: opts.uid || `${String(actionId)}:${source}:${now.toFixed(3)}:${Math.random().toString(36).slice(2, 8)}`,
id: String(actionId),
targetId: opts.targetId ?? behaviorTargetId(opts.target),
reason: opts.reasonText || opts.reason || "",
causeText: opts.causeText || "",
source,
priority: Number(opts.priority ?? Math.max(basePriority, actionPriority(actionId) + 70)) || basePriority,
force: opts.force !== false,
replaceSameSource: opts.replaceSameSource !== false,
interrupt: opts.interrupt !== false,
createdAt: now,
status: opts.status || "queued",
attempts: Number(opts.attempts || 0) || 0,
lastTriedAt: Number(opts.lastTriedAt ?? -999) || -999,
retryDelay: Number(opts.retryDelay ?? 0.25) || 0.25,
expiresAt: Number.isFinite(opts.expiresAt) ? opts.expiresAt : now + Number(opts.ttl ?? 18),
duration: Number(opts.duration ?? 0) || 0,
minDuration: Number(opts.minDuration ?? opts.duration ?? 0) || 0,
searchRange: Number(opts.searchRange ?? 0) || 0,
};
return typeof normalizeTarinaiForcedRequest === "function" ? { ...raw, ...normalizeTarinaiForcedRequest(raw, raw), force: raw.force, replaceSameSource: raw.replaceSameSource, interrupt: raw.interrupt, retryDelay: raw.retryDelay, attempts: raw.attempts, lastTriedAt: raw.lastTriedAt, searchRange: raw.searchRange } : raw;
}
function markForcedBehaviorActive(tarinai, entry, action = null) {
if (!tarinai || !entry) return null;
entry.status = "active";
entry.activeSince = entry.activeSince || tarinai.world?.time || 0;
entry.startedAt = entry.startedAt || entry.activeSince;
const payload = { ...entry, id: action?.id || entry.id, status: "active" };
return typeof setTarinaiForcedBehavior === "function" ? setTarinaiForcedBehavior(tarinai, payload) : payload;
}
function behaviorIsForced(behavior = null) {
return typeof isTarinaiBehaviorValueForced === "function"
? isTarinaiBehaviorValueForced(behavior)
: Boolean(behavior?.source === "forced" || behavior?.forcedId || behavior?.forcedRequest);
}
function isCurrentBehaviorForced(tarinai) {
return behaviorIsForced(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior);
}
function currentForcedBehaviorRequest(tarinai) {
if (typeof getTarinaiForcedBehavior === "function") return getTarinaiForcedBehavior(tarinai);
const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
return behaviorIsForced(b) ? (b.forcedRequest || { uid: b.forcedId || "", id: b.actionId || "", source: b.data?.forcedSource || b.source || "external", priority: Number(b.priority || 0) || 0 }) : null;
}
function clearForcedBehaviorQueue(tarinai, predicate = null) {
if (!tarinai) return 0;
const queue = forcedBehaviorQueueOf(tarinai);
const before = queue.length;
const next = typeof predicate !== "function" ? [] : queue.filter(entry => !predicate(entry));
setForcedBehaviorQueue(tarinai, next);
const active = currentForcedBehaviorRequest(tarinai);
if (active?.uid && !next.some(entry => entry && entry.uid === active.uid) && typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return before - next.length;
}
function behaviorText(tarinai) {
const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
if (!b) return "";
const state = String(tarinai.state || "");
const timerActive = (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.fightTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.intimidateTimer || 0) > 0.04 || (tarinai.fearTimer || 0) > 0.04;
const validByState = !b.state || b.state === state || timerActive || ["seek_food", "seek_water", "seek_friend", "seek_enemy", "follow_parent", "seek_bed", "wander", "build", "play_ball"].includes(state);
if (!validByState && !behaviorIsForced(b)) return "";
return composeTarinaiBehaviorText(tarinai, b) || String(b.text || b.reason || b.label || "").trim();
}
function setBehavior(tarinai, fields = {}) {
if (!tarinai || tarinai.dead) return null;
const now = tarinai.world?.time || 0;
const previous = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const previousActionId = previous.actionId || "";
const actionId = fields.actionId || fields.id || previousActionId || tarinai.state || "idle";
const action = fields.action || actionById(actionId) || null;
const need = fields.need || action?.need || previous.need || "fulfill";
const label = fields.label || fields.actionLabel || action?.label || previous.label || "行動している";
const reason = fields.reason || fields.text || label;
const startedAt = Number.isFinite(fields.startedAt) ? fields.startedAt : (Number.isFinite(previous.startedAt) && previousActionId === actionId ? previous.startedAt : now);
const lockSeconds = Number(fields.lockSeconds ?? previous.lockSeconds ?? (action ? actionLockSeconds(action, tarinai) : 1.0)) || 1.0;
const minDuration = Number(fields.minDuration ?? previous.minDuration ?? Math.max(0.45, lockSeconds * 0.72)) || 0.45;
const target = fields.target !== undefined ? fields.target : tarinai.target;
const forced = Boolean(fields.forced ?? (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(previous) : Boolean(previous.forcedId || previous.forcedRequest)));
const source = forced ? "forced" : (fields.source || previous.source || "need");
const forcedRequest = fields.forcedRequest || previous.forcedRequest || null;
const next = {
actionId,
need,
subNeed: fields.subNeed || action?.subNeed || previous.subNeed || "",
phase: fields.phase || previous.phase || (String(tarinai.state || "") === "idle" ? "idle" : "acting"),
label,
text: fields.text || reason,
reason,
target,
startedAt,
elapsed: Math.max(0, now - startedAt),
minDuration,
lockSeconds,
deadlineAt: Number.isFinite(fields.deadlineAt) ? fields.deadlineAt : previous.deadlineAt,
source,
sourceReasonText: fields.sourceReasonText || previous.sourceReasonText || "",
causeText: fields.causeText || previous.causeText || "",
forcedId: fields.forcedId || previous.forcedId || "",
forcedRequest,
priority: Number(fields.priority ?? previous.priority ?? (action ? actionPriority(action) : 0)) || 0,
interruptible: fields.interruptible !== false,
data: { ...(previous.data || {}), ...(fields.data || {}) },
tiedNeeds: fields.tiedNeeds || previous.tiedNeeds || [need],
specId: fields.specId || action?.id || previous.specId || actionId,
specKind: fields.specKind || action?.kind || previous.specKind || "need_action",
};
if (forcedRequest?.source) next.data.forcedSource = forcedRequest.source;
tarinai.behaviorSerial = (tarinai.behaviorSerial || 0) + 1;
next.serial = tarinai.behaviorSerial;
if (typeof setTarinaiBehavior === "function") tarinai.behavior = setTarinaiBehavior(tarinai, next);
else tarinai.behavior = next;
if (target !== undefined) tarinai.target = target;
if (reason && (!tarinai.thought || tarinai.thought === previous.reason || tarinai.thought === previous.text || tarinai.thought === previous.label)) tarinai.thought = reason;
return tarinai.behavior || next;
}
function setBehaviorFromAction(tarinai, action, choice = {}, reasonText = "", options = {}) {
if (!action) return null;
return setBehavior(tarinai, {
actionId: action.id,
action: action,
need: choice.need || action.need,
subNeed: options.subNeed || action.subNeed || "",
label: action.label || options.actionLabel || "行動している",
text: reasonText || action.label,
reason: reasonText || action.label,
target: options.target !== undefined ? options.target : tarinai?.target,
tiedNeeds: choice.tiedNeeds || [choice.need || action.need],
source: options.forced ? "forced" : (options.source || "need"),
forced: Boolean(options.forced),
forcedId: options.forcedId || "",
sourceReasonText: options.sourceReasonText || "",
causeText: options.causeText || "",
forcedRequest: options.forcedRequest || null,
priority: options.priority ?? actionPriority(action),
lockSeconds: options.lockSeconds,
minDuration: options.minDuration,
phase: options.phase || "starting",
specId: action.id,
specKind: action.kind || "need_action",
});
}
function clearBehavior(tarinai, reason = "") {
if (!tarinai) return;
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(tarinai, { reason });
else tarinai.behavior = null;
if (reason) tarinai.thought = reason;
}
function queueForcedTarinaiBehavior(tarinai, actionId, opts = {}) {
if (!tarinai || tarinai.dead || !actionId) return false;
const entry = normalizeForcedBehaviorEntry(tarinai, actionId, opts);
let queue = forcedBehaviorQueueOf(tarinai);
if (entry.replaceSameSource) queue = queue.filter(e => !(e && e.id === entry.id && e.source === entry.source));
queue.push(entry);
queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0)));
setForcedBehaviorQueue(tarinai, queue.slice(0, 12));
if (opts.target) tarinai.target = opts.target;
if (opts.applyShock && typeof applyNeedShock === "function") applyNeedShock(tarinai, opts.applyShock, opts.target || null);
return true;
}
function completeForcedBehavior(tarinai, behavior = null, status = "completed") {
if (!tarinai) return false;
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const forcedId = b.forcedId || "";
if (!forcedId) return false;
const queue = forcedBehaviorQueueOf(tarinai);
const before = queue.length;
const next = queue.filter(entry => entry && entry.uid !== forcedId);
setForcedBehaviorQueue(tarinai, next);
if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return before !== next.length;
}
function retryForcedBehavior(tarinai, behavior = null, reason = "retry") {
if (!tarinai) return false;
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const forcedId = b.forcedId || "";
if (!forcedId) return false;
const now = tarinai.world?.time || 0;
const entry = forcedBehaviorQueueOf(tarinai).find(e => e && e.uid === forcedId);
if (!entry) return false;
entry.status = "queued";
entry.lastFailedAt = now;
entry.failReason = reason;
entry.activeSince = 0;
if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return true;
}
function processForcedBehaviorQueue(tarinai, world, needs) {
if (!tarinai || tarinai.dead) return false;
const now = world?.time || 0;
let queue = forcedBehaviorQueueOf(tarinai).filter(entry => entry && (!Number.isFinite(entry.expiresAt) || entry.expiresAt >= now));
if (!queue.length) {
setForcedBehaviorQueue(tarinai, []);
if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return false;
}
// \u5b9f\u884c\u4e2d\u306e\u5f37\u5236\u884c\u52d5\u306f\u30ad\u30e5\u30fc\u306b\u6b8b\u3057\u3001\u5b8c\u4e86/\u5931\u6557/\u671f\u9650\u5207\u308c\u307e\u3067\u52b9\u679c\u3092\u4fdd\u6301\u3059\u308b\u3002
const activeQueueBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior;
const activeForcedId = activeQueueBehavior?.forcedId || "";
for (const entry of queue) {
if (!entry.uid) entry.uid = `${entry.id}:${entry.source || "external"}:${entry.createdAt || now}:${Math.random().toString(36).slice(2, 8)}`;
if (entry.uid === activeForcedId && behaviorIsForced(activeQueueBehavior) && activeQueueBehavior?.actionId === entry.id) {
markForcedBehaviorActive(tarinai, entry, actionById(entry.id));
} else if (entry.status === "active") {
// behavior \u304c\u5916\u90e8\u8981\u56e0\u3067\u6d88\u3048\u305f\u5834\u5408\u306f\u3001\u671f\u9650\u5185\u306a\u3089\u518d\u8a66\u884c\u3078\u623b\u3059\u3002
entry.status = "queued";
entry.lastFailedAt = now;
}
}
queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0)));
setForcedBehaviorQueue(tarinai, queue);
const activePriority = Number(activeQueueBehavior?.priority ?? actionPriority(activeQueueBehavior?.actionId)) || 0;
const activeForced = behaviorIsForced(activeQueueBehavior);
for (let i = 0; i < queue.length; i++) {
const entry = queue[i];
const action = actionById(entry.id);
if (!action) { queue.splice(i, 1); i--; continue; }
if (entry.status === "active" && activeQueueBehavior?.forcedId === entry.uid) continue;
const entryPriority = Number(entry.priority ?? actionPriority(entry.id)) || 0;
if (activeQueueBehavior && (tarinai.behaviorLockTimer || 0) > 0.08) {
if (activeForced && entryPriority <= activePriority + 8) continue;
if (!entry.interrupt && entryPriority <= activePriority) continue;
if (entryPriority <= activePriority && !entry.force) continue;
}
if (now - Number(entry.lastTriedAt || -999) < Number(entry.retryDelay || 0.25)) continue;
entry.lastTriedAt = now;
entry.attempts = (Number(entry.attempts || 0) || 0) + 1;
const target = resolveForcedBehaviorTarget(tarinai, world, entry, action);
if (target) {
tarinai.target = target;
entry.targetId = behaviorTargetId(target);
}
if ((action.id === "fight_rival" || action.id === "intimidate_enemy" || action.id === "approach_mate" || action.id === "birth_ritual") && !target) {
continue;
}
const need = action.need || entry.need || "safety";
const choice = { need, tiedNeeds: [need], max: Math.max(needThreshold(need, "start"), Number(needs?.[need] || 0) || 0) };
const causeText = entry.causeText || forcedCausePhrase(entry.source || "", entry.reasonText || "");
const reasonText = buildReasonText(need, choice.tiedNeeds, action, tarinai, world, { causeText });
if (activeQueueBehavior && entryPriority > activePriority) {
retryForcedBehavior(tarinai, activeQueueBehavior, "interrupted_by_higher_forced_behavior");
clearBehavior(tarinai);
tarinai.behaviorLockTimer = 0;
if (tarinai.state === "sleep" || tarinai.sleeping) {
tarinai.sleeping = false;
tarinai.sleepSession = null;
}
}
const started = startNeedAction(tarinai, world, choice, action, reasonText, {
forced: true,
forcedId: entry.uid,
forcedReasonText: entry.reasonText || reasonText,
sourceReasonText: entry.reasonText || reasonText,
causeText,
priority: entryPriority,
source: entry.source,
target,
forcedRequest: { ...entry, status: "active", reason: entry.reasonText || reasonText, causeText },
lockSeconds: entry.duration || undefined,
minDuration: entry.minDuration || undefined,
});
if (started) {
markForcedBehaviorActive(tarinai, entry, action);
setForcedBehaviorQueue(tarinai, queue);
return true;
}
}
setForcedBehaviorQueue(tarinai, queue);
return false;
}
function setBehaviorText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, reason = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null, forcedId = "", sourceReasonText = "", forcedReasonText = "", causeText = "" } = {}) {
if (!tarinai) return;
const now = tarinai.world?.time || 0;
const previousBehavior = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const previousActionId = previousBehavior.actionId || "";
const nextNeed = need || previousBehavior.need || "social";
const nextActionId = actionId || previousActionId || tarinai.state || "idle";
const displayReason = reasonText ?? reason;
const nextActionLabel = actionLabel || displayReason || previousBehavior.label || tarinai.state || "行動している";
const action = actionById(nextActionId);
const previousForced = behaviorIsForced(previousBehavior);
const preserveForced = Boolean(previousForced && source === "state" && (!actionId || previousActionId === nextActionId));
let nextReasonText = displayReason || nextActionLabel;
const rawNeedsForText = tarinai.needRaw || tarinai.needs || {};
const existingTiedNeeds = (Array.isArray(previousBehavior.tiedNeeds) && previousBehavior.tiedNeeds.length ? [...previousBehavior.tiedNeeds] : [nextNeed])
.filter((key, idx) => idx === 0 || Number(rawNeedsForText?.[key] || 0) > 0.5);
if (existingTiedNeeds.some(key => key && key !== nextNeed) && !String(nextReasonText).includes("けど、")) {
nextReasonText = buildReasonText(nextNeed, existingTiedNeeds, { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null);
}
const nextForced = forced ?? preserveForced;
const nextSource = nextForced ? "forced" : (source || previousBehavior.source || "state");
const nextForcedId = forcedId || previousBehavior.forcedId || "";
const nextSourceReason = sourceReasonText || forcedReasonText || previousBehavior.sourceReasonText || "";
const nextCauseText = causeText || previousBehavior.causeText || "";
if (nextCauseText) {
nextReasonText = buildReasonText(nextNeed, existingTiedNeeds.length ? existingTiedNeeds : [nextNeed], { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null, { causeText: nextCauseText });
}
if (target !== undefined) tarinai.target = target;
setBehavior(tarinai, {
actionId: nextActionId,
action,
need: nextNeed,
subNeed: subNeed || action?.subNeed || previousBehavior.subNeed || "",
label: nextActionLabel,
text: nextReasonText,
reason: nextReasonText,
target: target !== undefined ? target : tarinai.target,
tiedNeeds: existingTiedNeeds,
source: nextSource,
sourceReasonText: nextSourceReason,
causeText: nextCauseText,
forced: nextForced,
forcedId: nextForcedId,
forcedRequest: previousBehavior.forcedRequest || null,
priority: previousBehavior.priority ?? actionPriority(action),
phase,
startedAt: previousBehavior.startedAt || now,
minDuration: previousBehavior.minDuration || 0.8,
lockSeconds: previousBehavior.lockSeconds || 1.0,
});
}
function synchronizeActionTextFromState(tarinai) {
if (!tarinai || tarinai.dead) return;
const state = String(tarinai.state || "");
const catalogText = typeof window !== "undefined" && window.TEXT_CATALOG?.reasonLabel ? window.TEXT_CATALOG.reasonLabel(tarinai) : "";
if (state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) {
const partner = tarinai.world?.tarinai?.find?.(o => o && !o.dead && o.id === tarinai.birthPartnerId) || tarinai.target || null;
const label = partner?.name ? `${partner.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
setBehaviorText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b", reason: label, target: partner });
return;
}
if (state === "eat" || (tarinai.eatTimer || 0) > 0.04 || (tarinai.grassEatTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reason: catalogText || "\u98df\u3079\u3066\u3044\u308b" });
return;
}
if (state === "fight" || (tarinai.fightTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reason: catalogText || "\u55a7\u5629\u3057\u3066\u3044\u308b" });
return;
}
if (state === "panic" || (tarinai.defeatedTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "\u9003\u3052\u3066\u3044\u308b", reason: catalogText || "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b" });
return;
}
if (state === "intimidate" || (tarinai.intimidateTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: catalogText || "\u5a01\u5687\u3057\u3066\u3044\u308b" });
return;
}
if (state === "sleep" || tarinai.sleeping) {
setBehaviorText(tarinai, { need: "sleep", actionId: (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(tarinai) : tarinai.behavior?.actionId) || "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reason: catalogText || "\u7720\u3063\u3066\u3044\u308b" });
}
}

View file

@ -118,26 +118,11 @@
play: clamp((base.play || 1) * (1 + e.openness * 0.45), 0.45, 2.10),
};
},
personalityLabel() {
const tags = getPersonalityTraitTags(this);
return tags.length ? tags.join(" / ") : "\u4e2d\u7acb";
},
personalityTags() { return getPersonalityTraitTags(this); },
adjustPersonality(key, delta, reason = "") { return adjustPersonality(this, key, delta, reason); },
shouldApplyPersonalityBehavior(key, direction = 1) { return shouldApplyPersonalityBehavior(this, key, direction); },
fightWinCount() {
return this.totalFightWins || 0;
},
fightLossCount() {
return this.totalFightLosses || 0;
},
maybeBecomeTimidAfterDamage(cause = "") {
if (this.dead) return false;
const losses = this.totalFightLosses || 0;
@ -166,14 +151,14 @@
rel.fightsWon = rel.fightsWon || 0;
rel.fightsLost = rel.fightsLost || 0;
if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; }
this.relationCache = null;
},
strongestRelation(kind = "friend", liveOnly = true) {
let bestId = null;
let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD;
const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null;
for (const [id, rel] of Object.entries(this.relationships || {})) {
if (liveIds && !liveIds.has(id)) continue;
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0);
if (score > bestScore) { bestId = id; bestScore = score; }
}
@ -187,13 +172,16 @@
},
currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) {
const now = this.world?.time || 0;
const cacheKey = `${minScore}:${liveOnly ? 1 : 0}`;
if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount;
let count = 0;
const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null;
for (const [id, rel] of Object.entries(this.relationships || {})) {
if ((rel.affinity || 0) <= minScore) continue;
if (liveIds && !liveIds.has(id)) continue;
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
count += 1;
}
this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 };
return count;
},
@ -203,16 +191,23 @@
},
bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) {
const now = this.world?.time || 0;
const cacheKey = `${minScore}:${Number.isFinite(maxDist) ? Math.round(maxDist) : "inf"}`;
if (this.relationCache?.bestFriendKey === cacheKey && now < (this.relationCache.bestFriendUntil || 0)) {
const cached = this.world?.liveTarinaiById?.(this.relationCache.bestFriendId);
if (cached && (!Number.isFinite(maxDist) || dist(this, cached) <= maxDist)) return cached;
}
let best = null, bestScore = minScore;
for (const [id, rel] of Object.entries(this.relationships || {})) {
const score = rel.affinity || 0;
if (score <= bestScore) continue;
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
const t = this.world.liveTarinaiById?.(id) || null;
if (!t) continue;
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
best = t;
bestScore = score;
}
this.relationCache = { ...(this.relationCache || {}), bestFriendKey: cacheKey, bestFriendId: best?.id || "", bestFriendUntil: now + 1.0 };
return best;
},
@ -223,7 +218,7 @@
const when = rel.lastTime || -Infinity;
if (this.world.time - when > maxAge) continue;
if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
const t = this.world.liveTarinaiById?.(id) || null;
if (!t) continue;
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
if (when > bestT) { best = t; bestT = when; }
@ -262,16 +257,13 @@
const before = this.stress || 0;
if (this.needs && typeof calculateStressFromNeeds === "function") {
const nextSafety = typeof quantizeNeed === "function" ? quantizeNeed((this.needs.safety || 0) + shock) : Math.min(100, (this.needs.safety || 0) + shock);
this.stress = calculateStressFromNeeds({ ...this.needs, safety: nextSafety });
this.stress = typeof applyGroundStressModifier === "function" ? applyGroundStressModifier(this, calculateStressFromNeeds({ ...this.needs, safety: nextSafety })) : calculateStressFromNeeds({ ...this.needs, safety: nextSafety });
}
const gained = Math.max(delta, Math.max(0, (this.stress || 0) - before));
const threshold = opts.threshold ?? 8;
if (gained >= threshold) this.showStressBar(opts.duration ?? 4.2);
return gained;
},
damageCauseLabel(reason = "") { return HEALTH.causeLabel(reason); },
recentDamageCause(maxAge = 10) { return HEALTH.recentDamageCause(this, maxAge); },
rememberDamage(amount, reason = "") { return HEALTH.rememberDamage(this, amount, reason); },
@ -308,22 +300,6 @@
familyJoined() {
return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length);
},
nearestCoParent(maxRange = 220) {
let best = null;
let bestD = maxRange;
for (const o of this.world.tarinai || []) {
if (!o || o === this || o.dead) continue;
if (!(this.world.areCoParents?.(this, o))) continue;
const d = dist(this, o);
if (d < bestD) {
best = o;
bestD = d;
}
}
return best;
},
displayGeneration() {
return this.familyJoined() ? this.generation : null;
},
@ -371,7 +347,7 @@
this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 8;
this.target = null;
this.goIdle?.("柵に阻まれて行き先を変えている");
this.goIdle?.("\u67f5\u306b\u963b\u307e\u308c\u3066\u884c\u304d\u5148\u3092\u5909\u3048\u3066\u3044\u308b");
this.wanderAngle += rand(-1.4, 1.4);
return;
}
@ -379,7 +355,7 @@
this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 12;
this.target = null;
this.goIdle?.("辿り着けず、あきらめた");
this.goIdle?.("\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f");
this.wanderAngle += rand(-2.2, 2.2);
}
},

View file

@ -4,7 +4,7 @@
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_item_effects.js");
const EFFECT_REGISTRY = global.TarinaiEffectRegistry || null;
const EFFECT_REGISTRY = global.TarinaiItemRegistry?.effect || null;
const ITEM_EFFECT_LABELS = Object.freeze(EFFECT_REGISTRY?.labels?.() || {});
const ITEM_EFFECT_COLORS = Object.freeze(EFFECT_REGISTRY?.colors?.() || {});
const RANDOM_EFFECT_POOL = Object.freeze(EFFECT_REGISTRY?.randomPool?.() || [
@ -218,14 +218,22 @@
applyFoodHungerRelief(amount = 0) {
const raw = Math.max(0, Number(amount) || 0);
const actual = this.hasItemEffect("laxative") ? raw * (EFFECT_REGISTRY?.modifier?.("laxative", "hungerReliefMultiplier", 0) ?? 0) : raw;
if (actual > 0.2) this.lastAteAt = this.world?.time || 0;
if (actual >= 6) {
const now = this.world?.time || 0;
if (actual > 0.2) this.lastAteAt = now;
if (actual >= 3.5) {
const day = Number(this.world?.config?.dayLength || CONFIG?.dayLength || 120) || 120;
const base = day / 4;
const base = day / 3.65;
const jitter = typeof stableUnit === "function" ? (0.82 + stableUnit(this.familyKey || this.id, "meal-cooldown") * 0.36) : 1;
this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, (this.world?.time || 0) + base * jitter);
this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, now + base * jitter);
this.needSatisfaction = this.needSatisfaction || (typeof createDefaultNeeds === "function" ? createDefaultNeeds() : {});
this.needSatisfaction.food = Math.max(this.needSatisfaction.food || 0, Math.min(88, actual * 1.85));
this.actionCooldowns = this.actionCooldowns || {};
this.actionCooldowns.food = Math.max(this.actionCooldowns.food || 0, 5.2);
}
this.hunger -= actual;
if (actual >= 3.5 && this.hunger < 68 && (typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need) === "food" && !(typeof isTarinaiBehaviorForced === "function" ? isTarinaiBehaviorForced(this) : Boolean(this.behavior?.source === "forced" || this.behavior?.forcedId || this.behavior?.forcedRequest))) {
this.behaviorLockTimer = Math.min(this.behaviorLockTimer || 0, 0.25);
}
return actual;
},
@ -267,7 +275,7 @@
if (!this.world || this.dead) return;
if (this.hasLodgedPinEffect?.("blocksZunchi")) {
this.oshiriByoZunchiStock = Math.min(24, Math.max(0, this.oshiriByoZunchiStock || 0) + 1);
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している";
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b";
return;
}
const side = this.facingDir ? this.facingDir() : (this.facing || 1);
@ -277,10 +285,8 @@
this.poopCount = (this.poopCount || 0) + 1;
this.digest = Math.max(0, (this.digest || 0) - 3.0);
const now = this.world?.time || 0;
if (now >= (this.nextPoopBubbleAt || -999)) {
this.nextPoopBubbleAt = now + 2.2;
this.bubble("\u3076\u308a\u3085\u3063", 1.2, ITEM_EFFECT_COLORS.laxative);
}
this.nextPoopBubbleAt = now + 2.2;
this.bubble("\u3076\u308a\u3085\u3063", 1.2, ITEM_EFFECT_COLORS.laxative, { force: true });
if ((this.world.time || 0) > (this.nextLaxativeLogAt || -999)) {
this.nextLaxativeLogAt = (this.world.time || 0) + 9;
this.world.log?.(`${this.name}\u306f\u4e0b\u5264\u306e\u52b9\u679c\u3067\u305a\u3093\u3061\u3092\u653e\u51fa\u3057\u305f\u3002`, "accident", { participants: [this] });

View file

@ -0,0 +1,344 @@
"use strict";
// Food/item/structure targeting helpers for Tarinai need actions.
function effectiveFoodTypeForItem(item) {
if (!item) return "";
return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || "");
}
function isDuplicatorFoodSource(item) {
return !!item && item.type === "duplicator";
}
const NORMAL_POOP_MEAL_THRESHOLD = 3;
function normalPoopUnitsFor(foodType = "", amount = 0, options = {}) {
const type = String(foodType || "");
if (type === "laxative" || options.laxative === true) return 0;
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 1;
const value = Math.max(0, Number(amount) || 0);
return value / 18;
}
function effectiveFoodRoleMatches(item, role) {
const type = effectiveFoodTypeForItem(item);
if (!type) return false;
if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type));
if (role === "drink") return type === "water" || type === "water_bowl" || type === "zunda_juice";
if (role === "medicine") return type === "sweet" || type === "water" || type === "water_bowl" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type));
return false;
}
function roleMatches(item, role) {
if (!item || item.dead) return false;
if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false;
if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role);
if (item.roles?.[role]) return true;
if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role);
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box";
if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(item.type);
if (role === "grassMaterial") return item.type === "grass";
return false;
}
function foodInteractionPoint(item) {
if (!item) return { x: 0, y: 0 };
if (item.type === "duplicator") return { x: item.x + (item.r || 34) * 0.62, y: item.y + (item.r || 34) * 0.24 };
return { x: item.x, y: item.y };
}
function foodInteractionDistance(tarinai, item) {
const p = foodInteractionPoint(item);
const mouth = tarinai?.mouthPosition?.(item) || { x: tarinai?.x || 0, y: tarinai?.y || 0 };
return distXY(mouth.x, mouth.y, p.x, p.y);
}
function foodInteractionReach(tarinai, item, role = "food") {
if (item?.type === "duplicator") return Math.max(14, (tarinai?.radius || 20) * 0.44 + 5);
if (role === "drink") return Math.max(16, (tarinai?.radius || 20) * 0.58 + (item?.r || 12) * 0.40);
return Math.max(13, (tarinai?.radius || 20) * 0.50 + (item?.r || 12) * 0.28);
}
function foodPriorityRank(tarinai, item) {
const type = effectiveFoodTypeForItem(item);
if (!type) return -1;
const need = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need;
const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0;
const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0;
if (type === "sweet") return 4100 + Math.max(0, healthNeed - 40) * 6;
if (type === "water" || type === "water_bowl") return 3650 + Math.max(0, healthNeed - 42) * 7;
if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6;
if (type === "love_mochi") return 3300 + Math.max(0, socialNeed - 45) * 7;
if (type === "fight_mochi") return 2600 + Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 4;
if (type === "sleep_drug") return need === "sleep" ? 3600 : 2450;
if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return 3000 + Math.max(0, healthNeed - 45) * 5;
if (type === "grass") {
const hunger = Number(tarinai?.hunger || 0) || 0;
const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0;
return 2000 + Math.max(0, hunger - 64) * 18 + Math.max(0, foodNeed - 58) * 12;
}
if (type === "zunchi") return 1000 + (tarinai?.isZunchiSlave ? 250 : 0);
if (type === "food" || type === "ant_corpse") return 3000;
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 3000;
return -1;
}
function foodPriorityScore(tarinai, item, distance = 0) {
let rank = foodPriorityRank(tarinai, item);
if (rank < 0) return -Infinity;
const hunger = Number(tarinai?.hunger || 0) || 0;
if (item?.type === "zunchi") {
if (zunchiEatingBlocked(tarinai, item, tarinai?.world)) return -Infinity;
if (!tarinai?.isZunchiSlave) rank -= 1400;
if (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 104) rank -= 1200;
}
if (item?.type === "duplicator") rank += 85;
return rank - Math.max(0, Number(distance) || 0) * 0.65;
}
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
if (!tarinai || !world) return false;
for (const it of world.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world.items || []) {
if (!it || it.dead || it.type === "zunchi") continue;
if (tarinai.shouldAvoidTarget?.(it)) continue;
const type = effectiveFoodTypeForItem(it);
if (!type) continue;
if (["sweet", "food", "grass", "water_bowl", "zunda_juice", "ant_corpse"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type))) {
if (it.type === "grass" && (it.amount || 0) <= 0) continue;
if (typeof tarinai.foodItemHasServingLeft === "function" && ["sweet", "food", "zunda_juice"].includes(type) && !tarinai.foodItemHasServingLeft(it)) continue;
return true;
}
}
return false;
}
function zunchiEatingBlocked(tarinai, item, world) {
if (!tarinai || !item || item.type !== "zunchi") return false;
const itemAge = Number(item.age || 0) || 0;
const worldAge = Number.isFinite(item.producedAt) ? Math.max(0, (world?.time || 0) - item.producedAt) : itemAge;
const protectedAge = Math.max(itemAge, worldAge);
const protectedUntil = Number(item.eatProtectedUntil || 0) || 0;
if (protectedUntil > 0 && (world?.time || 0) < protectedUntil) return true;
const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
const hunger = Number(tarinai.hunger || 0) || 0;
// 排泄直後のずんちは観察・接触対象であり、通常食として即消費させない。
// ずんちどれいも数秒は待つ。通常個体は極限飢餓でも最低90秒は食べない。
const emergency = hunger >= 104 || foodNeed >= 94;
const grace = tarinai.isZunchiSlave ? (emergency ? 4 : 18) : (emergency ? 20 : 90);
if (protectedAge < grace) return true;
if (!emergency && !tarinai.isZunchiSlave && item.stage === "fresh" && protectedAge < 150 && foodNeed < 94 && hunger < 104) return true;
return false;
}
function findNearestItemWithRole(world, tarinai, role, maxDist = 520) {
let best = null, bestScore = Infinity;
const hunger = Number(tarinai?.hunger || 0) || 0;
const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0;
const foodEmergency = role === "food" && (hunger >= 96 || foodNeed >= 84);
const searchDist = foodEmergency ? Math.max(maxDist, 1180) : maxDist;
const primary = ((role === "food" || role === "drink" || role === "medicine") && world?.nearbyFood)
? world.nearbyFood(tarinai.x, tarinai.y, searchDist, true)
: (world?.nearbyItems?.(tarinai.x, tarinai.y, searchDist, true) || world?.items || []);
const candidates = Array.isArray(primary) ? primary.slice() : [];
if (foodEmergency && world?.itemsOfType) {
for (const type of ["food", "sweet", "grass", "zunchi", "ant_corpse", "zunda_juice", "duplicator"]) {
for (const it of world.itemsOfType(type) || []) {
if (it && !it.dead && !candidates.includes(it)) candidates.push(it);
}
}
}
for (const item of candidates) {
if (!roleMatches(item, role)) continue;
if (tarinai?.shouldAvoidTarget?.(item)) continue;
const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item);
if (d > searchDist) continue;
let score = d;
if (role === "food" || role === "medicine") {
const priority = foodPriorityScore(tarinai, item, d);
if (!Number.isFinite(priority)) continue;
score = -priority;
}
if (score < bestScore) { best = item; bestScore = score; }
}
return best;
}
function sleepPlaceAvailableFor(world, tarinai, item) {
if (!world || !tarinai || !item || item.dead) return false;
if (!(item.type === "nest_box" || item.type === "bed")) return false;
if (tarinai.shouldAvoidTarget?.(item)) return false;
const capacity = item.type === "nest_box"
? (world.nestBoxCapacity ? world.nestBoxCapacity(item) : 5)
: 1;
const occupancy = item.type === "nest_box"
? (world.nestBoxOccupants ? world.nestBoxOccupants(item, Infinity).length : world.bedOccupancy?.(item) || 0)
: (world.bedOccupancy?.(item) || 0);
return occupancy < capacity;
}
function nearestSleepPlaceOfType(world, tarinai, type, maxDist = 520) {
let best = null;
let bestScore = Infinity;
const list = world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || [];
for (const item of list) {
if (!item || item.dead || item.type !== type) continue;
if (!sleepPlaceAvailableFor(world, tarinai, item)) continue;
const d = dist(tarinai, item);
if (d > maxDist) continue;
const comfort = world?.bedComfort ? world.bedComfort(item) : 1;
const score = d - comfort * 18;
if (score < bestScore) { best = item; bestScore = score; }
}
return best;
}
function findNearestSleepPlace(world, tarinai, maxDist = 520) {
return nearestSleepPlaceOfType(world, tarinai, "nest_box", maxDist)
|| nearestSleepPlaceOfType(world, tarinai, "bed", maxDist)
|| findOwnedStructure(world, tarinai, "grass_bed", maxDist)
|| findUnownedStructure(world, tarinai, "grass_bed", maxDist)
|| null;
}
function findNearbyDanger(world, tarinai, maxDist = 150) {
const ant = (world?.nearbyAnts?.(tarinai.x, tarinai.y, maxDist, true) || [])[0] || null;
const item = findNearestItemWithRole(world, tarinai, "danger", maxDist);
if (ant && item) return distXY(tarinai.x, tarinai.y, ant.x, ant.y) <= dist(tarinai, item) ? ant : item;
return ant || item;
}
function activePanicBreaker(tarinai, world, maxDist = 260) {
const breaker = tarinai?.lastNeedShockBreaker || null;
if (!breaker || !tarinai) return null;
const cause = String(tarinai.lastPanicCause || "");
// Relation/death shocks are intentionally not active dangers. Keeping them as
// breakers made old panic spread and persist after the original event ended.
if (cause === "relation_death" || cause === "pending_relation_death") {
tarinai.lastNeedShockBreaker = null;
return null;
}
if (breaker.dead) {
tarinai.lastNeedShockBreaker = null;
return null;
}
const now = Number(world?.time || 0) || 0;
if (Number.isFinite(tarinai.lastPanicAt) && now - tarinai.lastPanicAt > 8.0) {
tarinai.lastNeedShockBreaker = null;
return null;
}
if (!Number.isFinite(breaker.x) || !Number.isFinite(breaker.y)) return null;
const d = distXY(tarinai.x, tarinai.y, breaker.x, breaker.y);
const isDangerItem = roleMatches(breaker, "danger") || breaker.kind === "ant" || breaker.type === "ant";
const isThreatTarinai = breaker !== tarinai && !breaker.dead && breaker.id && (breaker.fightTimer > 0.04 || breaker.intimidateTimer > 0.04 || breaker.fightMochiTimer > 0.04);
const allowedRange = isDangerItem ? maxDist : (isThreatTarinai ? maxDist * 0.75 : maxDist * 0.45);
if (d <= allowedRange) return breaker;
tarinai.lastNeedShockBreaker = null;
return null;
}
function survivalNeedShouldCancelPanic(tarinai, needs) {
if (!tarinai) return false;
const food = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
const sleep = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0;
const health = Number(needs?.health ?? tarinai.needRaw?.health ?? tarinai.needs?.health ?? 0) || 0;
return food >= 72 || (tarinai.hunger || 0) >= 82 || sleep >= 82 || (tarinai.energy || 100) <= 14 || health >= 88;
}
function evaluateConflictUrge(tarinai, world, maxDist = 96) {
if (!tarinai || tarinai.dead || !world) return null;
if ((tarinai.fightCooldown || 0) > 0.04 || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.postBirthPeaceTimer || 0) > 0.04) return null;
const p = tarinai.personalityProfile?.() || tarinai.currentPersonality || {};
const aggression = Number(tarinai.currentPersonality?.aggression ?? p.fight ?? 0) || 0;
const battleDrug = (tarinai.fightMochiTimer || 0) > 0.04 || tarinai.hasFightMochiEffect?.();
const near = typeof world.nearbyTarinai === "function" ? world.nearbyTarinai(tarinai.x, tarinai.y, maxDist) : (world.tarinai || []);
let best = null;
for (const other of near || []) {
if (!other || other === tarinai || other.dead) continue;
if (!world.canFightPair?.(tarinai, other)) continue;
if ((other.fightCooldown || 0) > 0.04 || (other.postBirthPeaceTimer || 0) > 0.04) continue;
if (world.areParentChild?.(tarinai, other) || world.areCoParents?.(tarinai, other)) continue;
const d = Math.max(1, dist(tarinai, other));
if (d > maxDist) continue;
const rel = tarinai.relationTo?.(other.id) || {};
const relOther = other.relationTo?.(tarinai.id) || {};
const mixedZunchi = !!tarinai.isZunchiSlave !== !!other.isZunchiSlave;
const otherBattleDrug = (other.fightMochiTimer || 0) > 0.04 || other.hasFightMochiEffect?.();
const closePressure = clamp(1 - d / maxDist, 0, 1) * 18;
const stressPressure = clamp(((tarinai.stress || 0) - 52) / 42, 0, 1) * 18;
const angerPressure = (tarinai.type === "angry" ? 12 : 0) + ((other.type === "angry" || otherBattleDrug) ? 8 : 0);
const fearPressure = clamp(((rel.fear || 0) + (relOther.fear || 0) - 22) / 90, 0, 1) * 14;
const affinityBrake = battleDrug ? 0 : clamp(Math.max(rel.affinity || 0, relOther.affinity || 0) / 80, 0, 1) * 26;
const calmBrake = tarinai.shouldApplyPersonalityBehavior?.("aggression", -1) ? 22 : 0;
const drugBonus = battleDrug ? 56 : 0;
const zunchiBonus = mixedZunchi ? 14 : 0;
const personalityBonus = clamp(aggression, -1, 1) * 10;
const score = closePressure + stressPressure + angerPressure + fearPressure + drugBonus + zunchiBonus + personalityBonus - affinityBrake - calmBrake;
if (!best || score > best.score) {
best = {
target: other,
score,
forced: !!battleDrug || !!otherBattleDrug,
defensive: mixedZunchi || (rel.fear || 0) > 35,
reason: mixedZunchi ? "\u8fd1\u304f\u306e\u76f8\u624b\u304c\u843d\u3061\u7740\u304b\u306a\u3044" : battleDrug ? "\u304d\u305a\u3064\u304d\u9905\u3067\u6c17\u304c\u7acb\u3063\u3066\u3044\u308b" : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u8fd1\u3044",
};
}
}
return best && best.score > (best.forced ? 4 : 12) ? best : null;
}
function conflictTargetFor(tarinai, world, minScore = 34) {
const evaluated = evaluateConflictUrge(tarinai, world, 118);
if (evaluated && evaluated.score >= minScore) return evaluated;
const id = tarinai?.conflictTargetId || "";
const target = id ? world?.liveTarinaiById?.(id) : null;
if (!target || !world?.canFightPair?.(tarinai, target)) return null;
const d = dist(tarinai, target);
const carried = Math.max(0, Number(tarinai.conflictUrge || 0) || 0) - Math.max(0, d - 70) * 0.28;
return carried >= minScore ? { target, score: carried, forced: (tarinai.fightMochiTimer || 0) > 0.04, defensive: false, reason: "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b" } : null;
}
function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) {
return findNearestItemWithRole(world, tarinai, materialRole, maxDist);
}
function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) {
let best = null, bestD = maxDist;
for (const item of world?.items || []) {
if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue;
if (type && item.type !== type) continue;
const d = dist(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) {
let best = null, bestD = maxDist;
for (const item of world?.items || []) {
if (!item?.isStructure || item.dead || item.ownerId) continue;
if (type && item.type !== type) continue;
if (tarinai?.shouldAvoidTarget?.(item)) continue;
const d = dist(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function consumeGrassMaterial(material, stages = 1) {
if (!material) return false;
const steps = Math.max(1, Math.floor(Number(stages) || 1));
const consumed = window.TarinaiGrass?.regress?.(material, steps) ?? 0;
if (!(consumed > 0)) return false;
material.eatenAmount = (material.eatenAmount || 0) + consumed;
material.world?.markTerrainDirty?.("grass-material-consumed");
return true;
}
function moveToOrUse(t, target, state = "seek_food", reason = "") {
if (!target) return false;
t.setActionState?.(state, { target, reason, sleeping: false });
return true;
}

298
js/tarinai_needs_core.js Normal file
View file

@ -0,0 +1,298 @@
"use strict";
const TARINAI_NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
const TARINAI_NEED_PRIORITY = ["safety", "health", "food", "sleep", "social", "fulfill"];
const TARINAI_NEED_LABELS = {
food: "\u6442\u990c",
sleep: "\u7761\u7720",
health: "\u5065\u5eb7",
safety: "\u5b89\u5168",
social: "\u95a2\u4fc2",
fulfill: "\u5145\u8db3",
};
const TARINAI_NEED_PHRASES = {
food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b",
sleep: "\u306d\u3080\u3044",
health: "\u8abf\u5b50\u304c\u60aa\u3044",
safety: "\u3042\u3076\u306a\u3044",
social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b",
fulfill: "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044",
};
const TARINAI_NEED_THRESHOLDS = {
food: { start: 64, continue: 20 },
sleep: { start: 62, continue: 30 },
health: { start: 54, continue: 32 },
safety: { start: 42, continue: 20 },
social: { start: 52, continue: 30 },
fulfill: { start: 48, continue: 26 },
};
const TARINAI_NEED_SATISFACTION_DECAY = {
food: 0.85,
sleep: 0.75,
health: 0.65,
safety: 1.10,
social: 1.05,
fulfill: 1.15,
};
const TARINAI_ACTION_LOCK_SECONDS = {
eat_food: 0.85,
drink_water: 1.8,
sleep_in_bed: 18.0,
sleep_build_grass_bed: 10.0,
sleep_anywhere: 12.0,
use_medicine: 2.4,
rest_to_recover: 6.0,
sunbath: 10.5,
panic_escape: 2.1,
flee: 1.7,
hide_at_owned_structure: 6.4,
approach_friend: 6.4,
approach_parent_or_child: 7.0,
approach_mate: 5.6,
fight_rival: 3.6,
intimidate_enemy: 1.15,
play: 8.2,
build_grass_bed: 10.0,
build_plushie: 10.0,
return_owned_structure: 6.4,
use_plushie: 7.4,
wander_lightly: 4.2,
};
const TARINAI_ACTION_PRIORITY = {
fight_rival: 56,
intimidate_enemy: 42,
panic_escape: 94,
flee: 92,
hide_at_owned_structure: 90,
use_medicine: 86,
rest_to_recover: 84,
sunbath: 91,
sleep_in_bed: 86,
sleep_build_grass_bed: 85,
sleep_anywhere: 84,
birth_ritual: 72,
approach_mate: 68,
approach_parent_or_child: 66,
approach_friend: 66,
eat_food: 50,
drink_water: 48,
build_grass_bed: 30,
build_plushie: 28,
use_plushie: 24,
return_owned_structure: 22,
play: 20,
wander_lightly: 10,
};
function actionPriority(actionOrId) {
const id = typeof actionOrId === "string" ? actionOrId : actionOrId?.id;
if (!id) return 0;
return Number(TARINAI_ACTION_PRIORITY[id] ?? actionOrId?.priority ?? 0) || 0;
}
const TARINAI_UNINTERRUPTIBLE_STATES = ["panic", "intimidate", "fight", "birth_ritual"];
const TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY = {
fight_mochi: 180,
love_mochi: 150,
sleep_drug: 145,
drug: 140,
user: 200,
external: 130,
};
function createDefaultNeeds() {
return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
}
function quantizeNeed(value) {
return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10));
}
function getNeedDisplayValue(value) {
return Math.max(0, Math.min(10, Math.round((Number(value) || 0) / 10)));
}
function needPressure(value) {
const x = Math.max(0, Math.min(100, Number(value) || 0)) / 100;
return x * x * 100;
}
function groundStressMultiplierFor(tarinai) {
const mult = Number(tarinai?.world?.groundStressMultiplier?.());
return Number.isFinite(mult) && mult > 0 ? mult : 1;
}
function applyGroundStressModifier(tarinai, nextStress) {
const next = clamp(Number(nextStress) || 0, 0, 130);
const before = Number(tarinai?.stress);
if (!Number.isFinite(before) || next <= before) return next;
return clamp(before + (next - before) * groundStressMultiplierFor(tarinai), 0, 130);
}
function calculateStressFromNeeds(needs) {
const weightedLinear =
(Number(needs.food) || 0) * 0.09 +
(Number(needs.sleep) || 0) * 0.11 +
(Number(needs.health) || 0) * 0.22 +
(Number(needs.safety) || 0) * 0.26 +
(Number(needs.social) || 0) * 0.07 +
(Number(needs.fulfill) || 0) * 0.13;
const peakNeed = Math.max(0, ...TARINAI_NEED_KEYS.map(key => Number(needs?.[key] || 0) || 0));
const peakPressure = Math.max(0, peakNeed - 62) * 0.10;
const quadraticPressure =
needPressure(needs.food) * 0.10 +
needPressure(needs.sleep) * 0.12 +
needPressure(needs.health) * 0.25 +
needPressure(needs.safety) * 0.30 +
needPressure(needs.social) * 0.08 +
needPressure(needs.fulfill) * 0.15;
return clamp(
weightedLinear +
peakPressure +
quadraticPressure * 0.35,
0,
130
);
}
function applyNeedShock(tarinai, deltas = {}, breaker = null) {
if (!tarinai || tarinai.dead) return false;
tarinai.needShock = tarinai.needShock || {};
for (const key of TARINAI_NEED_KEYS) {
const delta = Number(deltas[key] || 0) || 0;
if (delta > 0) tarinai.needShock[key] = Math.max(tarinai.needShock[key] || 0, delta);
else if (delta < 0) tarinai.needShock[key] = Math.min(tarinai.needShock[key] || 0, delta);
}
if (breaker) tarinai.lastNeedShockBreaker = breaker;
if (tarinai.needs && typeof calculateStressFromNeeds === "function") {
const projected = { ...tarinai.needs };
for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock[key] || 0));
tarinai.stress = applyGroundStressModifier(tarinai, calculateStressFromNeeds(projected));
}
return true;
}
function applyNeedSatisfaction(tarinai, deltas = {}, source = "") {
if (!tarinai || tarinai.dead) return false;
tarinai.needSatisfaction = tarinai.needSatisfaction || createDefaultNeeds();
tarinai.lastSatisfiedAt = tarinai.lastSatisfiedAt || {};
const now = tarinai.world?.time || 0;
let changed = false;
for (const key of TARINAI_NEED_KEYS) {
const amount = Math.max(0, Number(deltas[key] || 0) || 0);
if (amount <= 0) continue;
tarinai.needSatisfaction[key] = clamp((tarinai.needSatisfaction[key] || 0) + amount, 0, 88);
tarinai.lastSatisfiedAt[key] = now;
changed = true;
}
if (source) tarinai.lastSatisfactionSource = source;
return changed;
}
function applyNeedRelief(tarinai, deltas = {}) {
if (!tarinai || tarinai.dead) return false;
const shockDeltas = {};
const satisfactionDeltas = {};
let hasShock = false;
let hasSatisfaction = false;
for (const key of TARINAI_NEED_KEYS) {
const delta = Number(deltas[key] || 0) || 0;
if (delta < 0) {
satisfactionDeltas[key] = -delta;
hasSatisfaction = true;
} else if (delta > 0) {
shockDeltas[key] = delta;
hasShock = true;
}
}
if (hasSatisfaction) applyNeedSatisfaction(tarinai, satisfactionDeltas, "relief");
if (hasShock) applyNeedShock(tarinai, shockDeltas, null);
return hasSatisfaction || hasShock;
}
function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") {
let actions = null;
try { actions = TARINAI_ACTIONS; } catch (err) { actions = null; }
if (!tarinai || tarinai.dead || !Array.isArray(actions)) return false;
const world = tarinai.world || globalThis.world || null;
const aggression = Number(tarinai.currentPersonality?.aggression || tarinai.personalityProfile?.().fight || 0);
const fightLikeTarget = breaker && breaker.type !== "firecracker" && breaker.kind !== "ant" && breaker.id && world?.canFightPair?.(tarinai, breaker);
const fightAction = actions.find(a => a.id === "fight_rival");
if (fightLikeTarget && aggression > 0.62 && fightAction) {
tarinai.conflictTargetId = breaker.id;
tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72);
const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 };
const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}
}
const panicAction = actions.find(a => a.id === "panic_escape") || actions.find(a => a.id === "flee");
if (!panicAction) return false;
const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 };
const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}
return false;
}
function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") {
if (!tarinai || tarinai.dead) return false;
const before = tarinai.previousNeeds || createDefaultNeeds();
const projected = { ...(tarinai.needs || createDefaultNeeds()) };
for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock?.[key] || 0));
const safetyDelta = (projected.safety || 0) - (before.safety || 0);
const healthDelta = (projected.health || 0) - (before.health || 0);
const fulfillDelta = (projected.fulfill || 0) - (before.fulfill || 0);
const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50;
if (!shockTriggered) return false;
if (["panic", "intimidate", "fight", "birth_ritual"].includes(tarinai.state)) return false;
return startNeedDrivenEmergencyReaction(tarinai, breaker, reason);
}
function needThreshold(need, kind = "start") {
return TARINAI_NEED_THRESHOLDS[need]?.[kind] ?? (kind === "continue" ? 30 : 50);
}
function actionLockSeconds(action, tarinai = null) {
const base = Number(action?.lockSeconds ?? TARINAI_ACTION_LOCK_SECONDS[action?.id] ?? 2.0) || 2.0;
const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${action?.id || "idle"}`;
const jitter = typeof stableUnit === "function" ? (stableUnit(key, "action-lock") - 0.5) * 0.18 : (Math.random() - 0.5) * 0.12;
return Math.max(0.5, base * (1 + jitter));
}
function scoreNeedForAction(needs, tarinai, world, need) {
const value = Number(needs?.[need]) || 0;
let score = value;
if (need === "safety") score += 14;
else if (need === "health") score += 8;
else if (need === "food") {
const hunger = Number(tarinai?.hunger || 0) || 0;
score += 3 + Math.max(0, hunger - 70) * 0.55 + Math.max(0, hunger - 92) * 0.95;
}
if ((typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need) === need) score += 10;
const cooldown = Number(tarinai?.actionCooldowns?.[need] || 0) || 0;
if (cooldown > 0) score -= Math.min(22, cooldown * 2.8);
const nowBucket = Math.floor((world?.time || 0) / 8);
const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${need}:${nowBucket}`;
const noise = typeof stableUnit === "function" ? (stableUnit(key, "need-score") - 0.5) * 3.2 : (Math.random() - 0.5) * 2.0;
return score + noise;
}
function chooseTopNeedRandom(needs, tarinai = null, world = null) {
const scores = {};
for (const key of TARINAI_NEED_KEYS) scores[key] = scoreNeedForAction(needs, tarinai, world, key);
const max = Math.max(...TARINAI_NEED_KEYS.map(key => scores[key]));
const tiedNeeds = TARINAI_NEED_KEYS.filter(key => max - scores[key] <= 1.5);
const need = TARINAI_NEED_PRIORITY.find(key => tiedNeeds.includes(key)) || tiedNeeds[0] || "fulfill";
return { need, tiedNeeds, max: Number(needs?.[need]) || 0, score: max, scores };
}

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,194 @@
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_render.js");
function shouldShowTarinaiDecor(tarinai) {
const worldRef = tarinai?.world;
if (!tarinai || !worldRef) return true;
if (worldRef.selected === tarinai || tarinai.favorite) return true;
if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.02 || (tarinai.pokeFlashTimer || 0) > 0.03) return true;
if ((tarinai.focusPulseTimer || 0) > 0.04 || tarinai.state === "cursor_friend" || tarinai.state === "cursor_enemy") return true;
const tier = window.TarinaiPerf?.renderQualityTier?.() || "high";
if (tier === "low") return false;
if (tier === "mid") {
const p = worldRef.pointer;
return Boolean(p?.inside && typeof distXY === "function" && distXY(tarinai.x, tarinai.y, p.x, p.y) < tarinai.radius * 2.0);
}
return true;
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
drawSunbathPose(ctx, t, lighting = null) {
if (this.dead) return true;
const active = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04;
if (!active) return false;
this.preloadSunbathSprites?.();
const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"];
const candidates = (Array.isArray(ids) && ids.length ? ids : ["sunbath_1", "sunbath_2", "sunbath_3"]).filter(id => String(id || "").startsWith("sunbath"));
const ready = (id) => typeof isImageReady === "function"
? isImageReady(id)
: !!(typeof images !== "undefined" && images?.get?.(id)?.complete && (images?.get?.(id)?.naturalWidth || images?.get?.(id)?.width));
const imageFor = (id) => {
if (!id) return null;
if (typeof getRenderableImage === "function") return getRenderableImage(id, "");
return (typeof images !== "undefined" ? images.get?.(id) : globalThis.TarinaiAssets?.images?.get?.(id)) || null;
};
let requested = this.sunbathSpriteId ? this.sunbathSpriteId() : this.normalizeSunbathSpriteId?.(this.sunbathSpriteVariant);
if (!requested || !candidates.includes(requested)) requested = candidates[0] || "sunbath_1";
const locked = this._activeSunbathSpriteId && candidates.includes(this._activeSunbathSpriteId) && ready(this._activeSunbathSpriteId)
? this._activeSunbathSpriteId
: "";
const ordered = [locked, requested, this._lastRenderableSunbathSpriteId, ...candidates].filter(Boolean);
let renderSprite = ordered.find(id => candidates.includes(id) && ready(id));
let img = renderSprite ? imageFor(renderSprite) : null;
if (img && ready(renderSprite)) {
this._activeSunbathSpriteId = renderSprite;
this._lastRenderableSunbathSpriteId = renderSprite;
this.sunbathSpriteVariant = renderSprite;
this.visibleSpriteId = renderSprite;
} else {
const fallbackId = this._lastNonSunbathSpriteId || this.goodMode || this.type || "smile";
renderSprite = fallbackId;
img = typeof getRenderableImage === "function"
? getRenderableImage(fallbackId, "smile")
: (typeof images !== "undefined" ? (images.get?.(fallbackId) || images.get?.("smile")) : null);
if (!img) {
renderSprite = "smile";
img = typeof getRenderableImage === "function"
? getRenderableImage("smile", "smile")
: (typeof images !== "undefined" ? images.get?.("smile") : null);
}
}
const worldW = Math.max(1, Number(this.world?.w || 1000) || 1000);
const worldH = Math.max(1, Number(this.world?.h || 720) || 720);
const pad = Math.max(28, CONFIG.worldPadding || 30);
const lastX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : NaN);
const lastY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : NaN);
if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = Number.isFinite(lastX) ? lastX : (Number.isFinite(this.x) ? this.x : pad);
if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = Number.isFinite(lastY) ? lastY : (Number.isFinite(this.y) ? this.y : pad);
let drawX = this.sunbathAnchorX;
let drawY = this.sunbathAnchorY;
if (!Number.isFinite(drawX)) drawX = Number.isFinite(lastX) ? lastX : pad;
if (!Number.isFinite(drawY)) drawY = Number.isFinite(lastY) ? lastY : pad;
drawX = clamp(drawX, pad, Math.max(pad, worldW - pad));
drawY = clamp(drawY, pad, Math.max(pad, worldH - pad));
this.sunbathAnchorX = drawX;
this.sunbathAnchorY = drawY;
this._lastSunbathDrawX = drawX;
this._lastSunbathDrawY = drawY;
this._lastValidX = drawX;
this._lastValidY = drawY;
if (!Number.isFinite(this.x) || Math.abs(this.x - drawX) > 1.5) this.x = drawX;
if (!Number.isFinite(this.y) || Math.abs(this.y - drawY) > 1.5) this.y = drawY;
const metrics = getImageMetrics(renderSprite) || getImageMetrics(img) || getImageMetrics("smile") || {};
const w = metrics?.w || img?.naturalWidth || img?.width || 512;
const h = metrics?.h || img?.naturalHeight || img?.height || 512;
const visualScale = this.effectiveScale ? this.effectiveScale() : this.scale;
const drawW = w * visualScale * 0.30;
const drawH = h * visualScale * 0.30;
const breathing = Math.sin(t * 1.25 + this.age * 0.18);
const bob = breathing * 0.55;
const faceRight = this.facingDir ? this.facingDir() > 0 : (this.facing || 1) > 0;
const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1;
if (nestHideAlpha <= 0.01) return true;
const lightState = lighting || getLightingState(this.world);
const showDecor = shouldShowTarinaiDecor(this);
if (showDecor) {
const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8);
drawProjectedShadow(ctx, drawX, drawY + drawH * 0.30, drawW * 0.58, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.90 * nestHideAlpha });
}
ctx.save();
ctx.globalAlpha = nestHideAlpha;
ctx.translate(drawX, drawY + bob);
ctx.rotate((faceRight ? 1 : -1) * Math.PI / 28);
if (faceRight) ctx.scale(-1, 1);
if (img) {
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
} else {
ctx.fillStyle = "#f4ead6";
ctx.strokeStyle = "rgba(92,71,49,0.48)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(0, 0, drawW * 0.42, drawH * 0.36, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.restore();
if (showDecor) {
ctx.save();
ctx.globalAlpha = 0.18 * nestHideAlpha;
ctx.fillStyle = lightState.goldenStrength > 0.25 ? "rgba(255, 223, 137, 0.95)" : "rgba(255, 244, 172, 0.82)";
ctx.beginPath();
ctx.arc(drawX - drawW * 0.28, drawY - drawH * 0.35, Math.max(3, drawW * 0.06), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
return true;
},
draw(ctx, t, lighting = null) {
if (this.dead) return;
const sunbathActive = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04;
if (!sunbathActive && Number.isFinite(this.x) && Number.isFinite(this.y)) {
this._lastValidX = this.x;
this._lastValidY = this.y;
}
const lightState = lighting || getLightingState(this.world);
const sprite = this.spriteId();
const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type));
const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile");
if (sunbathActive && this.drawSunbathPose?.(ctx, t, lightState)) return;
if (!sunbathActive && String(this.visibleSpriteId || "").startsWith("sunbath")) {
this.visibleSpriteId = "";
this.spriteLockUntil = 0;
}
const showDecor = shouldShowTarinaiDecor(this);
if (sunbathActive) this.preloadSunbathSprites?.();
let sprite = this.spriteId();
if (!sunbathActive && String(sprite || "").startsWith("sunbath")) {
this.visibleSpriteId = "";
this.spriteLockUntil = 0;
sprite = this.rawSpriteId ? this.rawSpriteId() : (this.goodMode || this.type || "smile");
}
if (!String(sprite || "").startsWith("sunbath")) this._lastNonSunbathSpriteId = sprite;
const sunbathSprite = String(sprite || "").startsWith("sunbath");
const sunbathCandidates = sunbathSprite && this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : [];
if (sunbathSprite) {
for (const id of sunbathCandidates) {
if (typeof ensureImage === "function") ensureImage(id);
else if (globalThis.TarinaiAssets?.ensureImage) globalThis.TarinaiAssets.ensureImage(id);
}
}
const spriteFallback = "smile";
let renderSprite = sprite;
let img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, spriteFallback) : (images.get(sprite) || images.get(spriteFallback) || images.get(this.type));
if (sunbathSprite) {
const ready = (id) => typeof isImageReady === "function" ? isImageReady(id) : !!(images?.get?.(id)?.complete || images?.get?.(id)?.naturalWidth || images?.get?.(id)?.width);
const imageFor = (id) => (typeof images !== "undefined" ? images.get?.(id) : globalThis.TarinaiAssets?.images?.get?.(id)) || null;
if (ready(sprite)) {
this._lastRenderableSunbathSpriteId = sprite;
} else {
const orderedFallbacks = [this._lastRenderableSunbathSpriteId, ...sunbathCandidates].filter(Boolean);
const fallbackSunbath = orderedFallbacks.find(id => ready(id));
if (fallbackSunbath) {
renderSprite = fallbackSunbath;
img = imageFor(fallbackSunbath);
this._lastRenderableSunbathSpriteId = fallbackSunbath;
this.sunbathSpriteVariant = fallbackSunbath;
}
}
if (!img && this._lastRenderableSunbathSpriteId && ready(this._lastRenderableSunbathSpriteId)) {
renderSprite = this._lastRenderableSunbathSpriteId;
img = imageFor(this._lastRenderableSunbathSpriteId);
}
if (!img) img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : (images.get("smile") || images.get(this.type));
}
const metrics = getImageMetrics(renderSprite) || getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile");
const w = metrics?.w || 512;
const h = metrics?.h || 512;
const moveSpeed = Math.hypot(this.vx, this.vy);
@ -63,18 +244,18 @@
const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8);
const shadowContactY = this.y + bob + (drawH / squish) * 0.30;
if (img) {
if (showDecor && img) {
drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, {
faceRight,
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 {
} else if (showDecor) {
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.powerItemMode === "protein" || this.powerItemMode === "niteropu") {
if (showDecor) drawCreatureGlow(ctx, this, drawW, drawH, lightState);
if (showDecor && (this.powerItemMode === "protein" || this.powerItemMode === "niteropu")) {
ctx.save();
const up = this.powerItemMode === "protein";
const baseColor = up ? "rgba(224,54,40,0.92)" : "rgba(116,80,190,0.88)";
@ -106,7 +287,7 @@
ctx.restore();
}
if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) {
const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
const partner = this.world.liveTarinaiById?.(this.birthPartnerId);
const hx = partner ? (this.x + partner.x) / 2 : this.x;
const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60;
const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10;
@ -194,7 +375,7 @@
ctx.textBaseline = "middle";
ctx.fillText(focusLabel, 0, ly);
ctx.restore();
} else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
} else if (showDecor && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
ctx.save();
ctx.globalAlpha = 0.28;
ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)";
@ -231,7 +412,7 @@
ctx.restore();
}
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
if (showDecor && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
ctx.save();
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;
@ -262,7 +443,19 @@
const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8);
const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8);
if (img) {
const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img;
let spriteCacheKey = renderSprite || sprite;
try {
if (typeof images !== "undefined" && images.get?.(renderSprite || sprite) !== img) {
let fallbackId = "fallback";
for (const [imageId, imageObj] of images) {
if (imageObj === img) { fallbackId = imageId; break; }
}
spriteCacheKey = `${renderSprite || sprite}:fallback:${fallbackId}`;
}
} catch (err) {
spriteCacheKey = renderSprite || sprite;
}
const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(spriteCacheKey, img, cacheW, cacheH) : img;
ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh);
} else {
ctx.save();
@ -276,7 +469,7 @@
ctx.restore();
}
if (img && (this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") {
const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5);
const stainCanvas = getCachedStainOverlayCanvas(renderSprite || sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5);
if (stainCanvas) {
ctx.save();
ctx.globalAlpha = clamp((this.zunchiStain || 0) / 100, 0.10, 0.88);
@ -300,16 +493,6 @@
drawDawnRimLight(ctx, dw, dh, lightState);
if (faceRight) ctx.scale(-1, 1);
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) {
ctx.save();
ctx.globalAlpha = 0.80;
ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.fillStyle = "rgba(42,36,29,0.72)";
const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?");
ctx.fillText(mark, 0, -drawH * 0.72);
ctx.restore();
}
ctx.restore();
@ -322,8 +505,10 @@
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;
if (showDecor) {
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)";
@ -334,7 +519,7 @@
const hpActive = (this.hpBarTimer || 0) > 0;
const stressActive = (this.stressBarTimer || 0) > 0;
if (hpActive || stressActive) {
if ((hpActive || stressActive) && (showDecor || this.world.selected === this || this.hurtTimer > 0.04)) {
const bw = Math.max(28, this.radius * 2.35);
const bh = 5;
const x = this.x - bw / 2;

View file

@ -0,0 +1,121 @@
"use strict";
// Social, panic, fight, mate behavior runtime ticks.
function mateTargetFor(t, world, maxDist = 520) {
if (!t || !world) return null;
const current = currentBehaviorTarget(t) || t.target;
const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04;
if (ok(current) && dist(t, current) <= maxDist + 120) return current;
return world.nearestOther?.(t, maxDist, ok) || null;
}
function triggerFriendContact(t, other, world, dt = 0) {
if (!t || !other || !world) return false;
const amount = 0.30 + Math.max(0, Number(dt) || 0) * 0.85;
t.adjustRelation?.(other, amount, -0.05, "friend_contact");
other.adjustRelation?.(t, amount * 0.78, -0.04, "friend_contact");
t.loneliness = clamp((t.loneliness || 0) - 8, 0, 110);
if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -16, fulfill: -3 });
if ((world.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("\u306f\u3046", 2.2, "rgba(92,132,78,0.78)");
t.setActionState?.("idle", { target: other, reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, sleeping: false });
if (typeof setBehaviorText === "function") setBehaviorText(t, { need: "social", subNeed: "bond", actionId: "approach_friend", actionLabel: "\u4ef2\u9593\u3068\u4e00\u7dd2\u306b\u3044\u308b", reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, target: other, phase: "perform", source: "behavior" });
return "finished";
}
function updateSocialContactBehavior(t, world, dt, mode = "bond") {
if (!t || !world) return false;
let other = currentBehaviorTarget(t) || t.target || null;
if (!other || other.dead || other === t) {
other = mode === "family" ? t.parentToFollow?.() : (t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 560) || world.nearestOther?.(t, 520));
}
if (!other || other.dead || other === t) return false;
const near = dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12);
if (!near) return moveToOrUse(t, other, mode === "family" ? "follow_parent" : "seek_friend", mode === "family" ? "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b" : "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b");
const conflict = conflictTargetFor(t, world, 30);
const forcedConflict = (t.fightMochiTimer || 0) > 0.04 || (other.fightMochiTimer || 0) > 0.04;
const socialPartsForContact = t.socialReasonParts || {};
const conflictDominant = Number(socialPartsForContact.conflict || 0) >= Math.max(Number(socialPartsForContact.bond || 0), Number(socialPartsForContact.mate || 0), Number(socialPartsForContact.family || 0)) + 8;
if (forcedConflict || (conflict?.target === other && conflict.score >= 34 && conflictDominant)) {
return updateFightBehavior(t, world, dt) || true;
}
const mate = mateTargetFor(t, world, 150);
const parts = t.socialReasonParts || {};
const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.actionId;
const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.actionId;
const mateIntent = mode === "mate" || selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || (t.loveMochiTimer || 0) > 0.04 || (other.loveMochiTimer || 0) > 0.04;
const mateDominant = Number(parts.mate || 0) >= Math.max(Number(parts.bond || 0), Number(parts.conflict || 0)) + 10;
if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10 && (mateIntent || mateDominant)) {
if (world.startBirthRitual?.(t, other)) return "finished";
}
return triggerFriendContact(t, other, world, dt);
}
function updateMateBehavior(t, world, dt) {
const other = mateTargetFor(t, world, 560);
if (!other) return false;
const d = dist(t, other);
if (d <= Math.max(50, (t.radius || 20) + (other.radius || 20) + 14)) {
const otherWantsMate = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.actionId) === "approach_mate" || (other.loveMochiTimer || 0) > 0.04 || (other.reproductionTimer || 0) <= 4;
const selfForced = isCurrentBehaviorForced(t) || (t.loveMochiTimer || 0) > 0.04;
if ((otherWantsMate || selfForced || (t.reproductionTimer || 0) <= 4) && world.startBirthRitual?.(t, other)) return "finished";
moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b");
if (typeof applyNeedShock === "function") applyNeedShock(other, { social: dt * 10 }, t);
return true;
}
return moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b");
}
function updateFightBehavior(t, world, dt) {
if (!t || !world) return false;
if ((t.fightTimer || 0) > 0.04) {
const rival = currentBehaviorTarget(t) || (t.fightTargetId ? world.liveTarinaiById?.(t.fightTargetId) : null);
t.setActionState?.("fight", { target: rival || t.target, reason: rival?.name ? `${rival.name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b", sleeping: false });
return true;
}
const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t) ? 24 : 42);
const current = currentBehaviorTarget(t);
const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, isCurrentBehaviorForced(t) ? 760 : 360));
if (!isLiveTarinaiEntity(rival)) return false;
t.conflictTargetId = rival.id;
if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u306b\u5411\u304b\u3063\u3066\u3044\u308b");
if (candidate?.forced || isCurrentBehaviorForced(t) || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival);
world.startConflict?.(t, rival);
return true;
}
function updatePanicBehavior(t, world, dt, needs) {
const breakerDanger = activePanicBreaker(t, world, 260);
const nearbyDanger = findNearbyDanger(world, t, 260) || null;
const realDanger = breakerDanger || nearbyDanger || null;
const currentTarget = currentBehaviorTarget(t);
const safety = Number(needs?.safety || 0) || 0;
const now = Number(world?.time || 0) || 0;
if (!Number.isFinite(t.panicStartedAt) || t.state !== "panic") t.panicStartedAt = now;
// Survival needs must be able to break panic. Otherwise a stale fear state
// can suppress eating/sleeping long enough to kill the colony.
if (!realDanger && survivalNeedShouldCancelPanic(t, needs)) {
t.fearTimer = 0;
t.lastNeedShockBreaker = null;
return "finished";
}
const panicAge = Math.max(0, now - (Number(t.panicStartedAt) || now));
const hardStop = Number(t.panicHardStopAt || 0) || 0;
if (!realDanger && (panicAge >= 7.5 || (hardStop > 0 && now >= hardStop))) {
t.fearTimer = 0;
t.lastNeedShockBreaker = null;
return "finished";
}
if (!realDanger && safety < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.12) return "finished";
const danger = realDanger || currentTarget;
if (t.state !== "panic" || !t.target || (t.target.dead && !Number.isFinite(t.target.x))) {
t.setActionState?.("panic", { target: t.panicDestination?.(danger, true), reason: "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b", wake: true, sleeping: false });
}
if (realDanger || (t.defeatedTimer || 0) > 0.04) t.fearTimer = Math.max(t.fearTimer || 0, 0.26);
if (!realDanger && safety < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.18) return "finished";
return true;
}

View file

@ -6,8 +6,12 @@
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
interactWithOthers(dt) {
let closeCount = 0;
const scanLimit = 8;
let scanned = 0;
for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) {
if (o === this || o.dead) continue;
if (scanned >= scanLimit && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04) break;
scanned += 1;
const dx = o.x - this.x, dy = o.y - this.y;
const d = Math.hypot(dx, dy);
if (d < 0.01 || d > 74) continue;
@ -22,8 +26,8 @@
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 36, safety: dt * 5 }, o);
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * 30, safety: dt * 5 }, this);
if (typeof queueForcedTarinaiBehavior === "function") {
queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${o.name || "相手"}と喧嘩しようとしている` });
queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${this.name || "相手"}と喧嘩しようとしている` });
queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
}
}
@ -136,14 +140,14 @@
const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false;
const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false;
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) {
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias) {
this.conflictTargetId = o.id;
o.conflictTargetId = this.id;
const urge = 48 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 12 : 0);
const urge = 34 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 10 : 0);
this.conflictUrge = Math.max(this.conflictUrge || 0, urge);
o.conflictUrge = Math.max(o.conflictUrge || 0, urge * 0.82);
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * 0.32, safety: dt * (battleDrug ? 4 : 1.6) }, o);
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * 0.24, safety: dt * (battleDrug ? 4 : 1.6) }, this);
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * (battleDrug ? 0.32 : 0.18), safety: dt * (battleDrug ? 4 : 0.9) }, o);
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * (battleDrug ? 0.24 : 0.14), safety: dt * (battleDrug ? 4 : 0.9) }, this);
}
const compatibleBirthStatus = this.isZunchiSlave === o.isZunchiSlave;
@ -156,10 +160,12 @@
applyNeedShock(o, { social: dt * (loveDrug ? 34 : 14) }, this);
}
if (loveDrug && typeof queueForcedTarinaiBehavior === "function") {
queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${o.name || "相手"}と繁殖したくて近づいている` });
queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${this.name || "相手"}と繁殖したくて近づいている` });
queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
}
const mateIntent = this.activeBehavior?.id === "approach_mate" || o.activeBehavior?.id === "approach_mate" || this.intent?.actionId === "approach_mate" || o.intent?.actionId === "approach_mate";
const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.actionId;
const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.actionId;
const mateIntent = selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || loveDrug;
if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) {
this.world.startBirthRitual(this, o);
}
@ -186,10 +192,23 @@
if (this.state === "sunbath") {
this.target = null;
this.vx *= Math.pow(0.90, dt * 60);
this.vy *= Math.pow(0.90, dt * 60);
this.x = clamp(this.x + Math.sin((this.world?.time || 0) * 1.3 + this.seed) * 0.03, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
this.y = clamp(this.y + Math.cos((this.world?.time || 0) * 1.1 + this.seed) * 0.02, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
this.vx = 0;
this.vy = 0;
const pad = Math.max(28, CONFIG.worldPadding || 30);
const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000);
const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720);
const fallbackX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : (Number.isFinite(this.x) ? this.x : pad));
const fallbackY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : (Number.isFinite(this.y) ? this.y : pad));
if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = fallbackX;
if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = fallbackY;
this.sunbathAnchorX = clamp(this.sunbathAnchorX, pad, safeW - pad);
this.sunbathAnchorY = clamp(this.sunbathAnchorY, pad, safeH - pad);
this.x = this.sunbathAnchorX;
this.y = this.sunbathAnchorY;
this._lastSunbathDrawX = this.x;
this._lastSunbathDrawY = this.y;
this._lastValidX = this.x;
this._lastValidY = this.y;
return;
}
if (this.state === "sleep") {
@ -197,7 +216,7 @@
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);
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); else if (typeof applyNeedRelief === "function") applyNeedRelief(this, { sleep: -dt * 3 * bedComfort, safety: -dt * 1.5 * bedComfort });
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) });
this.hunger = clamp(this.hunger + dt * 0.018, 0, 115);
if (this.target && !this.target.dead && this.target.type === "nest_box") {
this.updateNestBoxPresence(dt);
@ -226,7 +245,7 @@
}
if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") {
const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
const partner = this.world.liveTarinaiById?.(this.birthPartnerId);
if (partner) {
const midX = (this.x + partner.x) / 2;
const midY = (this.y + partner.y) / 2;
@ -276,9 +295,9 @@
const d = Math.hypot(dx, dy) || 1;
if (this.state === "wander" && this.target?.detour && d < Math.max(22, (this.radius || 20) * 0.9)) {
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(this, { fulfill: 6 }, "wander");
this.currentAction = null;
this.intentLockTimer = 0;
this.goIdle?.("少し歩いた");
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null;
this.behaviorLockTimer = 0;
this.goIdle?.("\u5c11\u3057\u6b69\u3044\u305f");
return;
}
if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) {
@ -289,9 +308,11 @@
? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62))
: (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20));
if (reachedSleepFurniture) {
this.target.use?.(this, this.world);
this.startSleeping?.(this.target, this.target.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u308b\u5834\u6240\u306b\u7740\u3044\u305f\u306e\u3067\u4f11\u3093\u3067\u3044\u308b");
if (this.target.type === "nest_box") this.enterNestBox(this.target, dt);
const sleepTarget = this.target;
const isEasyBed = sleepTarget?.type === "grass_bed";
sleepTarget?.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined);
this.startSleeping?.(sleepTarget, sleepTarget?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
if (sleepTarget?.type === "nest_box") this.enterNestBox(sleepTarget, dt);
return;
}
}
@ -406,11 +427,15 @@
this.dead = true;
this.deathReason = finalReason;
this.world.deadCount += 1;
this.world.items.push(new Item("trace", this.x, this.y));
this.world.items.push(new Item("splat", this.x, this.y));
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y));
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y));
if (Math.random() < 0.55) {
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
if (spot) this.world.items.push(new Item("grass", spot.x, spot.y));
if (spot) {
const deathGrass = new Item("grass", spot.x, spot.y);
if (this.world.addItem) this.world.addItem(deathGrass, "tarinai-death-grass");
else this.world.items.push(deathGrass);
}
}
this.world.markDead(this, finalReason);
this.releasedLiveToken = this.liveToken || 0;

View file

@ -53,36 +53,68 @@ const TEXT_CATALOG = {
TEXT_CATALOG.stateLabels = Object.freeze({
idle: "待機",
seek_food: "食べ物へ向かう",
seek_bed: "寝床へ向かう",
play_ball: "ボール遊び",
seek_water: "水を探す",
zunchi_sick: "ずんち病",
fight_sick: "きずつき病",
system: "ずんち病",
seek_friend: "仲間を探す",
follow_parent: "親についていく",
cursor_friend: "カーソルについていく",
cursor_enemy: "カーソルを避ける",
fight: "喧嘩中",
sleep: "睡眠中",
eat: "食事中",
panic: "パニック",
intimidate: "威嚇中",
birth_ritual: "誕生前",
sunbath: "日光浴中",
ant_attack: "アリ攻撃中",
ant_intimidate: "アリ威嚇中",
frozen: "冷凍中",
idle: "\u5f85\u6a5f",
seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
seek_water: "\u6c34\u3092\u63a2\u3059",
seek_material: "\u6750\u6599\u3092\u63a2\u3059",
seek_enemy: "\u76f8\u624b\u3078\u5411\u304b\u3046",
wander: "\u79fb\u52d5\u4e2d",
build: "\u4f5c\u6210\u4e2d",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
system: "\u305a\u3093\u3061\u75c5",
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b",
fight: "\u55a7\u5629\u4e2d",
sleep: "\u7761\u7720\u4e2d",
eat: "\u98df\u4e8b\u4e2d",
panic: "\u30d1\u30cb\u30c3\u30af",
intimidate: "\u5a01\u5687\u4e2d",
birth_ritual: "\u8a95\u751f\u524d",
sunbath: "\u65e5\u5149\u6d74\u4e2d",
ant_attack: "\u30a2\u30ea\u653b\u6483\u4e2d",
ant_intimidate: "\u30a2\u30ea\u5a01\u5687\u4e2d",
frozen: "\u51b7\u51cd\u4e2d",
});
TEXT_CATALOG.targetLabelOverrides = Object.freeze({
system: "ずんち病",
zunchi_sick: "ずんち病",
fight_sick: "きずつき病",
TEXT_CATALOG.behaviorLabels = Object.freeze({
seek_material: "材料を探している",
build_structure: "作成している",
build: "作成している",
seek_enemy: "気になる相手へ向かっている",
wander_lightly: "少し移動している",
eat_food: "食事をしている",
drink_water: "水を飲んでいる",
sleep_in_bed: "寝床で休んでいる",
sleep_anywhere: "休んでいる",
intimidate_enemy: "威嚇している",
panic_escape: "逃げている",
});
TEXT_CATALOG.behaviorLabel = function behaviorLabelFromCatalog(value = "") {
const key = String(value || "");
return TEXT_CATALOG.behaviorLabels?.[key] || TEXT_CATALOG.stateLabels?.[key] || key;
};
TEXT_CATALOG.cleanBehaviorText = function cleanBehaviorText(text = "", fallbackKey = "") {
const raw = String(text || "").trim();
const key = String(fallbackKey || raw || "").trim();
const mapped = TEXT_CATALOG.behaviorLabel(key);
if (!raw) return mapped || "";
if (/^[a-z][a-z0-9_:-]*$/i.test(raw)) return TEXT_CATALOG.behaviorLabel(raw);
return raw;
};
TEXT_CATALOG.targetLabelOverrides = Object.freeze({
system: "\u305a\u3093\u3061\u75c5",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
});
// Backward-compatible alias. Tool/item labels come from TOOL_DEFINITIONS via toolLabel().
TEXT_CATALOG.targetLabels = TEXT_CATALOG.targetLabelOverrides;
TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) {
@ -90,53 +122,57 @@ TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) {
};
TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) {
if (!target) return "なし";
if (!target) return "\u306a\u3057";
if (target.name) return target.name;
if (target.kind === "queen") return "女王アリ";
if (target.kind === "worker") return "働きアリ";
if (target.kind === "queen") return "\u5973\u738b\u30a2\u30ea";
if (target.kind === "worker") return "\u50cd\u304d\u30a2\u30ea";
if (target.type) {
const type = String(target.type || "");
if (TEXT_CATALOG.targetLabelOverrides?.[type]) return TEXT_CATALOG.targetLabelOverrides[type];
const label = typeof toolLabel === "function" ? toolLabel(type) : "";
return label || type;
}
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "位置";
return "不明";
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u4f4d\u7f6e";
return "\u4e0d\u660e";
};
TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) {
if (!t) return "なし";
if ((t.pinchThoughtTimer || 0) > 0) return "つままれている";
const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim();
if (behaviorText) return behaviorText;
if (!t) return "\u306a\u3057";
if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior;
const behaviorLabel = typeof globalThis.behaviorText === "function"
? globalThis.behaviorText(t)
: String(typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (behaviorState?.text || behaviorState?.reason || "")).trim();
const cleanedBehaviorLabel = TEXT_CATALOG.cleanBehaviorText(behaviorLabel, behaviorState?.actionId || t.state || "");
if (cleanedBehaviorLabel) return cleanedBehaviorLabel;
const target = TEXT_CATALOG.targetLabel(t.target);
if (t.state === "eat") return target !== "なし" ? `${target}を食べている` : "食べている";
if (t.state === "sleep") return t.target?.type === "nest_box" ? "巣箱の中で体力を回復している" : "体力を回復している";
if (t.state === "panic") return t.target ? `${target}から逃げている` : "怖がっている";
if (t.state === "intimidate") return target !== "なし" ? `${target}を威嚇している` : "相手を威嚇している";
if (t.state === "ant_intimidate") return "アリを威嚇している";
if (t.state === "ant_attack") return "アリを攻撃している";
if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている";
if (t.state === "sunbath") return "晴れた日なたでストレスを下げている";
if (t.state === "frozen") return "フィールド外で冷凍保存されている";
if (t.state === "seek_food") return target !== "なし" ? `${target}を探している` : "お腹がすいている";
if (t.state === "seek_water") return t.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している";
if (t.state === "seek_bed") return target !== "なし" ? `${target}へ向かっている` : "眠る場所を探している";
if (t.state === "play_ball") return "ボールが気になっている";
if (t.state === "seek_friend" && t.intent?.actionId === "approach_mate") return t.target?.name ? `${t.target.name}に繁殖のため近づいている` : "繁殖できる相手を探している";
if (t.state === "seek_friend") return "仲間を探している";
if (t.state === "follow_parent") return "親についていく";
if (t.state === "cursor_friend") return "カーソルについていく";
if (t.state === "cursor_enemy") return "カーソルを避けている";
if (t.state === "fight") return target !== "なし" ? `${target}と喧嘩している` : "喧嘩している";
if (t.state === "fight_sick") return "きずつき病で当てもなく彷徨っている";
if (t.state === "zunchi_sick" || t.state === "system") return "ずんち病で調子が悪い";
if ((t.hurtTimer || 0) > 0.08) return "痛みで混乱している";
if ((t.fearTimer || 0) > 0.08) return "怖がっている";
if ((t.grassEatTimer || 0) > 0.04) return "草を食べている";
if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b";
if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
if (t.state === "panic") return t.target ? `${target}\u304b\u3089\u9003\u3052\u3066\u3044\u308b` : "\u6016\u304c\u3063\u3066\u3044\u308b";
if (t.state === "intimidate") return target !== "\u306a\u3057" ? `${target}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "ant_intimidate") return "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "ant_attack") return "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
if (t.state === "sunbath") return "\u6674\u308c\u305f\u65e5\u306a\u305f\u3067\u30b9\u30c8\u30ec\u30b9\u3092\u4e0b\u3052\u3066\u3044\u308b";
if (t.state === "frozen") return "\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3055\u308c\u3066\u3044\u308b";
if (t.state === "seek_food") return target !== "\u306a\u3057" ? `${target}\u3092\u63a2\u3057\u3066\u3044\u308b` : "\u304a\u8179\u304c\u3059\u3044\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 === "seek_bed") return target !== "\u306a\u3057" ? `${target}\u3078\u5411\u304b\u3063\u3066\u3044\u308b` : "\u7720\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "play_ball") return "\u30dc\u30fc\u30eb\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b";
if (t.state === "seek_friend" && (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate") return t.target?.name ? `${t.target.name}\u306b\u7e41\u6b96\u306e\u305f\u3081\u8fd1\u3065\u3044\u3066\u3044\u308b` : "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "seek_friend") return "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "follow_parent") return "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f";
if (t.state === "cursor_friend") return "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f";
if (t.state === "cursor_enemy") return "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b";
if (t.state === "fight") return target !== "\u306a\u3057" ? `${target}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b";
if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\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\u3067\u8abf\u5b50\u304c\u60aa\u3044";
if ((t.hurtTimer || 0) > 0.08) return "\u75db\u307f\u3067\u6df7\u4e71\u3057\u3066\u3044\u308b";
if ((t.fearTimer || 0) > 0.08) return "\u6016\u304c\u3063\u3066\u3044\u308b";
if ((t.grassEatTimer || 0) > 0.04) return "\u8349\u3092\u98df\u3079\u3066\u3044\u308b";
const thought = String(t.thought || "").trim();
if (thought) return thought;
return "周囲を見ている";
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
};
function stateLabel(s) { return TEXT_CATALOG.stateLabel(s); }

View file

@ -15,6 +15,8 @@ const ui = {
resetBtn: document.getElementById("resetBtn"),
creditsDialog: document.getElementById("creditsDialog"),
creditsCloseBtn: document.getElementById("creditsCloseBtn"),
panel: document.querySelector(".panel"),
panelQuickTabs: document.getElementById("panelQuickTabs"),
toolPalette: document.getElementById("toolPalette"),
toolCard: document.getElementById("toolCard"),
selectedCard: document.getElementById("selectedCard"),
@ -31,6 +33,8 @@ const ui = {
statPop: document.getElementById("statPop"),
statDead: document.getElementById("statDead"),
statColonyMood: document.getElementById("statColonyMood"),
statGroundType: document.getElementById("statGroundType"),
groundTypeBtn: document.getElementById("groundTypeBtn"),
colonyChart: document.getElementById("colonyChart"),
colonyChartLegend: document.getElementById("colonyChartLegend"),
archiveContent: document.getElementById("archiveContent"),
@ -50,6 +54,7 @@ const uiCache = {
stats: {},
selectedSnapshot: "",
selectedEmpty: false,
showSelectedEmpty: false,
selectedCollapsedCategories: new Set(),
logPushEnabled: false,
logPushKinds: new Set(["death", "accident", "birth"]),

View file

@ -6,6 +6,7 @@ function bindUI() {
syncLogPushControls();
syncAudioControls();
applyToolTips();
window.bindGroundUI?.();
renderEcologyCards();
window.TarinaiSaveSystem?.bindSaveSystem?.();
window.TarinaiFreezeSystem?.bindFreezeSystem?.();
@ -15,9 +16,71 @@ function bindUI() {
return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(world.tool));
}
function clickedInsideGameOrToolUi(target) {
return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target));
return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target) || ui.panelQuickTabs?.contains?.(target));
}
function panelSectionForTab(tab = "") {
if (tab === "selected") return ui.selectedCard;
if (tab === "tools") return ui.toolCard;
if (tab === "colony") return document.querySelector(".colony-card");
if (tab === "events") return document.querySelector(".log-card");
return null;
}
function setActivePanelTab(tab = "") {
for (const btn of ui.panelQuickTabs?.querySelectorAll("[data-panel-tab]") || []) {
btn.classList.toggle("active", btn.dataset.panelTab === tab);
}
}
function scrollPanelToTab(tab = "") {
const panel = ui.panel;
if (!panel) return false;
if (tab === "selected" && (!world.selected || world.selected.dead || !world.tarinai.includes(world.selected))) {
selectTool("observe");
uiCache.showSelectedEmpty = true;
uiCache.selectedEmpty = false;
renderSelected();
showToast("たりないが未選択です。観察モードにしました。");
}
const target = panelSectionForTab(tab);
if (!target) return false;
target.classList?.remove?.("hidden");
const tabsH = ui.panelQuickTabs?.offsetHeight || 0;
const top = Math.max(0, target.offsetTop - tabsH - 8);
panel.scrollTo({ top, behavior: "smooth" });
setActivePanelTab(tab);
return true;
}
ui.panelQuickTabs?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-panel-tab]");
if (!btn) return;
e.preventDefault();
audio.uiClick?.();
scrollPanelToTab(btn.dataset.panelTab || "");
});
ui.panel?.addEventListener("scroll", () => {
const panel = ui.panel;
if (!panel || !ui.panelQuickTabs) return;
const tabsH = ui.panelQuickTabs.offsetHeight || 0;
const entries = [
["selected", ui.selectedCard],
["tools", ui.toolCard],
["colony", document.querySelector(".colony-card")],
["events", document.querySelector(".log-card")],
].filter(([, el]) => el && !el.classList.contains("hidden"));
let active = entries[0]?.[0] || "";
const y = panel.scrollTop + tabsH + 18;
for (const [key, el] of entries) {
if (el.offsetTop <= y) active = key;
}
setActivePanelTab(active);
}, { passive: true });
syncToolSizeBadges();
setActivePanelTab(ui.selectedCard?.classList.contains("hidden") ? "tools" : "selected");
for (const toggle of document.querySelectorAll(".panel-card-toggle")) {
toggle.addEventListener("click", () => {
const card = toggle.closest(".collapsible-card");
@ -38,7 +101,6 @@ function bindUI() {
uiCache.lastChartDrawAt = 0;
renderStats();
});
syncToolSizeBadges();
ui.logPushEnabled?.addEventListener("change", () => {
uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked);
audio.notify?.();
@ -147,6 +209,7 @@ function bindUI() {
});
ui.selectedCloseBtn?.addEventListener("click", () => {
world.selected = null;
uiCache.showSelectedEmpty = false;
uiCache.selectedSnapshot = "";
uiCache.selectedEmpty = false;
renderSelected();
@ -189,7 +252,7 @@ function bindUI() {
document.addEventListener("click", (e) => {
if (!isActivePlacementTool()) return;
if (clickedInsideGameOrToolUi(e.target)) return;
selectTool("observe"); // 道具設置モードを解除
selectTool("observe"); // \u9053\u5177\u8a2d\u7f6e\u30e2\u30fc\u30c9\u3092\u89e3\u9664
});
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); openCreditsDialog(); });
ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog);
@ -279,552 +342,10 @@ function bindUI() {
chainScroll(ui.archiveContent);
transferEdgeScroll(ui.log, rightPanel);
const inputModeManager = window.TarinaiInputMode;
const setMobileInputMode = (mode = "auto") => {
mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto");
uiCache.mobileInputMode = mode;
ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode));
document.body.classList.toggle("mobile-mode-camera", mode === "camera");
document.body.classList.toggle("mobile-mode-tool", mode === "tool");
document.body.classList.toggle("mobile-mode-family", mode === "family");
};
const updateTouchMobileUiClass = () => {
const enabled = Boolean(inputModeManager?.refresh?.().touchFirst);
document.body.classList.toggle("touch-mobile-ui", enabled);
if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled;
};
const mobileInputMode = () => inputModeManager?.currentMode || uiCache.mobileInputMode || "auto";
updateTouchMobileUiClass();
window.addEventListener("resize", updateTouchMobileUiClass, { passive: true });
window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass);
setMobileInputMode(uiCache.mobileInputMode || "auto");
ui.mobileModeControls?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mobile-mode]");
if (!btn) return;
audio.uiClick?.();
setMobileInputMode(btn.dataset.mobileMode || "auto");
showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`);
});
const touchPoint = (touches, index = 0) => {
const t = touches?.[index];
if (!t) return null;
return { clientX: t.clientX, clientY: t.clientY };
};
const touchCenter = (touches) => {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a) return null;
if (!b) return a;
return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2 };
};
const touchDistance = (touches) => {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a || !b) return 0;
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
};
const screenToWorldClient = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 };
};
const clientToWorldRaw = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const scale = world.viewScale ? world.viewScale() : 1;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const rawX = (sx - off.x) / scale + (world.cameraX || 0);
const rawY = (sy - off.y) / scale + (world.cameraY || 0);
return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h };
};
const setZoomAroundClientPoint = (clientX, clientY, nextZoom) => {
if (!world.setFieldZoom) return false;
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
if (!world.setFieldZoom(nextZoom)) return false;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const scale = world.viewScale ? world.viewScale() : 1;
world.cameraX = focus.x - (sx - off.x) / scale;
world.cameraY = focus.y - (sy - off.y) / scale;
world.clampCamera?.();
return true;
};
function isClientOverFrozenPanel(clientX, clientY) {
const panel = document.getElementById("frozenPanel");
if (!panel) return false;
const r = panel.getBoundingClientRect();
return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
}
function rememberGrabOrigin(target) {
if (!target || uiCache.grabKind !== "tarinai") return;
uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 };
}
function finishTarinaiGrab(target, clientX, clientY) {
if (!target || uiCache.grabKind !== "tarinai") return false;
if (isClientOverFrozenPanel(clientX, clientY) && window.TarinaiFreezeSystem?.freezeTarinai) {
window.TarinaiFreezeSystem.freezeTarinai(target, world);
uiCache.grabOrigin = null;
return true;
}
const p = screenToWorldClient(clientX, clientY);
if (!p.inside && uiCache.grabOrigin) {
target.x = uiCache.grabOrigin.x;
target.y = uiCache.grabOrigin.y;
target.vx = uiCache.grabOrigin.vx || 0;
target.vy = uiCache.grabOrigin.vy || 0;
target.thought = "フィールドに戻された";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
showToast("フィールド外なので元の位置に戻しました。");
uiCache.grabOrigin = null;
return true;
}
uiCache.grabOrigin = null;
return false;
}
const beginTouchGrab = (p, e) => {
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) return false;
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.touches?.[0]?.clientX || 0;
uiCache.grabStartY = e.touches?.[0]?.clientY || 0;
if (uiCache.grabKind === "tarinai") {
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
uiCache.touchMode = "grab";
renderStats();
return true;
};
canvas?.addEventListener("touchstart", (e) => {
if (!e.touches?.length) return;
e.preventDefault();
uiCache.touchMoved = false;
const mode = mobileInputMode();
if ((mode === "camera" || mode === "family") && e.touches.length === 1) {
const t = e.touches[0];
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchMode = "cameraPan";
return;
}
if (e.touches.length >= 2) {
const c = touchCenter(e.touches);
uiCache.touchMode = "pinchZoom";
uiCache.pinchStartDistance = Math.max(1, touchDistance(e.touches));
uiCache.pinchStartZoom = world.fieldZoom || 1;
uiCache.pinchLastCenterX = c?.clientX || 0;
uiCache.pinchLastCenterY = c?.clientY || 0;
return;
}
const t = e.touches[0];
const p = screenToWorldClient(t.clientX, t.clientY);
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchLastWorldX = p.x;
uiCache.touchLastWorldY = p.y;
if (mode === "tool" && world.tool === "observe") {
uiCache.touchMode = "toolTapOnly";
return;
}
if (world.tool === "water_hose" && p.inside) {
audio.waterHose?.();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = t.clientX;
uiCache.hoseStartY = t.clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
uiCache.touchMode = "hose";
render();
return;
}
if (world.tool === "pinch" && p.inside && beginTouchGrab(p, e)) return;
uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap";
}, { passive: false });
canvas?.addEventListener("touchmove", (e) => {
if (!e.touches?.length || !uiCache.touchMode) return;
e.preventDefault();
if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) {
const c = touchCenter(e.touches);
const distNow = Math.max(1, touchDistance(e.touches));
const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow);
setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio);
const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX);
const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY);
uiCache.pinchLastCenterX = c.clientX;
uiCache.pinchLastCenterY = c.clientY;
if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy);
uiCache.touchMoved = true;
render();
return;
}
const t = e.touches[0];
const p = screenToWorldClient(t.clientX, t.clientY);
const screenDx = t.clientX - (uiCache.touchLastX || t.clientX);
const screenDy = t.clientY - (uiCache.touchLastY || t.clientY);
const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY));
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
if (uiCache.touchMode === "cameraPan") {
if (totalMove > 2) uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
return;
}
if (uiCache.touchMode === "toolTapOnly") {
if (totalMove > 8) uiCache.touchMoved = true;
return;
}
if (uiCache.touchMode === "hose" && uiCache.hosing) {
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
if (totalMove > 3) { uiCache.hoseMoved = true; uiCache.touchMoved = true; }
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
render();
return;
}
if (uiCache.touchMode === "grab" && uiCache.grabbing) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.touchMode = "";
return;
}
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; }
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("つままれている");
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
render();
return;
}
if (uiCache.touchMode === "panOrTap") {
if (totalMove > 7) {
uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
}
}
}, { passive: false });
canvas?.addEventListener("touchend", (e) => {
e.preventDefault();
if (uiCache.hosing) {
uiCache.hosing = false;
world.rebuildSpatial?.();
renderStats();
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
renderStats();
} else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) {
const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
} else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) {
const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
}
if (!e.touches?.length) {
uiCache.touchMode = "";
uiCache.touchMoved = false;
}
}, { passive: false });
canvas?.addEventListener("touchcancel", () => {
uiCache.touchMode = "";
uiCache.touchMoved = false;
uiCache.hosing = false;
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
}, { passive: true });
canvas?.addEventListener("contextmenu", (e) => e.preventDefault());
canvas?.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "water_hose") {
audio.waterHose?.();
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;
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) {
showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return;
}
e.preventDefault();
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.clientX;
uiCache.grabStartY = e.clientY;
if (uiCache.grabKind === "tarinai") {
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
canvas.style.cursor = "grabbing";
renderStats();
return;
}
if (e.button !== 2) return;
e.preventDefault();
uiCache.panning = true;
uiCache.panMoved = false;
uiCache.panButton = e.button;
uiCache.panStartX = e.clientX;
uiCache.panStartY = e.clientY;
uiCache.panLastX = e.clientX;
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) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, e.clientX, e.clientY);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
renderStats();
return;
}
if (!uiCache.panning) return;
if (uiCache.panButton !== null && e.button !== uiCache.panButton) return;
uiCache.panning = false;
uiCache.panButton = null;
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) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
canvas.style.cursor = world.tool === "pinch" ? "grab" : "default";
return;
}
const p = screenToWorld(e);
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY));
if (totalMove > 3) uiCache.grabMoved = true;
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.clientX, e.clientY);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("つままれている");
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
canvas.style.cursor = "grabbing";
return;
}
if (!uiCache.panning) return;
const dx = e.clientX - uiCache.panLastX;
const dy = e.clientY - uiCache.panLastY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
const totalMove = Math.hypot(e.clientX - uiCache.panStartX, e.clientY - uiCache.panStartY);
if (totalMove > 8) {
canvas.style.cursor = "grabbing";
world.panCamera?.(-dx, -dy);
uiCache.panMoved = true;
render();
}
});
canvas?.addEventListener("click", (e) => {
if (uiCache.hoseMoved) {
uiCache.hoseMoved = false;
return;
}
if (uiCache.grabMoved) {
uiCache.grabMoved = false;
return;
}
if (uiCache.panMoved) {
uiCache.panMoved = false;
return;
}
const p = screenToWorld(e);
if (!p.inside) return;
world.handleClick(p.x, p.y);
renderStats();
});
canvas?.addEventListener("mousemove", (e) => {
const p = screenToWorld(e);
const prevX = world.pointer.x;
const prevY = world.pointer.y;
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = !!p.inside;
const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0));
world.pointer.motion = clamp(delta / 18, 0, 2);
world.pointer.movedAt = performance.now();
});
canvas?.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
canvas?.addEventListener("mouseleave", () => {
world.pointer.inside = false;
world.pointer.motion = 0;
});
const inputContext = window.TarinaiUIInputShared?.createInputContext?.({ canvas, world, ui, uiCache });
if (!inputContext) throw new Error("Tarinai UI input context is not available");
window.TarinaiTouchInput?.bindTouchInput?.(inputContext);
window.TarinaiMouseInput?.bindMouseInput?.(inputContext);
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;

View file

@ -18,7 +18,8 @@ function renderStats() {
};
setTextIfChanged(ui.statPop, "pop", values.pop);
setTextIfChanged(ui.statDead, "dead", values.dead);
setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "のんびり");
setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "安定");
window.TarinaiGroundUI?.update?.(world);
updateColonyHistory(values);
drawColonyChart(values);
renderSelected();
@ -111,9 +112,6 @@ function drawColonyChart(values) {
const chart = ui.colonyChart;
if (!chart) return;
const now = performance.now();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0;
if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return;
const mode = uiCache.chartMode || "population";
if (mode === "environment" && !uiCache.chartHistory.length && !uiCache.environmentInitialRow) {
uiCache.environmentInitialRow = { t: world.time || 0, ...values };
@ -178,17 +176,17 @@ function chartSeries(mode = "population") {
{ key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true },
{ key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true },
{ key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true },
{ key: "mood", label: "\u7dcf\u5408\u7684\u6c17\u5206", color: "#0072b2", percent: true },
{ key: "mood", label: "気分", color: "#0072b2", percent: true },
];
if (mode === "objects") return [
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
{ key: "obj_trace", label: "足跡", color: "#8a7054" },
];
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 },
{ key: "security", label: "安全", color: "#d65e46", percent: true },
{ key: "hygiene", label: "衛生", color: "#6f8a3f", percent: true },
{ key: "happiness", label: "満足", color: "#d98bc0", percent: true },
];
return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
}

View file

@ -1,9 +1,4 @@
"use strict";
function buildArchiveRows(family) {
return liveFamilyComponents(family);
}
function lineageEdgeFamily(family = world.family || {}) {
return family || {};
}
@ -85,47 +80,3 @@ function lineageMutualChildIds(n, family = world.family || {}, idSet = null) {
children.sort((a, b) => lineageNodeSort(family[a], family[b]));
return children;
}
function lineageHasActualRelation(n, family = world.family || {}) {
return lineageMutualParentIds(n, family).length > 0 || lineageMutualChildIds(n, family).length > 0;
}
function lineageRelatedIdSet(family) {
family = lineageEdgeFamily(family);
const relationIndex = lineageRelationIndex(family);
const ids = new Set();
for (const n of Object.values(family || {}).filter(Boolean)) {
const parents = relationIndex.parentsByChild.get(n.id) || [];
const children = relationIndex.childrenByParent.get(n.id) || [];
if (parents.size || children.size) ids.add(n.id);
for (const id of parents) ids.add(id);
for (const id of children) ids.add(id);
}
return ids;
}
function liveFamilyComponents(family) {
// Childless pairs, including pairs currently performing the birth ritual, are
// intentionally excluded. A \u5bb6\u7cfb\u56f3 node is created only after an actual parent/child
// relation exists; otherwise ritual participants become isolated pseudo-families.
family = lineageEdgeFamily(family);
const relatedIds = lineageRelatedIdSet(family);
const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean);
const ids = new Set(nodes.map(n => n.id));
const comps = window.TarinaiFamilyGraph.connectedComponents(nodes, {
sort: lineageNodeSort,
edgesOf: (n) => [
...lineageMutualParentIds(n, family, ids),
...lineageMutualChildIds(n, family, ids),
],
filter: comp => comp.some(x => x.alive),
});
comps.sort((a, b) => {
const ag = Math.min(...a.map(n => n.generation || 1));
const bg = Math.min(...b.map(n => n.generation || 1));
const at = Math.min(...a.map(n => n.birthTime || 0));
const bt = Math.min(...b.map(n => n.birthTime || 0));
return ag - bg || at - bt || b.length - a.length;
});
return comps;
}

View file

@ -201,37 +201,6 @@ function lineageExpectedChildEdgeCount(component, family) {
for (const n of component || []) count += lineageParentIds(n, idSet, family).length;
return count;
}
function lineageVisibleRelationSignature(family) {
family = lineageEdgeFamily(family);
const relationIndex = lineageRelationIndex(family);
const relatedIds = new Set();
for (const [childId, parents] of relationIndex.parentsByChild.entries()) {
if (!parents.size) continue;
relatedIds.add(childId);
for (const parentId of parents) relatedIds.add(parentId);
}
return Array.from(relatedIds)
.sort()
.map(id => {
const n = family[id] || {};
const parents = Array.from(relationIndex.parentsByChild.get(id) || []).sort().join(",");
const children = Array.from(relationIndex.childrenByParent.get(id) || []).sort().join(",");
return [
id,
n.name || "",
n.type || "",
n.generation || 1,
n.birthTime || 0,
n.alive === false ? 0 : 1,
n.deathReason || "",
parents,
children,
].join(":");
})
.join("|");
}
function lineageArchiveNearViewport() {
if (!ui.archiveContent?.getBoundingClientRect) return true;
const rect = ui.archiveContent.getBoundingClientRect();

View file

@ -360,11 +360,6 @@ function validateFamilyTree() {
});
return issues;
}
function renderArchiveWindow() {
renderArchive();
}
function scheduleArchiveWindowRender() {
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) {
uiCache.archiveScheduled = false;

57
js/ui_ground.js Normal file
View file

@ -0,0 +1,57 @@
"use strict";
(function (global) {
function groundDef(worldRef = global.world) {
const id = worldRef?.groundType || "soil";
return global.TarinaiGround?.definition?.(id) || (typeof GROUND_TYPES !== "undefined" ? GROUND_TYPES[id] || GROUND_TYPES.soil : { id: "soil", label: "土", description: "いつものじめん" });
}
function groundTip(worldRef = global.world) {
const def = groundDef(worldRef);
if (global.TarinaiGround?.tooltipText) return global.TarinaiGround.tooltipText(def.id);
return `${def.label || "じめん"}\n${def.description || "クリックで切り替え"}`;
}
function updateGroundUI(worldRef = global.world) {
const uiRef = global.ui || {};
const def = groundDef(worldRef);
const label = def.label || "土";
if (uiRef.statGroundType) setTextIfChanged(uiRef.statGroundType, "groundType", label);
if (uiRef.groundTypeBtn) {
const desc = def.description || "クリックで切り替え";
uiRef.groundTypeBtn.dataset.tip = desc;
uiRef.groundTypeBtn.removeAttribute("title");
uiRef.groundTypeBtn.setAttribute("aria-label", desc ? `地面を切り替える。現在は${label}${desc}` : `地面を切り替える。現在は${label}`);
}
}
function bindGroundUI(worldRef = global.world) {
const btn = global.ui?.groundTypeBtn;
if (!btn) return;
if (btn.dataset.groundBound !== "1") {
btn.dataset.groundBound = "1";
btn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const currentWorld = global.world || worldRef;
audio.uiClick?.();
currentWorld?.cycleGroundType?.();
if (global.uiCache) {
global.uiCache.lastStatsGroundType = "";
if (global.uiCache.stats) global.uiCache.stats.groundType = "";
}
updateGroundUI(currentWorld);
global.renderStats?.();
});
}
global.TarinaiTooltips?.bind?.(btn, () => groundTip(worldRef));
updateGroundUI(worldRef);
}
global.TarinaiGroundUI = Object.freeze({
bind: bindGroundUI,
update: updateGroundUI,
tip: groundTip,
});
global.bindGroundUI = bindGroundUI;
})(window);

81
js/ui_input_mouse.js Normal file
View file

@ -0,0 +1,81 @@
"use strict";
(function (global) {
function bindMouseInput(ctx) {
if (!ctx?.canvas) return false;
const { canvas, world, uiCache } = ctx;
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
canvas.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "water_hose") {
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
e.preventDefault();
ctx.beginHose(p, e.clientX, e.clientY);
return;
}
if (e.button === 0 && world.tool === "pinch") {
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
if (!ctx.beginGrab(p, e.clientX, e.clientY, { notifyNoTarget: true })) return;
e.preventDefault();
canvas.style.cursor = "grabbing";
return;
}
if (e.button !== 2) return;
e.preventDefault();
ctx.beginPan(e.clientX, e.clientY, e.button);
});
window.addEventListener("mouseup", (e) => {
if (ctx.endHose()) return;
if (ctx.endGrab(e.clientX, e.clientY)) return;
ctx.endPan(e.button);
});
window.addEventListener("mousemove", (e) => {
if (uiCache.hosing) {
const p = ctx.screenToWorldEvent(e);
ctx.moveHose(p, e.clientX, e.clientY);
return;
}
if (uiCache.grabbing) {
const p = ctx.screenToWorldEvent(e);
const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY));
if (totalMove > 3) uiCache.grabMoved = true;
ctx.moveGrab(p, e.clientX, e.clientY);
return;
}
ctx.movePan(e.clientX, e.clientY);
});
canvas.addEventListener("click", (e) => {
if (uiCache.hoseMoved) {
uiCache.hoseMoved = false;
return;
}
if (uiCache.grabMoved) {
uiCache.grabMoved = false;
return;
}
if (uiCache.panMoved) {
uiCache.panMoved = false;
return;
}
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
world.handleClick(p.x, p.y);
renderStats();
});
canvas.addEventListener("mousemove", (e) => ctx.updatePointerFromEvent(e));
canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
canvas.addEventListener("mouseleave", () => {
world.pointer.inside = false;
world.pointer.motion = 0;
});
return true;
}
global.TarinaiMouseInput = Object.freeze({ bindMouseInput });
})(window);

378
js/ui_input_shared.js Normal file
View file

@ -0,0 +1,378 @@
"use strict";
(function (global) {
function cursorForTool(tool) {
if (tool === "water_hose") return "crosshair";
if (tool === "pinch") return "grab";
if (tool === "observe") return "default";
if (tool === "poke" || tool === "delete") return "pointer";
return "crosshair";
}
function createInputContext({ canvas, world, ui, uiCache }) {
if (!canvas || !world || !uiCache) return null;
const inputModeManager = global.TarinaiInputMode;
function setMobileInputMode(mode = "auto") {
mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto");
uiCache.mobileInputMode = mode;
ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode));
document.body.classList.toggle("mobile-mode-camera", mode === "camera");
document.body.classList.toggle("mobile-mode-tool", mode === "tool");
document.body.classList.toggle("mobile-mode-family", mode === "family");
return mode;
}
function updateTouchMobileUiClass() {
const enabled = Boolean(inputModeManager?.refresh?.().touchFirst);
document.body.classList.toggle("touch-mobile-ui", enabled);
if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled;
return enabled;
}
function mobileInputMode() {
return inputModeManager?.currentMode || uiCache.mobileInputMode || "auto";
}
function bindMobileModeControls() {
updateTouchMobileUiClass();
window.addEventListener("resize", updateTouchMobileUiClass, { passive: true });
window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass);
setMobileInputMode(uiCache.mobileInputMode || "auto");
ui.mobileModeControls?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mobile-mode]");
if (!btn) return;
audio.uiClick?.();
setMobileInputMode(btn.dataset.mobileMode || "auto");
showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`);
});
}
function touchPoint(touches, index = 0) {
const t = touches?.[index];
if (!t) return null;
return { clientX: t.clientX, clientY: t.clientY };
}
function touchCenter(touches) {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a) return null;
if (!b) return a;
return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2 };
}
function touchDistance(touches) {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a || !b) return 0;
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
}
function screenToWorldClient(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 };
}
function screenToWorldEvent(e) {
if (typeof screenToWorld === "function") return screenToWorld(e);
return screenToWorldClient(e.clientX, e.clientY);
}
function clientToWorldRaw(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const scale = world.viewScale ? world.viewScale() : 1;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const rawX = (sx - off.x) / scale + (world.cameraX || 0);
const rawY = (sy - off.y) / scale + (world.cameraY || 0);
return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h };
}
function setZoomAroundClientPoint(clientX, clientY, nextZoom) {
if (!world.setFieldZoom) return false;
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
if (!world.setFieldZoom(nextZoom)) return false;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const scale = world.viewScale ? world.viewScale() : 1;
world.cameraX = focus.x - (sx - off.x) / scale;
world.cameraY = focus.y - (sy - off.y) / scale;
world.clampCamera?.();
return true;
}
function isClientOverFrozenPanel(clientX, clientY) {
const panel = document.getElementById("frozenPanel");
if (!panel) return false;
const r = panel.getBoundingClientRect();
return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
}
function rememberGrabOrigin(target) {
if (!target || uiCache.grabKind !== "tarinai") return;
uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 };
}
function finishTarinaiGrab(target, clientX, clientY) {
if (!target || uiCache.grabKind !== "tarinai") return false;
if (isClientOverFrozenPanel(clientX, clientY) && global.TarinaiFreezeSystem?.freezeTarinai) {
global.TarinaiFreezeSystem.freezeTarinai(target, world);
uiCache.grabOrigin = null;
return true;
}
const p = screenToWorldClient(clientX, clientY);
if (!p.inside && uiCache.grabOrigin) {
target.x = uiCache.grabOrigin.x;
target.y = uiCache.grabOrigin.y;
target.vx = uiCache.grabOrigin.vx || 0;
target.vy = uiCache.grabOrigin.vy || 0;
target.thought = "\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3055\u308c\u305f";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
showToast("\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u306a\u306e\u3067\u5143\u306e\u4f4d\u7f6e\u306b\u623b\u3057\u307e\u3057\u305f\u3002");
uiCache.grabOrigin = null;
return true;
}
uiCache.grabOrigin = null;
return false;
}
function prepareGrabTarget(target) {
if (uiCache.grabKind === "tarinai") {
const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id);
if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch");
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
}
function beginGrab(p, clientX, clientY, options = {}) {
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) {
if (options.notifyNoTarget) showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return false;
}
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = clientX || 0;
uiCache.grabStartY = clientY || 0;
prepareGrabTarget(target);
if (options.touchMode) uiCache.touchMode = options.touchMode;
renderStats();
return true;
}
function moveGrab(p, clientX, clientY, options = {}) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
if (options.touch) uiCache.touchMode = "";
canvas.style.cursor = world.tool === "pinch" ? "grab" : "default";
return false;
}
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(clientX, clientY);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
canvas.style.cursor = "grabbing";
if (options.renderFrame) render();
return true;
}
function endGrab(clientX, clientY) {
if (!uiCache.grabbing) return false;
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, clientX, clientY);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
canvas.style.cursor = cursorForTool(world.tool);
renderStats();
return true;
}
function beginHose(p, clientX, clientY, options = {}) {
audio.waterHose?.();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = clientX;
uiCache.hoseStartY = clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
if (options.touchMode) uiCache.touchMode = options.touchMode;
render();
return true;
}
function moveHose(p, clientX, clientY, options = {}) {
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(clientX - (uiCache.hoseStartX || clientX), clientY - (uiCache.hoseStartY || clientY));
if (totalMove > 3) {
uiCache.hoseMoved = true;
if (options.touch) uiCache.touchMoved = true;
}
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
canvas.style.cursor = "crosshair";
render();
}
function endHose() {
if (!uiCache.hosing) return false;
uiCache.hosing = false;
canvas.style.cursor = cursorForTool(world.tool);
world.rebuildSpatial?.();
renderStats();
return true;
}
function beginPan(clientX, clientY, button = null) {
uiCache.panning = true;
uiCache.panMoved = false;
uiCache.panButton = button;
uiCache.panStartX = clientX;
uiCache.panStartY = clientY;
uiCache.panLastX = clientX;
uiCache.panLastY = clientY;
}
function movePan(clientX, clientY) {
if (!uiCache.panning) return false;
const dx = clientX - uiCache.panLastX;
const dy = clientY - uiCache.panLastY;
uiCache.panLastX = clientX;
uiCache.panLastY = clientY;
const totalMove = Math.hypot(clientX - uiCache.panStartX, clientY - uiCache.panStartY);
if (totalMove > 8) {
canvas.style.cursor = "grabbing";
world.panCamera?.(-dx, -dy);
uiCache.panMoved = true;
render();
}
return true;
}
function endPan(button = null) {
if (!uiCache.panning) return false;
if (uiCache.panButton !== null && button !== null && button !== uiCache.panButton) return false;
uiCache.panning = false;
uiCache.panButton = null;
canvas.style.cursor = cursorForTool(world.tool);
return true;
}
function updatePointerFromEvent(e) {
const p = screenToWorldEvent(e);
const prevX = world.pointer.x;
const prevY = world.pointer.y;
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = !!p.inside;
const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0));
world.pointer.motion = clamp(delta / 18, 0, 2);
world.pointer.movedAt = performance.now();
}
function cancelPointerActions() {
uiCache.touchMode = "";
uiCache.touchMoved = false;
uiCache.hosing = false;
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
}
return {
canvas,
world,
ui,
uiCache,
cursorForTool,
setMobileInputMode,
updateTouchMobileUiClass,
mobileInputMode,
bindMobileModeControls,
touchPoint,
touchCenter,
touchDistance,
screenToWorldClient,
screenToWorldEvent,
clientToWorldRaw,
setZoomAroundClientPoint,
beginGrab,
moveGrab,
endGrab,
beginHose,
moveHose,
endHose,
beginPan,
movePan,
endPan,
updatePointerFromEvent,
cancelPointerActions,
};
}
global.TarinaiUIInputShared = Object.freeze({ createInputContext, cursorForTool });
})(window);

133
js/ui_input_touch.js Normal file
View file

@ -0,0 +1,133 @@
"use strict";
(function (global) {
function bindTouchInput(ctx) {
if (!ctx?.canvas) return false;
const { canvas, world, uiCache } = ctx;
ctx.bindMobileModeControls();
canvas.addEventListener("touchstart", (e) => {
if (!e.touches?.length) return;
e.preventDefault();
uiCache.touchMoved = false;
const mode = ctx.mobileInputMode();
if ((mode === "camera" || mode === "family") && e.touches.length === 1) {
const t = e.touches[0];
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchMode = "cameraPan";
return;
}
if (e.touches.length >= 2) {
const c = ctx.touchCenter(e.touches);
uiCache.touchMode = "pinchZoom";
uiCache.pinchStartDistance = Math.max(1, ctx.touchDistance(e.touches));
uiCache.pinchStartZoom = world.fieldZoom || 1;
uiCache.pinchLastCenterX = c?.clientX || 0;
uiCache.pinchLastCenterY = c?.clientY || 0;
return;
}
const t = e.touches[0];
const p = ctx.screenToWorldClient(t.clientX, t.clientY);
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchLastWorldX = p.x;
uiCache.touchLastWorldY = p.y;
if (mode === "tool" && world.tool === "observe") {
uiCache.touchMode = "toolTapOnly";
return;
}
if (world.tool === "water_hose" && p.inside) {
ctx.beginHose(p, t.clientX, t.clientY, { touchMode: "hose" });
return;
}
if (world.tool === "pinch" && p.inside && ctx.beginGrab(p, t.clientX, t.clientY, { touchMode: "grab" })) return;
uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap";
}, { passive: false });
canvas.addEventListener("touchmove", (e) => {
if (!e.touches?.length || !uiCache.touchMode) return;
e.preventDefault();
if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) {
const c = ctx.touchCenter(e.touches);
const distNow = Math.max(1, ctx.touchDistance(e.touches));
const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow);
ctx.setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio);
const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX);
const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY);
uiCache.pinchLastCenterX = c.clientX;
uiCache.pinchLastCenterY = c.clientY;
if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy);
uiCache.touchMoved = true;
render();
return;
}
const t = e.touches[0];
const p = ctx.screenToWorldClient(t.clientX, t.clientY);
const screenDx = t.clientX - (uiCache.touchLastX || t.clientX);
const screenDy = t.clientY - (uiCache.touchLastY || t.clientY);
const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY));
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
if (uiCache.touchMode === "cameraPan") {
if (totalMove > 2) uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
return;
}
if (uiCache.touchMode === "toolTapOnly") {
if (totalMove > 8) uiCache.touchMoved = true;
return;
}
if (uiCache.touchMode === "hose" && uiCache.hosing) {
ctx.moveHose(p, t.clientX, t.clientY, { touch: true });
return;
}
if (uiCache.touchMode === "grab" && uiCache.grabbing) {
if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; }
ctx.moveGrab(p, t.clientX, t.clientY, { touch: true, renderFrame: true });
return;
}
if (uiCache.touchMode === "panOrTap") {
if (totalMove > 7) {
uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
}
}
}, { passive: false });
canvas.addEventListener("touchend", (e) => {
e.preventDefault();
if (uiCache.hosing) ctx.endHose();
if (uiCache.grabbing) {
ctx.endGrab(e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0);
} else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) {
const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
} else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) {
const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
}
if (!e.touches?.length) {
uiCache.touchMode = "";
uiCache.touchMoved = false;
}
}, { passive: false });
canvas.addEventListener("touchcancel", () => ctx.cancelPointerActions(), { passive: true });
return true;
}
global.TarinaiTouchInput = Object.freeze({ bindTouchInput });
})(window);

View file

@ -2,9 +2,8 @@
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const perf = world?.performanceLevel ? world.performanceLevel() : 0;
const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2);
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr));
const perfScale = window.TarinaiPerf?.dprScale?.() || 1;
const dpr = Math.max(0.5, Math.min((window.devicePixelRatio || 1) * perfScale, 2));
const nextW = Math.max(1, Math.floor(rect.width * dpr));
const nextH = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;
@ -48,40 +47,7 @@ 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 baseTip = btn?.dataset?.tip || "";
if (!baseTip) return;
const tip = baseTip;
pop.textContent = tip;
pop.classList.remove("hidden");
const rect = btn.getBoundingClientRect();
const popW = pop.offsetWidth || 220;
const popH = pop.offsetHeight || 36;
const left = rect.left - popW - 12;
pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`;
pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}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);
}
window.TarinaiTooltips?.applyToolPalette?.();
}
function renderEcologyCards() {

View file

@ -1,5 +1,7 @@
"use strict";
const escapeHtml = window.TarinaiUIHelpers.htmlEscape;
function quoteNameInRecordText(text, name) {
const raw = String(text || "");
const n = String(name || "").trim();
@ -139,19 +141,6 @@ function personalityDailySnapshot(t) {
}).join("|");
return `${keyPart}#${causePart}`;
}
function recentChangeCausesHtml(t) {
const rows = Array.isArray(t?.recentChangeCauses) ? t.recentChangeCauses.slice(0, 8) : [];
if (!rows.length) return `<li><span>-</span><strong>\u6700\u8fd1\u306e\u5909\u5316\u306a\u3057</strong></li>`;
return rows.map(entry => {
const timeText = world.clockStringFromTime ? world.clockStringFromTime(entry.time || 0) : formatRecordTime(entry.time || 0);
const reason = String(entry.reason || "\u5909\u5316");
const target = String(entry.target || "\u72b6\u614b");
const value = entry.value === "" || entry.value == null ? "" : ` ${entry.value}`;
return `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(reason)} / ${escapeHtml(target)}${escapeHtml(value)}</strong></li>`;
}).join("");
}
function selectedDataCategoryHtml(id, label, bodyHtml) {
if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set();
const collapsed = uiCache.selectedCollapsedCategories.has(id);
@ -169,20 +158,20 @@ function selectedInfoRowHtml(label, valueHtml, metric = "") {
function selectedInventoryItems(t) {
const worldRef = t?.world || world;
const labels = { grass_bed: "かんたんベッド", plushie: "ぬいぐるみ" };
const labels = { grass_bed: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", plushie: "\u306c\u3044\u3050\u308b\u307f" };
const items = (worldRef?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id);
const counts = new Map();
for (const it of items) {
const label = labels[it.type] || toolLabel(it.type) || it.type;
counts.set(label, (counts.get(label) || 0) + 1);
}
return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} ×${count}` : label);
return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} \u00d7${count}` : label);
}
function selectedInventoryHtml(t) {
const entries = selectedInventoryItems(t);
if (!entries.length) return `<div class="info-row"><span>所持品</span><strong>なし</strong></div>`;
return `<div class="info-row"><span>所持品</span><strong>${entries.map(v => `<span class="personality-tag">${escapeHtml(v)}</span>`).join("")}</strong></div>`;
if (!entries.length) return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>\u306a\u3057</strong></div>`;
return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>${entries.map(v => `<span class="personality-tag">${escapeHtml(v)}</span>`).join("")}</strong></div>`;
}
function selectedStateText(t) {
@ -192,17 +181,21 @@ function selectedStateText(t) {
function renderSelected() {
const t = world.selected;
if (!t || t.dead || !world.tarinai.includes(t)) {
ui.selectedCard?.classList.add("hidden");
if (uiCache.selectedEmpty) return;
const showEmpty = Boolean(uiCache.showSelectedEmpty);
ui.selectedCard?.classList.toggle("hidden", !showEmpty);
if (uiCache.selectedEmpty && uiCache.selectedEmptyVisible === showEmpty) return;
uiCache.selectedSnapshot = "";
uiCache.selectedEmpty = true;
uiCache.selectedEmptyVisible = showEmpty;
ui.selectedInfo.className = "selected-info empty";
ui.selectedInfo.textContent = "\u672a\u9078\u629e";
return;
}
uiCache.showSelectedEmpty = false;
ui.selectedCard?.classList.remove("hidden");
uiCache.selectedEmpty = false;
uiCache.selectedEmptyVisible = false;
const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id;
if (activeNameInput) return;
const rel = t.relationSummary ? t.relationSummary() : {};
@ -216,9 +209,10 @@ function renderSelected() {
].join(",");
const itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : [];
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
const snapshot = [
t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target),
t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", t.activeBehavior?.id || "", t.activeBehavior?.presentText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
behaviorState?.need || "", behaviorState?.actionId || "", behaviorState?.label || "", behaviorState?.reason || "", behaviorState?.source || "", behaviorState?.text || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
fmt(t.energy), fmt(t.stress), fmt(t.mood),
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
@ -273,7 +267,7 @@ function renderSelected() {
const needRowsHtml = needKeys.map(key => {
const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key;
const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10);
const chosen = t.intent?.need === key ? ` data-chosen-need="true"` : "";
const chosen = behaviorState?.need === key ? ` data-chosen-need="true"` : "";
return `<div class="need-cell"${chosen}><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}).join("");
const healthHtml = [
@ -286,13 +280,11 @@ function renderSelected() {
? itemEffectSummary.map(e => `<div class="info-row"><span>${escapeHtml(e.label)}</span><strong>${escapeHtml(e.detail)}</strong></div>`).join("")
: `<div class="info-row"><span>\u30a2\u30a4\u30c6\u30e0\u52b9\u679c</span><strong>\u306a\u3057</strong></div>`;
const relationHtml = `
<div class="info-row relation-row relation-list-row"><span>\u53cb\u9054</span><strong>${relationListHtml(t, "friend")}</strong></div>
<div class="info-row relation-row relation-list-row"><span>\u6575\u5bfe</span><strong>${relationListHtml(t, "enemy")}</strong></div>
<div class="info-row"><span>\u3051\u3093\u304b\u52dd\u7387</span><strong>${escapeHtml(fightWinRateLabel(t))}</strong></div>
`;
const familyHtml = `
<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 relation-row relation-list-row"><span>友達</span><strong>${relationListHtml(t, "friend")}</strong></div>
<div class="info-row relation-row relation-list-row"><span>敵対</span><strong>${relationListHtml(t, "enemy")}</strong></div>
<div class="info-row"><span></span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "")}</strong></div>
<div class="info-row"><span></span><strong>${t.children?.length || 0}</strong></div>
<div class="info-row"><span>けんか勝率</span><strong>${escapeHtml(fightWinRateLabel(t))}</strong></div>
`;
ui.selectedInfo.innerHTML = `
<div class="selected-name-editor">
@ -304,8 +296,7 @@ function renderSelected() {
${selectedDataCategoryHtml("health", "\u6b32\u6c42", healthHtml)}
${selectedDataCategoryHtml("effects", "\u30a2\u30a4\u30c6\u30e0\u52b9\u679c", itemEffectHtml)}
${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)}
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)}
${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)}
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2\u3068\u5bb6\u65cf", relationHtml)}
${selectedDataCategoryHtml("inventory", "\u6240\u6301\u54c1", selectedInventoryHtml(t))}
${selectedDataCategoryHtml("records", "\u8a18\u9332", `<div class="selected-records"><ol>${recordHtml}</ol></div>`)}
</div>
@ -325,19 +316,22 @@ function stateLabel(s) {
}
function reasonLabel(t) {
if (!t) return "なし";
const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim();
if (!t) return "\u306a\u3057";
const behavior = typeof globalThis.behaviorText === "function"
? globalThis.behaviorText(t)
: String(typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.text || t.behavior?.reason || "")).trim();
if (behavior) return behavior;
const state = String(t.state || "");
const live = window.TEXT_CATALOG?.reasonLabel?.(t) || "";
const liveStates = new Set(["birth_ritual", "eat", "sleep", "panic", "fight", "intimidate", "ant_attack", "ant_intimidate", "frozen"]);
if (live && (liveStates.has(state) || (t.birthRitualTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04 || (t.fightTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04)) return live;
if (t?.intent?.reasonText) return t.intent.reasonText;
return live || "なし";
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior;
if (behaviorState?.reason || behaviorState?.text) return behaviorState.reason || behaviorState.text;
return live || "\u306a\u3057";
}
function targetLabel(target) {
return window.TEXT_CATALOG?.targetLabel?.(target) || "なし";
return window.TEXT_CATALOG?.targetLabel?.(target) || "\u306a\u3057";
}
function fightWinRateLabel(t) {
@ -355,26 +349,6 @@ function setTextIfChanged(el, key, value) {
uiCache.stats[key] = next;
el.textContent = next;
}
function setValueIfChanged(el, key, value) {
if (!el) return;
if (uiCache.stats[key] === value) return;
uiCache.stats[key] = value;
el.value = value;
}
function escapeHtml(value) {
const helper = window.TarinaiUIHelpers?.htmlEscape;
if (typeof helper === "function" && helper !== escapeHtml) return helper(value);
return String(value ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[c] || c));
}
function escapeRegExp(value) {
return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -1,10 +1,11 @@
"use strict";
const SCALABLE_TOOLS = new Set(scalableToolIds());
const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose"]);
const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"];
const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "小", medium: "中", large: "大" };
const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
function renderToolPalette() {
if (!ui?.toolPalette || typeof toolCategories !== "function") return;
@ -23,11 +24,17 @@ function renderToolPalette() {
body.className = "tool-grid tool-category-body";
for (const toolId of category.toolIds || []) {
const def = toolDefinition(toolId);
if (!def || def.simulationOnly) continue;
if (!def || def.simulationOnly || toolId === "observe") continue;
const btn = document.createElement("button");
btn.className = "tool";
if (EMOJI_ICON_TOOLS.has(toolId)) btn.classList.add("tool-emoji-icon");
btn.dataset.tool = toolId;
btn.textContent = def.label || toolId;
btn.dataset.toolCategory = category.id || "";
const label = document.createElement("span");
label.className = "tool-label";
label.textContent = def.label || toolId;
btn.title = label.textContent;
btn.appendChild(label);
attachToolSpritePreview(btn, def, toolId);
if (toolId === world?.tool) btn.classList.add("selected");
body.appendChild(btn);
@ -38,31 +45,47 @@ function renderToolPalette() {
ui.toolPalette.appendChild(frag);
}
function drawToolPreviewCanvas(canvas, itemType, { watermark = false } = {}) {
if (!canvas || !itemType) return false;
const ctx = canvas.getContext("2d");
if (!ctx) return false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (typeof window.drawToolItemPreview === "function") {
return window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true, watermark }) !== false;
}
if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType) && typeof window.drawConsumableFieldSprite === "function") {
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
const baseScale = itemType === "protein" || itemType === "niteropu" ? 0.9 : 1.0;
ctx.scale(baseScale, baseScale);
window.drawConsumableFieldSprite(ctx, itemType, 17.5, 112.4, { compact: true });
ctx.restore();
return true;
}
return false;
}
function createToolPreviewCanvas(className, size = 96) {
const canvas = document.createElement("canvas");
canvas.className = className;
canvas.width = size;
canvas.height = size;
canvas.setAttribute("aria-hidden", "true");
return canvas;
}
function attachToolSpritePreview(btn, def, toolId) {
const itemType = def?.itemType || toolId;
if (!btn || !itemType || !def?.placeable) return;
const drawPreview = window.drawToolItemPreview || window.drawConsumableFieldSprite;
if (typeof drawPreview !== "function") return;
const canvas = document.createElement("canvas");
canvas.className = "tool-sprite-preview";
canvas.width = 72;
canvas.height = 72;
canvas.setAttribute("aria-hidden", "true");
const ctx = canvas.getContext("2d");
if (!ctx) return;
let drawn = false;
if (typeof window.drawToolItemPreview === "function") {
drawn = window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true }) !== false;
} else if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType)) {
ctx.translate(canvas.width / 2, canvas.height / 2);
const scale = itemType === "protein" || itemType === "niteropu" ? 0.82 : 0.92;
ctx.scale(scale, scale);
window.drawConsumableFieldSprite(ctx, itemType, 13.5, 112.4, { compact: true });
drawn = true;
}
if (!drawn) return;
if (toolId === "ball" || itemType === "ball") return;
if (typeof window.drawToolItemPreview !== "function" && typeof window.drawConsumableFieldSprite !== "function") return;
const iconCanvas = createToolPreviewCanvas("tool-sprite-preview", 160);
const iconDrawn = drawToolPreviewCanvas(iconCanvas, itemType);
if (!iconDrawn) return;
btn.classList.add("tool-has-canvas-icon");
btn.prepend(canvas);
btn.prepend(iconCanvas);
}
function toolSizeFor(tool) {

70
js/ui_tooltips.js Normal file
View file

@ -0,0 +1,70 @@
"use strict";
(function (global) {
function ensureTooltipPopover() {
let pop = document.getElementById("toolTipPopover");
if (!pop) {
pop = document.createElement("div");
pop.id = "toolTipPopover";
pop.className = "tool-tip-popover hidden";
document.body.appendChild(pop);
}
return pop;
}
function resolveTip(el) {
if (!el) return "";
if (typeof el.__tarinaiTipProvider === "function") return String(el.__tarinaiTipProvider() || "");
return String(el.dataset?.tip || "");
}
function showTooltip(el) {
const tip = resolveTip(el);
if (!tip) return;
const pop = ensureTooltipPopover();
pop.textContent = tip;
pop.classList.remove("hidden");
const rect = el.getBoundingClientRect();
const popW = pop.offsetWidth || 220;
const popH = pop.offsetHeight || 36;
const preferLeft = rect.left - popW - 12;
const rightSide = rect.right + 12;
const left = preferLeft >= 8 ? preferLeft : rightSide;
pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`;
pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`;
}
function hideTooltip() {
ensureTooltipPopover().classList.add("hidden");
}
function bindTooltip(el, tip = "") {
if (!el) return;
if (typeof tip === "function") el.__tarinaiTipProvider = tip;
else if (tip) el.dataset.tip = String(tip);
if (el.dataset.tipBound === "1") return;
el.dataset.tipBound = "1";
el.addEventListener("mouseenter", () => showTooltip(el));
el.addEventListener("focus", () => showTooltip(el));
el.addEventListener("mouseleave", hideTooltip);
el.addEventListener("blur", hideTooltip);
}
function applyToolPaletteTooltips() {
const tips = global.TEXT_CATALOG?.toolTips || {};
const root = global.ui?.toolPalette || document;
for (const btn of root.querySelectorAll?.(".tool[data-tool]") || []) {
const tip = tips[btn.dataset.tool];
if (tip) bindTooltip(btn, tip);
}
}
global.TarinaiTooltips = Object.freeze({
ensure: ensureTooltipPopover,
bind: bindTooltip,
show: showTooltip,
hide: hideTooltip,
applyToolPalette: applyToolPaletteTooltips,
});
global.bindTooltip = bindTooltip;
})(window);

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "15.24.14";
const APP_BUILD = "forced-behavior-sleep-rhythm";
const APP_VERSION = "16.07.09";
const APP_BUILD = "noise-cleanup";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -20,7 +20,6 @@
shouldDropRainWater(worldRef, dt = 0) {
return Boolean(
worldRef?.weather === "light_rain" &&
(worldRef.itemCounts?.water || 0) < 24 &&
Math.random() < Math.max(0, Number(dt) || 0) * 0.12
);
},

View file

@ -43,6 +43,7 @@ class World { constructor() {
this.lastPhase = "";
this.weather = "sunny";
this.fieldType = "garden";
this.groundType = "soil";
this.fieldZoom = 1;
this.cameraX = 0;
this.cameraY = 0;
@ -67,7 +68,7 @@ class World { constructor() {
this.drawList = [];
this.itemCounts = {};
this.effectCounts = {};
this.colonyMood = { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } };
this.colonyMood = { id: "relaxed", label: "安定", description: "標準状態", effects: { personality: {} } };
this.lastColonyMoodDay = 0;
this.foodSpoilagePenalty = 0;
this.foodSpoilageEvents = 0;

View file

@ -6,35 +6,40 @@
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
updateAnts(dt) {
if (!this.ants) this.ants = [];
const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
for (const nest of nests) {
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt);
const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length;
const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside);
const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0);
if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) {
const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32);
const a = new AntActor(this, {
kind: "worker",
homeId: nest.id,
x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18),
y: nest.y - nest.r * 1.00 + rand(-3, 3),
hp,
maxHp: ANT_WORKER_HP || 32,
});
this.ants.push(a);
this.emit?.("ant:spawned", { ant: a, nest });
this.antNestUpdateAccum = (this.antNestUpdateAccum || 0) + dt;
if (this.antNestUpdateAccum >= 3.0) {
const nestDt = this.antNestUpdateAccum;
this.antNestUpdateAccum = 0;
const nests = this.itemsOfType?.("ant_nest") || (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
for (const nest of nests) {
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45;
this.drawListDirty = true;
nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - nestDt);
let outside = 0;
for (const a of this.ants) if (a && !a.dead && a.kind === "worker" && a.homeId === nest.id) outside += 1;
const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside);
const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0);
if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) {
const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32);
const a = new AntActor(this, {
kind: "worker",
homeId: nest.id,
x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18),
y: nest.y - nest.r * 1.00 + rand(-3, 3),
hp,
maxHp: ANT_WORKER_HP || 32,
});
this.ants.push(a);
this.emit?.("ant:spawned", { ant: a, nest });
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
nest.antSpawnTimer = rand(2.5, 5.5) + outside * 0.65;
this.drawListDirty = true;
}
}
}
const aliveAntCount = (this.ants || []).reduce((n, a) => n + (a && !a.dead ? 1 : 0), 0);
const q = this.performanceQuality ? this.performanceQuality() : null;
const stride = Math.max(1, Math.min(3, q?.antStride || (aliveAntCount >= 70 ? 3 : aliveAntCount >= 28 ? 2 : 1)));
const stride = aliveAntCount >= 28 ? 3 : 2;
this.antUpdatePhase = ((this.antUpdatePhase || 0) + 1) % stride;
for (let i = 0; i < this.ants.length; i++) {
const a = this.ants[i];
@ -110,7 +115,8 @@
const spot = this.findPlacementSpot ? this.findPlacementSpot(nest, { allowOriginal: true, maxRadius: 80, attempts: 10 }) : { x, y };
if (!spot) continue;
let nearest = Infinity;
for (const it of this.items || []) {
const nests = this.itemsOfType ? this.itemsOfType("ant_nest") : (this.items || []);
for (const it of nests) {
if (!it || it.dead || it.type !== "ant_nest") continue;
nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y));
}

View file

@ -79,8 +79,8 @@
if (t.addStress) t.addStress(22 * p, { threshold: 8 });
t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "爆竹の爆風でパニックになっている", wake: true }); }
if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); }
let nx = dx / d;
let ny = dy / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
@ -94,7 +94,7 @@
panic: true,
target: { x, y },
fearTimer: 1.7 + p,
thought: "爆竹の爆風でパニックになっている",
thought: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
});
t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
@ -111,7 +111,7 @@
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
});
let blastedBalls = 0;
for (const ball of this.items) {
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
let dx = ball.x - x;
let dy = ball.y - y;
@ -181,12 +181,12 @@
panic: true,
target: { x, y },
fearTimer: 1.7 + q,
thought: "爆発病の爆発に巻き込まれている",
thought: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b",
});
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8);
o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85);
if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "爆発病の爆発に巻き込まれている", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "爆発病の爆発に巻き込まれている", wake: true }); }
if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); }
o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85);
o.fallMax = Math.max(o.fallMax || 0, o.fallTimer);
o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
@ -200,7 +200,7 @@
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale,
});
for (const ball of this.items) {
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
let dx = ball.x - x;
let dy = ball.y - y;
@ -279,29 +279,23 @@
it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160);
it.stage = "fresh";
it.burstFromOshibyo = true;
this.items.push(it);
this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
this.addItem?.(it, "zunchi-burst") || this.items.push(it);
}
this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" }));
this.log?.(`${source?.name || "たりない"}から溜まっていたずんちが弾け飛んだ。`, "accident", { participants: source?.name ? [source] : [] });
this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] });
audio.place?.("zunchi");
this.rebuildSpatial?.(true);
this.ensureSpatial?.("zunchi-burst");
},
spawnZunchi(x, y) {
const zunchiLimit = CONFIG.zunchiLimit ?? 56;
if ((this.itemCounts.zunchi || 0) >= zunchiLimit) {
let oldest = null;
for (const it of this.items) {
if (it.type !== "zunchi" || it.dead) continue;
if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it;
}
if (oldest) oldest.amount = 0;
}
spawnZunchi(x, y, source = null) {
const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40));
it.amount = 260;
this.items.push(it);
this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
it.stage = "fresh";
it.producedById = source?.id || "";
it.producedAt = this.time || 0;
it.spawnGrace = Math.max(it.spawnGrace || 0, 10.0);
it.eatProtectedUntil = (this.time || 0) + 90;
this.addItem?.(it, "zunchi-spawn") || this.items.push(it);
audio.place?.("zunchi");
},
@ -392,33 +386,6 @@
const occ = this.bedOccupancy(bed);
return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
},
bestBedFor(t, maxDist = 360) {
const choose = (kind) => {
let best = null;
let bestScore = Infinity;
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue;
if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) {
const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : [];
if (occupants.length >= this.nestBoxCapacity(bed)) continue;
}
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
const d = dist(t, bed);
const occ = this.bedOccupancy(bed);
const comfort = this.bedComfort(bed);
const crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34;
const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty;
if (score < bestScore) { best = bed; bestScore = score; }
}
return best;
};
const owned = (this.nearbyItems(t.x, t.y, maxDist) || [])
.filter(bed => bed && !bed.dead && bed.ownerId === t.id && bed.roles?.sleepPlace)
.sort((a, b) => dist(t, a) - dist(t, b))[0] || null;
return owned || choose("nest_box") || choose("bed") || choose("grass_bed");
},
relationNotice(aId, bId, kind, cooldown = 30) {
const key = [aId, bId].sort().join(":") + `:${kind}`;
const last = this.relationNotices?.[key] || -Infinity;
@ -439,32 +406,11 @@
entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy;
}
if (options.panic && entity instanceof Tarinai) {
if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
}
return true;
},
applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) {
if (!entity || !Number.isFinite(strength)) return false;
let dx = (entity.x || 0) - sourceX;
let dy = (entity.y || 0) - sourceY;
let d = Math.hypot(dx, dy);
if (!Number.isFinite(d) || d < 0.001) {
const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2);
dx = Math.cos(a);
dy = Math.sin(a);
d = 1;
}
const random = options.random || 0;
return this.applyImpulse(
entity,
dx / d * strength + rand(-random, random),
dy / d * strength + rand(-random, random),
options,
);
},
applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) {
if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false;
const normalized = options.cause || cause || "\u885d\u7a81";
@ -492,8 +438,8 @@
victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42));
attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42));
this.emit?.("fight:lost", { winner: attacker, loser: victim, damage });
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" });
else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" });
else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
const dx = victim.x - attacker.x;
const dy = victim.y - attacker.y;
const d = Math.hypot(dx, dy) || 1;
@ -579,9 +525,9 @@
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu.");
t.thought = "げんこつがこわい";
t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044";
} else {
t.thought = isStone ? "石が落ちてきてパニックになっている" : "落ちてきた道具に当たって混乱している";
t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b";
}
t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25));
t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26));
@ -613,17 +559,6 @@
this.log(`${label}\u304c\u843d\u3061\u3001${victims.join("\u3068")}\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
}
},
countBallChasers(ball, except = null) {
if (!ball) return 0;
let count = 0;
for (const t of this.tarinai || []) {
if (!t || t === except || t.dead) continue;
if (t.state === "play_ball" && t.target === ball) count += 1;
}
return count;
},
limitBallChasers() {
if (!this.itemCounts?.ball) return;
const groups = new Map();
@ -641,7 +576,7 @@
return dist(a, ball) + ap - (dist(b, ball) + bp);
});
for (const t of arr.slice(5)) {
t.goIdle("ボールが混み合っているので眺めている");
t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b");
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45);
}
}
@ -682,8 +617,9 @@
if ((this.itemCounts?.ball || 0) < 2) return;
const now = this.time || 0;
if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map();
const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball");
const balls = this.itemsOfType?.("ball") || (this.items || []).filter(it => it && !it.dead && it.type === "ball");
for (const a of balls) {
if (!a || a.dead || a.type !== "ball") continue;
const ar = a.r || 18;
const avx = a.vx || 0;
const avy = a.vy || 0;
@ -769,7 +705,7 @@
resolveBallInteractions(dt) {
if (!this.itemCounts?.ball) return;
for (const ball of this.items) {
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84;
@ -816,14 +752,14 @@
panic: true,
target: { x: ball.x, y: ball.y },
fearTimer: 0.95,
thought: "高速のボールにぶつかってパニックになっている",
thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
});
this.applyImpactDamage(t, damage, "\u885d\u7a81");
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true });
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer);
@ -929,7 +865,7 @@
panic: true,
target: { x: impactor.x, y: impactor.y },
fearTimer: 0.55,
thought: "高速でぶつかって混乱している",
thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b",
});
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
@ -937,8 +873,8 @@
if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81");
for (const t of [target, impactor]) {
if (!t || t.dead) continue;
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true });
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true });
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);

View file

@ -82,11 +82,6 @@
mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"),
];
},
nestBoxTopRect(it) {
return this.nestBoxSolidRects(it)[0] || null;
},
nestBoxEntryPoint(box) {
const base = this.nestBoxBaseRect(box);
if (!base) return { x: box?.x || 0, y: box?.y || 0 };
@ -299,19 +294,5 @@
}
return null;
},
findGrassSproutSpot(source, fertile = false) {
const attempts = fertile ? 24 : 10;
const minR = fertile ? Math.max(30, source.r * 1.9) : 18;
const maxR = fertile ? 125 : 72;
for (let i = 0; i < attempts; i++) {
const angle = rand(0, Math.PI * 2);
const radius = rand(minR, maxR);
const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44);
const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
if (this.grassSpotOpen(x, y, { minGrassGap: fertile ? 24 : 22, avoidTarinai: true })) return { x, y };
}
return null;
}
}));
})(typeof window !== "undefined" ? window : globalThis);

75
js/world_event_effects.js Normal file
View file

@ -0,0 +1,75 @@
"use strict";
(function (global) {
const events = global.TarinaiEvents;
if (!events || events.__worldEventEffectsBound) return;
events.__worldEventEffectsBound = true;
events.on("world:phase", () => {
global.audio?.phase?.();
});
function playLogSoundForEntry(entry = {}) {
if (!entry || entry.hiddenFromObservation || entry.sound === false || entry.silentAudio) return;
const audio = global.audio;
if (!audio) return;
const kind = entry.kind || "note";
const text = String(entry.text || "");
if (kind === "death") return audio.death?.();
if (kind === "birth") return audio.birth?.();
if (kind === "fight") return /決着|勝|負|覚え/.test(text) ? audio.fightFinish?.() : audio.fight?.();
if (kind === "food") return audio.eat?.();
if (kind === "grass") return audio.play?.("sfx_grass", { category: "ops", minGap: 0.25 });
if (kind === "weather") return audio.phase?.();
if (kind === "relation") return audio.notify?.();
if (kind === "accident") {
if (/配置|置いた|設置/.test(text)) return audio.place?.(/爆竹/.test(text) ? "firecracker" : "");
if (/げんこつ/.test(text)) return audio.genkotsuImpact?.();
if (/爆竹|爆発/.test(text)) return audio.explode?.();
if (/石/.test(text)) return audio.stoneImpact?.();
if (/ボール/.test(text)) return audio.ballHit?.();
return audio.damage?.(18);
}
if (kind === "event") {
if (/女王アリ/.test(text)) return audio.queenAnt?.();
if (/アリ|巣/.test(text)) return audio.antNest?.();
if (/病|発病/.test(text)) return audio.disease?.();
if (/回復|治/.test(text)) return audio.heal?.();
}
}
function renderFreezePanel(worldRef) {
if (global.TarinaiFreezePanel?.render && global.TarinaiFreezeStore?.frozenList) {
global.TarinaiFreezePanel.render(global.TarinaiFreezeStore.frozenList(worldRef));
return;
}
global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef);
}
events.on("log:entry", (event) => {
const detail = event.detail || {};
const worldRef = detail.world || global.world;
const entry = detail.entry;
if (!entry) return;
playLogSoundForEntry(entry);
if (typeof renderLog === "function") renderLog(worldRef?.logs || []);
if (typeof pushLogNotification === "function") pushLogNotification(entry);
});
events.on("tool:placed", (event) => {
const detail = event.detail || {};
if (detail.dropped) global.audio?.place?.(detail.type || detail.item?.type || "");
});
events.on("freeze:changed", (event) => {
const detail = event.detail || {};
const worldRef = detail.world || global.world;
renderFreezePanel(worldRef);
if (detail.renderWorld !== false) {
if (typeof renderSelected === "function") renderSelected();
if (typeof renderStats === "function") renderStats();
if (typeof render === "function") render();
}
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -19,6 +19,11 @@
this.items = [];
this.ants = [];
this.effects = [];
this.itemCounts = {};
this.effectCounts = {};
this.itemTypeBuckets = new Map();
this.itemIdMap = new Map();
this.itemBucketsDirty = false;
this.logs = [];
this.events = window.TarinaiEvents || null;
this.eventCounters = {};
@ -27,6 +32,8 @@
this.drawSortTimer = 0;
this.drawListDirty = true;
this.antUpdatePhase = 0;
this.grassGrowthTimer = 0;
this.nextGrassLimitCheckAt = 0;
this.selected = null;
this.deadCount = 0;
this.liveIdNext = 1;
@ -39,6 +46,7 @@
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
this.fightPairCooldowns = {};
this.foodSpoilagePenalty = 0;
this.foodSpoilageEvents = 0;
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
@ -50,6 +58,7 @@
this.clampCamera();
this.lastPhase = this.phaseName();
this.weather = "sunny";
this.groundType = window.TarinaiGround?.exists?.(this.groundType || "soil") ? (this.groundType || "soil") : "soil";
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
const grassCount = field.grass ?? 18;
@ -62,8 +71,15 @@
generation: 1,
});
}
for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)));
this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
for (let i = 0; i < grassCount; i++) {
const grass = new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70));
globalThis.TarinaiGrass?.setStage?.(grass, Math.floor(rand(0, 2.999)), { force: true });
if (this.addItem) this.addItem(grass, "reset-grass");
else this.items.push(grass);
}
this.addItem?.(new Item("stone", this.w * 0.68, this.h * 0.32), "reset-stone") || this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
this.updateItemCounts();
this.enforceGrassLimit?.("reset-grass-limit");
this.updateItemCounts();
this.updateEffectCounts();
this.markTerrainDirty?.("reset");
@ -73,7 +89,7 @@
},
tarinaiFamilyKey(t) {
return t?.familyKey || t?.archiveKey || t?.id || "";
return t?.familyKey || t?.id || "";
},
assignTarinaiLiveId(t) {
@ -342,7 +358,7 @@
if (!other || other === dead || other.dead) continue;
if (other.target === dead || other.target?.id === deadId) {
other.target = null;
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("参照していた相手がいなくなった");
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("\u53c2\u7167\u3057\u3066\u3044\u305f\u76f8\u624b\u304c\u3044\u306a\u304f\u306a\u3063\u305f");
changed = true;
}
if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; }
@ -372,7 +388,6 @@
this.markFamilyTreeDirty("death");
}
}
this.notifyDeathToRelations?.(t, finalReason);
this.familyPrunePending = true;
},
@ -405,8 +420,66 @@
return t;
},
nestBoxOccupants(box, limit = 5) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = (this.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id);
return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants;
},
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, Infinity);
const capacity = this.nestBoxCapacity(best);
return { box: best, occupants, capacity };
},
nestBoxTooltipLines(box) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = this.nestBoxOccupants(box, Infinity);
const names = occupants.map(t => t?.name).filter(Boolean).slice(0, 5);
const more = occupants.length > names.length ? `、ほか${occupants.length - names.length}` : "";
return ["巣箱", names.length ? `入ってる子: ${names.join("、")}${more}` : "入ってる子: なし"];
},
pointerOwnedBedInfo() {
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 !== "grass_bed") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; }
}
if (!best) return null;
const owner = this.liveTarinaiById?.(best.ownerId) || null;
return { bed: best, owner };
},
ownedBedTooltipLines(bed) {
if (!bed || bed.dead || bed.type !== "grass_bed") return [];
const owner = this.liveTarinaiById?.(bed.ownerId) || null;
return ["かんたんベッド", `持ち主: ${owner?.name || "不明"}`];
},
pointerItemTooltipInfo() {
const nest = this.pointerNestBoxInfo?.();
if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: "nest_box" };
const bedInfo = this.pointerOwnedBedInfo?.();
if (bedInfo?.bed) return { target: bedInfo.bed, lines: this.ownedBedTooltipLines(bedInfo.bed), kind: "grass_bed" };
return null;
},
startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false;
if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) return false;
if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false;
if (this.areParentChild(a, b)) return false;
if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) return false;
@ -428,11 +501,35 @@
b.birthRitualRole = 1;
a.birthRitualLeader = true;
b.birthRitualLeader = false;
a.setActionState("birth_ritual", { target: b, reason: "繁殖の前ぶれをしている" });
b.setActionState("birth_ritual", { target: a, reason: "繁殖の前ぶれをしている" });
if (typeof setLiveActionText === "function") {
setLiveActionText(a, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${b.name || "相手"}と繁殖の前ぶれをしている`, target: b });
setLiveActionText(b, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${a.name || "相手"}と繁殖の前ぶれをしている`, target: a });
const ritualReason = "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
const applyRitualAction = (actor, partner) => {
if (!actor) return false;
if (typeof actor.setActionState === "function") {
actor.setActionState("birth_ritual", { target: partner || null, reason: ritualReason, need: "social", subNeed: "mate", actionId: "birth_ritual", phase: "acting" });
return true;
}
actor.state = "birth_ritual";
actor.target = partner || null;
actor.thought = ritualReason;
actor.sleeping = false;
if (typeof setBehaviorText === "function") {
setBehaviorText(actor, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: ritualReason, reasonText: ritualReason, target: partner || null, phase: "acting", source: "state" });
} else if (typeof patchTarinaiBehavior === "function") {
patchTarinaiBehavior(actor, { actionId: "birth_ritual", phase: "acting", target: partner || null, source: "state", need: "social", subNeed: "mate", reason: ritualReason, label: ritualReason, text: ritualReason });
}
return true;
};
applyRitualAction(a, b);
applyRitualAction(b, a);
if (typeof setBehaviorText === "function") {
const aBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(a) : a.behavior;
const bBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(b) : b.behavior;
const aLoveCause = (aBehavior?.source === "love_mochi" || (a.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${b.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const bLoveCause = (bBehavior?.source === "love_mochi" || (b.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${a.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const aText = typeof buildReasonText === "function" ? buildReasonText("social", aBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, a, this, { causeText: aLoveCause }) : `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`;
const bText = typeof buildReasonText === "function" ? buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause }) : `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`;
setBehaviorText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b });
setBehaviorText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a });
}
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
@ -447,8 +544,8 @@
a.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null;
a.birthRitualLeader = b.birthRitualLeader = false;
a.goIdle("誕生の前ぶれが終わった");
b.goIdle("誕生の前ぶれが終わった");
a.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
b.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) {
a.birthRitualRole = b.birthRitualRole = 0;
return null;
@ -573,17 +670,44 @@
if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey);
if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey);
this.familyCleanVersion = null;
audio.birth();
this.lastBirthAt = this.time;
if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] });
return child;
},
fightPairKey(a, b) {
const aid = a?.id || a?.familyKey || "";
const bid = b?.id || b?.familyKey || "";
if (!aid || !bid) return "";
return String(aid) < String(bid) ? `${aid}:${bid}` : `${bid}:${aid}`;
},
fightPairCooldownRemaining(a, b) {
const key = this.fightPairKey?.(a, b);
if (!key) return 0;
const until = Number(this.fightPairCooldowns?.[key] || 0) || 0;
return Math.max(0, until - (this.time || 0));
},
markFightPairCooldown(a, b, seconds = 3.2) {
const key = this.fightPairKey?.(a, b);
if (!key) return;
this.fightPairCooldowns = this.fightPairCooldowns || {};
this.fightPairCooldowns[key] = Math.max(Number(this.fightPairCooldowns[key] || 0) || 0, (this.time || 0) + Math.max(0.2, Number(seconds) || 3.2));
},
isAlreadyFightingPair(a, b) {
if (!a || !b) return false;
return a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id) && ((b.fightTargetIds || []).includes(a.id) || b.fightTargetId === a.id);
},
startForcedFight(a, b) {
if (!a || !b || a === b || a.dead || b.dead) return false;
if (!this.canFightPair(a, b)) return false;
const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id);
const alreadyFighting = this.isAlreadyFightingPair?.(a, b);
if (alreadyFighting) return true;
if ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04) return false;
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
for (const t of [a, b]) {
t.birthRitualTimer = 0;
t.birthRitualMax = 0;
@ -601,7 +725,7 @@
t.goIdle("");
}
this.startFightMochiIntimidation(a, b);
this.startFight(a, b);
this.startFight(a, b, { forced: true, initiator: a });
return true;
},
@ -629,33 +753,36 @@
},
startConflict(a, b) {
if (!a || !b || a.dead || b.dead) return;
if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return;
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; }
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return;
if (!a || !b || a.dead || b.dead) return false;
if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return false;
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) return this.startForcedFight(a, b);
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return false;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false;
const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a;
if (!this.canFightPair(actor, target)) return;
if (!this.canFightPair(actor, target)) return false;
const actorScore = actor === a ? scoreA : scoreB;
const targetScore = actor === a ? scoreB : scoreA;
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04;
const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90);
if (canIntimidate && Math.random() < intimidationChance) {
this.startIntimidation(actor, target, actorScore, targetScore);
return;
return this.startIntimidation(actor, target, actorScore, targetScore);
}
this.startFight(a, b);
return this.startFight(actor, target, { initiator: actor });
},
startIntimidation(actor, target, actorScore = 0, targetScore = 0) {
if (!actor || !target || actor.dead || target.dead) return false;
if ((actor.fightTimer || 0) > 0.04 || (target.fightTimer || 0) > 0.04) return false;
if ((actor.intimidateTimer || 0) > 0.04 || (target.intimidateTimer || 0) > 0.04) return false;
if (this.fightPairCooldownRemaining?.(actor, target) > 0.04) return false;
if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
const fearLoad = ((typeof target.relationTo === "function" ? (target.relationTo(actor.id).fear || 0) : 0) * 0.72) + target.personalityProfile().fear * 8;
const actorAggression = Math.max(0, actor.currentPersonality?.aggression || 0);
@ -670,17 +797,17 @@
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
target.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35));
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65));
actor.intimidateTargetId = target.id;
actor.setActionState("intimidate", { target, reason: "相手を威嚇している" });
if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: `${target.name || "相手"}を威嚇している`, target, phase: "perform", source: "behavior" });
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
if (typeof setBehaviorText === "function") setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.42,
size: actor.radius * 0.55,
color: "rgba(145, 106, 55, 0.70)",
}));
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.95, 1.55));
target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
if (Math.random() >= successChance) {
actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation");
@ -688,20 +815,20 @@
actor.intimidateTimer = 0;
actor.intimidateTargetId = null;
target.intimidatedTimer = 0;
if (actor.state === "intimidate") actor.goIdle("威嚇に失敗した");
if (target.state === "panic") target.goIdle("威嚇から戻った");
if (actor.state === "intimidate") actor.goIdle("\u5a01\u5687\u306b\u5931\u6557\u3057\u305f");
if (target.state === "panic") target.goIdle("\u5a01\u5687\u304b\u3089\u623b\u3063\u305f");
actor.spriteLockUntil = 0;
target.spriteLockUntil = 0;
this.startFight(actor, target);
this.startFight(actor, target, { initiator: actor });
return true;
}
actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation");
target.defeatedById = actor.id;
target.fightWinnerId = actor.id;
target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4));
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "威嚇されて逃げている", fear: 1.35, wake: true, cause: "intimidated" });
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "威嚇されて逃げている", wake: true }); }
target.defeatedTimer = Math.max(target.defeatedTimer, rand(1.8, 2.8));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.2, 2.0));
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" });
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); }
this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54);
target.vy += rand(-18, 18);
@ -711,21 +838,41 @@
this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] });
actor.lastLog = target.lastLog = this.time;
}
this.markFightPairCooldown?.(actor, target, 2.8);
return true;
},
startFight(a, b) {
if (!a || !b || a.dead || b.dead) return;
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
a.setActionState("fight", { target: b, reason: `${b.name || "相手"}と喧嘩している` });
b.setActionState("fight", { target: a, reason: `${a.name || "相手"}と喧嘩している` });
if (typeof setLiveActionText === "function") {
setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${b.name || "相手"}と喧嘩している`, target: b, phase: "perform", source: "behavior" });
setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${a.name || "相手"}と喧嘩している`, target: a, phase: "perform", source: "behavior" });
startFight(a, b, opts = {}) {
if (!a || !b || a.dead || b.dead) return false;
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return false;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
if (this.isAlreadyFightingPair?.(a, b)) return true;
const allowForcedStart = Boolean(opts?.forced);
if (!allowForcedStart && ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04 || (a.intimidateTimer || 0) > 0.04 || (b.intimidateTimer || 0) > 0.04)) return false;
if (!allowForcedStart && this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
this.markFightPairCooldown?.(a, b, 3.4);
const initiator = opts?.initiator === b ? b : a;
const receiver = initiator === a ? b : a;
const causeFor = (self, other) => {
if (((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(self) : self.behavior)?.source === "fight_mochi") || (self.fightMochiTimer || 0) > 0.04) return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c";
if (self === receiver && other === initiator) return `${other.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`;
return `${other.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`;
};
const textFor = (self, other) => typeof buildReasonText === "function"
? buildReasonText("social", (typeof getTarinaiBehaviorTiedNeeds === "function" ? getTarinaiBehaviorTiedNeeds(self, "social") : self.behavior?.tiedNeeds) || ["social"], { id: "fight_rival", need: "social", subNeed: "conflict", label: self === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` }, self, this, { causeText: causeFor(self, other) })
: `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b`;
const reasonA = textFor(a, b);
const reasonB = textFor(b, a);
a.counterAttackFromId = a === receiver ? initiator.id : null;
b.counterAttackFromId = b === receiver ? initiator.id : null;
a.setActionState("fight", { target: b, reason: reasonA });
b.setActionState("fight", { target: a, reason: reasonB });
if (typeof setBehaviorText === "function") {
setBehaviorText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" });
setBehaviorText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" });
}
a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0));
b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0));
a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8));
b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8));
a.nextHeadbutt = this.time + rand(0.08, 0.18);
b.nextHeadbutt = a.nextHeadbutt;
a.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
@ -758,9 +905,10 @@
}));
audio.fight();
if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b] });
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b], sound: false });
a.lastLog = b.lastLog = this.time;
}
return true;
}
}));
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -18,10 +18,10 @@
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
<div class="signboard-editor-titlebar">
<h2 id="signboardEditorTitle">\u770b\u677f</h2>
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">×</button>
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
</div>
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT}" rows="3" spellcheck="false"></textarea>
<div class="signboard-editor-meta"><span>6文字×3行まで</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT + SIGNBOARD_LINES - 1}" rows="3" cols="6" wrap="off" spellcheck="false" inputmode="text"></textarea>
<div class="signboard-editor-meta"><span>6\u6587\u5b57\u00d73\u884c\u307e\u3067</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
<div class="signboard-editor-actions">
<button id="signboardEditorClear" class="btn" type="button">\u6d88\u3059</button>
<button id="signboardEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
@ -38,25 +38,35 @@
return chars.slice(0, Math.max(0, limit)).join("");
}
function sanitizeSignboardText(value = "") {
function sanitizeSignboardText(value = "", opts = {}) {
const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " ");
const rawLines = normalized.split("\n");
const rawLines = normalized.split("\n").slice(0, SIGNBOARD_LINES);
const out = [];
for (const rawLine of rawLines) {
let rest = String(rawLine || "").trim();
if (!rest && out.length < SIGNBOARD_LINES) { out.push(""); continue; }
while (rest && out.length < SIGNBOARD_LINES) {
out.push(sliceGraphemes(rest, SIGNBOARD_CHARS_PER_LINE));
rest = Array.from(rest).slice(SIGNBOARD_CHARS_PER_LINE).join("");
}
if (out.length >= SIGNBOARD_LINES) break;
for (let i = 0; i < SIGNBOARD_LINES; i += 1) {
const rawLine = rawLines[i];
if (rawLine == null) break;
out.push(sliceGraphemes(String(rawLine), SIGNBOARD_CHARS_PER_LINE));
}
return out.slice(0, SIGNBOARD_LINES).join("\n").replace(/\n+$/g, "").trim();
const next = out.join("\n");
return opts.trimTrailing === false ? next : next.replace(/\n+$/g, "");
}
function signboardVisibleLength(text = "") {
return Array.from(String(text || "").replace(/\n/g, "")).length;
}
function replaceSignboardEditorSelection(area, text = "") {
if (!area) return;
const value = String(area.value || "");
const start = Number(area.selectionStart || 0);
const end = Number(area.selectionEnd || start);
const next = sanitizeSignboardText(value.slice(0, start) + text + value.slice(end), { trimTrailing: false });
area.value = next;
const caret = Math.min(next.length, start + String(text || "").length);
area.selectionStart = caret;
area.selectionEnd = caret;
}
function openSignboardEditor(sign, worldRef) {
if (!sign) return false;
const dialog = ensureSignboardEditor();
@ -88,9 +98,41 @@
dialog.__worldRef = worldRef;
if (area) {
area.value = sanitizeSignboardText(sign.text || "");
area.onkeydown = (e) => {
const value = String(area.value || "");
const start = Number(area.selectionStart || 0);
const end = Number(area.selectionEnd || start);
const before = value.slice(0, start);
const selected = value.slice(start, end);
const lineIndex = before.split("\n").length - 1;
const lines = value.split("\n");
const currentLine = lines[lineIndex] || "";
const replacing = selected.length > 0;
if (e.key === "Enter") {
const selectedLineBreaks = (selected.match(/\n/g) || []).length;
e.preventDefault();
if (lines.length - selectedLineBreaks < SIGNBOARD_LINES) {
replaceSignboardEditorSelection(area, "\n");
syncCount();
}
return;
}
if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const selectedSameLine = !selected.includes("\n");
const selectedCount = selectedSameLine ? Array.from(selected).length : 0;
if (!replacing && Array.from(currentLine).length >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
else if (replacing && selectedSameLine && Array.from(currentLine).length - selectedCount >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
}
};
area.onpaste = (e) => {
e.preventDefault();
const text = e.clipboardData?.getData?.("text") || "";
replaceSignboardEditorSelection(area, text);
syncCount();
};
area.oninput = () => {
const clean = sanitizeSignboardText(area.value || "");
if (area.value !== clean && signboardVisibleLength(area.value || "") > SIGNBOARD_TEXT_LIMIT) area.value = clean;
const clean = sanitizeSignboardText(area.value || "", { trimTrailing: false });
if (area.value !== clean) area.value = clean;
syncCount();
};
setTimeout(() => { area.focus(); area.select(); syncCount(); }, 0);
@ -219,6 +261,14 @@
}
}
}
if (item.type === "grass_bed") {
const spacing = Math.max(46, (item.r || 18) * 3.0);
for (const it of this.nearbyItems(item.x, item.y, spacing + 70) || []) {
if (!it || it === item || it.dead || it.type !== "grass_bed") continue;
const required = spacing + Math.max(0, ((it.r || 18) - (item.r || 18)) * 0.5);
if (distXY(item.x, item.y, it.x, it.y) < required) return true;
}
}
return false;
},
@ -302,6 +352,10 @@
if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; }
}
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem;
if (foundT) {
const lodged = foundT.currentLodgedPin?.() || (this.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === foundT.id);
if (lodged && lodged.detachPushpin) return lodged;
}
if (foundT && foundItem) {
const td = distXY(x, y, foundT.x, foundT.y);
return td <= foundItemD + 8 ? foundT : foundItem;
@ -311,18 +365,10 @@
placeItem(item, dropped = true) {
if (!item) return null;
if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) {
showToast("\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8349\u306e\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u307e\u3059\u3002");
if (item.type === "grass" && !this.canAddGrass?.(1)) {
showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`);
return null;
}
if (this.isFenceType(item.type)) {
this.compactItems();
this.updateItemCounts();
if (this.liveFenceCount() >= this.fenceLimit()) {
showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
return null;
}
}
if (this.placementBlocked(item)) {
showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002");
return null;
@ -332,13 +378,14 @@
item.dropTimer = item.dropMax;
item.dropImpactDone = false;
}
this.items.push(item);
this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
this.markTerrainDirty?.(`place:${item.type}`);
const placed = this.addItem?.(item, `place:${item.type}`);
if (!placed) {
if (item.type === "grass") showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`);
return null;
}
this.emit("tool:place", { item, type: item.type, dropped });
this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type });
if (dropped) audio.place?.(item.type);
this.rebuildSpatial(true);
this.ensureSpatial?.(`place:${item.type}`);
return item;
},
@ -391,32 +438,11 @@
const spot = this.edgeSpawnPoint(x, y, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 });
t.wanderAngle = spot.angle;
audio.birth();
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
return;
}
if (tool === "delete") {
let best = null, bestD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (it.dead) continue;
const visualHit = Math.max(22, (it.r || 12) * 2.0);
const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit;
const d = distXY(x, y, it.x, it.y);
if (d < hit && d < bestD) { best = it; bestD = d; }
}
if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
if (isPinType(best.type) && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
best.amount = 0;
audio.delete?.();
this.compactItems();
this.updateItemCounts();
this.rebuildSpatial();
const label = toolLabel(best.type);
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return;
}
if (this.useToolAt?.(x, y)) return;
const itemType = toolItemType(tool);
if (itemType) {
@ -450,33 +476,6 @@
return type === "ball_poke" || type === "avoid_relationship";
},
playLogSoundForEntry(entry = {}) {
if (!entry || entry.hiddenFromObservation || typeof audio === "undefined") return;
const kind = entry.kind || "note";
const text = String(entry.text || "");
if (kind === "death") return audio.death?.();
if (kind === "birth") return audio.birth?.();
if (kind === "fight") return /\u6c7a\u7740|\u52dd|\u8ca0|\u899a\u3048/.test(text) ? audio.fightFinish?.() : audio.fight?.();
if (kind === "food") return audio.eat?.();
if (kind === "grass") return audio.play?.("sfx_grass", { category: "ops", minGap: 0.25 });
if (kind === "weather") return audio.phase?.();
if (kind === "relation") return audio.notify?.();
if (kind === "accident") {
if (/\u914d\u7f6e|\u7f6e\u3044\u305f|\u8a2d\u7f6e/.test(text)) return audio.place?.(/\u7206\u7af9/.test(text) ? "firecracker" : "");
if (/\u3052\u3093\u3053\u3064/.test(text)) return audio.genkotsuImpact?.();
if (/\u7206\u7af9|\u7206\u767a/.test(text)) return audio.explode?.();
if (/\u77f3/.test(text)) return audio.stoneImpact?.();
if (/\u30dc\u30fc\u30eb/.test(text)) return audio.ballHit?.();
return audio.damage?.(18);
}
if (kind === "event") {
if (/\u5973\u738b\u30a2\u30ea/.test(text)) return audio.queenAnt?.();
if (/\u30a2\u30ea|\u5de3/.test(text)) return audio.antNest?.();
if (/\u75c5|\u767a\u75c5/.test(text)) return audio.disease?.();
if (/\u56de\u5fa9|\u6cbb/.test(text)) return audio.heal?.();
}
},
log(text, kind = null, options = {}) {
const entry = {
time: this.time,
@ -484,6 +483,7 @@
kind: kind || this.inferLogKind(text),
eventType: options.eventType || "",
hiddenFromObservation: Boolean(options.hiddenFromObservation),
sound: options.sound === false ? false : true,
participants: (options.participants || []).filter(Boolean).map(t => ({
id: t.id || t.releasedLiveId || "",
liveId: t.id || t.releasedLiveId || "",
@ -513,11 +513,8 @@
if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind);
}
this.logs.unshift(entry);
this.emit("log:entry", { entry });
this.playLogSoundForEntry?.(entry);
this.logs = this.logs.slice(0, 100);
renderLog(this.logs);
if (typeof pushLogNotification === "function") pushLogNotification(entry);
this.emit("log:entry", { world: this, entry });
}
}));
})(typeof window !== "undefined" ? window : globalThis);

Some files were not shown because too many files have changed in this diff Show more