Compare commits

...

4 commits

Author SHA1 Message Date
077d4ab9ac stable 2026-06-26 19:04:32 +09:00
86226ccc94 stable 2026-06-26 12:46:32 +09:00
fbdac9ebf1 not baaad 2026-06-25 22:30:35 +09:00
16074232aa hhmm 2026-06-25 17:43:24 +09:00
128 changed files with 10679 additions and 2879 deletions

View file

@ -1,5 +1,5 @@
{
"version": "16.07.09",
"version": "38.00.00",
"css": [
"css/base.css",
"css/layout.css",
@ -19,15 +19,35 @@
"js/input_mode_manager.js",
"js/math.js",
"js/physics_helpers.js",
"js/collision_footprint_system.js",
"js/mechanical_system.js",
"js/constraint_system.js",
"js/assets.js",
"js/audio.js",
"js/perf_profiler.js",
"js/render.js",
"js/sim_core.js",
"js/tarinai_seed_factory.js",
"js/structures.js",
"js/items.js",
"js/item_type_initializers.js",
"js/item_lifecycle_support.js",
"js/item_lifecycle_runtime.js",
"js/item_update_policy.js",
"js/item_dynamic_tool_system.js",
"js/item_dynamic_ball_system.js",
"js/item_dynamic_duplicator_system.js",
"js/item_dynamic_pin_system.js",
"js/item_dynamic_zunchi_system.js",
"js/item_dynamic_system.js",
"js/item_lifecycle_decay_system.js",
"js/item_lifecycle_growth_system.js",
"js/item_lifecycle_step_frame.js",
"js/item_lifecycle_step_decay.js",
"js/item_lifecycle_step_dynamic.js",
"js/item_lifecycle_step_growth.js",
"js/item_lifecycle_pipeline.js",
"js/item_runtime.js",
"js/structure_lifecycle.js",
"js/item_render_runtime.js",
"js/ants.js",
@ -48,7 +68,24 @@
"js/tarinai_building_behavior.js",
"js/tarinai_action_definitions.js",
"js/tarinai_needs_items.js",
"js/tarinai_forced_action_planner_system.js",
"js/tarinai_action_planner_system.js",
"js/tarinai_need_planner_system.js",
"js/tarinai_item_interaction_context.js",
"js/tarinai_food_interaction_system.js",
"js/tarinai_contact_item_system.js",
"js/tarinai_item_interaction_system.js",
"js/tarinai_sunbath_system.js",
"js/tarinai_cursor_care_system.js",
"js/tarinai_local_environment_system.js",
"js/tarinai_social_move_life.js",
"js/tarinai_update_step_frame.js",
"js/tarinai_update_step_ai.js",
"js/tarinai_update_step_environment.js",
"js/tarinai_update_step_movement.js",
"js/tarinai_update_step_health.js",
"js/tarinai_update_pipeline.js",
"js/tarinai_runtime.js",
"js/tarinai_render.js",
"js/world.js",
"js/world_view.js",
@ -60,27 +97,36 @@
"js/world_ants_system.js",
"js/weather_system.js",
"js/item_update_scheduler.js",
"js/simulation_runtime_helpers.js",
"js/tarinai_update_policy.js",
"js/simulation_environment_system.js",
"js/simulation_item_ant_system.js",
"js/simulation_effects_system.js",
"js/simulation_creature_system.js",
"js/simulation_maintenance_system.js",
"js/simulation_ambient_system.js",
"js/simulation_systems.js",
"js/world_update_phases.js",
"js/system_order.js",
"js/simulation.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/command_dispatcher.js",
"js/text_catalog.js",
"js/ui.js",
"js/ui_log.js",
"js/ui_helpers.js",
"js/ui_selected.js",
"js/save_schema.js",
"js/snapshot_system.js",
"js/restore_coordinator.js",
"js/history_system.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/ui_tooltips.js",
"js/ui_layout_dialogs.js",
"js/ui_ground.js",
@ -103,7 +149,8 @@
"assets/objects/*.webp",
"assets/sprites/*.webp",
"assets/sounds/*.wav",
"assets/sounds/*.mp3"
"assets/sounds/*.mp3",
"assets/objects/*.png"
],
"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."
"notes": "Boundary cleanup v38: freezer removed, washing expanded and continuous, laboratory ground added, and save schema synchronized."
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/ui/favicon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
assets/ui/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -347,38 +347,40 @@ h1 {
.tool-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
gap: 8px;
gap: 6px;
}
.tool {
--tool-main-icon-size: 30px;
--tool-main-icon-size: 28px;
--tool-icon-zoom: 1.05;
--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;
grid-template-columns: 29px minmax(0,1fr);
align-items: center;
justify-items: stretch;
gap: 3px;
padding: 4px 4px 4px 2px;
min-height: 47px;
font-size: 10.2px;
line-height: 1.06;
letter-spacing: -0.015em;
text-align: center;
text-align: left;
position: relative;
overflow: hidden;
background-repeat: no-repeat;
background-position: center 4px, center;
background-position: left 3px center, center;
background-size: var(--tool-main-icon-size) var(--tool-main-icon-size), cover;
}
.tool::before {
content: "";
position: absolute;
left: 50%;
top: 3px;
left: 3px;
top: 50%;
width: var(--tool-main-icon-size);
height: var(--tool-main-icon-size);
transform: translateX(-50%);
transform: translateY(-50%) scale(var(--tool-icon-zoom, 1));
transform-origin: center;
background: var(--tool-icon) center / contain no-repeat;
opacity: 0.98;
z-index: 1;
@ -397,33 +399,41 @@ h1 {
position: relative;
z-index: 2;
display: block;
grid-row: 2;
align-self: start;
grid-column: 2;
align-self: center;
justify-self: start;
max-width: 100%;
min-width: 0;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.12;
font-size: 10.2px;
line-height: 1.10;
font-weight: 700;
max-width: 5.05em;
word-break: normal;
overflow-wrap: anywhere;
text-shadow: 0 1px 0 rgba(255,255,255,0.62);
}
.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 { --tool-icon: none; grid-template-columns: 29px minmax(0,1fr); min-height: 47px; padding-top: 4px; }
.tool.tool-has-canvas-icon::before,
.tool.tool-has-canvas-icon::after { background: none !important; }
.tool-sprite-preview {
position: absolute;
left: 50%;
top: -5px;
width: 48px;
height: 48px;
transform: translateX(-50%);
left: 1px;
top: 50%;
width: 30px;
height: 30px;
transform: translateY(-50%);
pointer-events: none;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool-sprite-watermark { display: none !important; }
.tool[data-tool="giant_drug"] .tool-sprite-preview { width: 39px; height: 39px; left: -4px; }
.tool[data-tool="dwarf_drug"] .tool-sprite-preview,
.tool[data-tool="oshibyo"] .tool-sprite-preview { width: 22px; height: 22px; left: 5px; }
.tool[data-tool="poke"], .tool[data-tool="water_hose"] { --tool-icon-zoom: 0.92; }
.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"); }
@ -436,7 +446,6 @@ h1 {
.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.webp"); }
.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.webp"); }
.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.webp"); }
.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.webp"); }
.tool[data-tool="signboard"] { --tool-icon: url("../assets/ui/tool_signboard.webp"); }
.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp"); }
@ -574,3 +583,113 @@ h1 {
background: #efffde;
border-color: rgba(103,178,44,0.46);
}
.rotator-editor {
position: fixed;
inset: 0;
z-index: 43;
display: grid;
place-items: center;
padding: 18px;
background: rgba(34, 29, 22, 0.34);
backdrop-filter: blur(3px);
}
.rotator-editor.hidden { display: none; }
.rotator-editor-panel {
width: min(620px, calc(100vw - 28px));
border-radius: 14px;
padding: 14px;
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.97));
border: 1px solid rgba(94, 70, 42, 0.20);
box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72);
}
.rotator-editor-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(94, 70, 42, 0.13);
}
.rotator-editor-titlebar h2 { margin: 0; font-size: 16px; letter-spacing: .03em; }
.rotator-editor-close {
width: 30px;
height: 30px;
border-radius: 999px;
border: 1px solid rgba(84,68,48,0.16);
background: rgba(255,255,255,0.72);
color: var(--ink);
font-size: 19px;
line-height: 1;
cursor: pointer;
}
.rotator-editor-close:hover { background: rgba(255,248,232,0.96); }
.rotator-editor-controls {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 8px;
}
.rotator-editor-controls label {
display: grid;
grid-template-columns: auto 1fr 72px auto;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ink);
}
.rotator-editor-controls input[type="number"] {
width: 72px;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid rgba(84,68,48,0.18);
background: rgba(255,255,255,0.84);
padding: 4px 6px;
}
.rotator-editor-tools {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.rotator-editor-tools button {
border: 1px solid rgba(84,68,48,0.16);
background: rgba(255,255,255,0.74);
color: var(--ink);
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
cursor: pointer;
}
.rotator-editor-tools button.active {
background: rgba(137, 101, 198, 0.18);
border-color: rgba(112, 80, 170, 0.38);
font-weight: 700;
}
#rotatorCanvas {
width: 100%;
height: min(58vh, 360px);
display: block;
border-radius: 12px;
border: 1px solid rgba(84,68,48,0.20);
background: #f7f3ea;
touch-action: none;
cursor: crosshair;
}
.rotator-editor-hint {
margin: 7px 2px 0;
color: var(--muted);
font-size: 12px;
}
.rotator-editor-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
@media (max-width: 560px) {
.rotator-editor-panel { padding: 10px; }
.rotator-editor-controls label { grid-template-columns: 1fr; align-items: stretch; }
#rotatorCanvas { height: 320px; }
}

View file

@ -675,3 +675,113 @@
font-size: 16px;
line-height: 1.2;
}
.ground-stat[data-ground-toggle] {
cursor: pointer;
user-select: none;
}
.ground-stat[data-ground-toggle]:focus-visible,
.ground-type-btn:focus-visible {
outline: 2px solid rgba(103,178,44,0.58);
outline-offset: 2px;
}
.save-hash-profile {
display: grid;
gap: 8px;
padding: 9px 10px;
border: 1px solid rgba(115, 91, 62, 0.18);
border-radius: 12px;
background: rgba(255,255,255,0.56);
color: #4a3929;
font-size: 12px;
}
.save-hash-profile:empty {
display: none;
}
.save-profile-summary {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
}
.save-profile-summary span {
padding: 3px 7px;
border-radius: 999px;
background: rgba(115, 91, 62, 0.08);
}
.save-profile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 5px;
}
.save-profile-grid > div {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 6px;
align-items: center;
padding: 4px 6px;
border-radius: 8px;
background: rgba(255,255,255,0.54);
}
.save-profile-grid em {
opacity: 0.62;
font-style: normal;
}
.save-profile-items {
display: flex;
flex-wrap: wrap;
gap: 5px 7px;
align-items: center;
}
.save-profile-items span {
padding: 2px 6px;
border-radius: 999px;
background: rgba(111, 153, 102, 0.13);
}
/* v36: pin only the operation tool category under the quick-scroll tabs. */
.tool-category[data-tool-category="operate"]:not(.collapsed) {
position: sticky;
top: 58px;
z-index: 11;
overflow: hidden;
background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(221, 237, 255, 0.98));
border-color: rgba(82,128,190,0.36);
box-shadow: 0 8px 18px rgba(58, 82, 112, 0.13), inset 0 1px 0 rgba(255,255,255,0.82);
}
.tool-category[data-tool-category="operate"] .tool-category-toggle {
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98));
border-bottom-color: rgba(82,128,190,0.30);
}
.tool-category[data-tool-category="operate"] .tool-category-body {
background: rgba(238, 247, 255, 0.98);
}
.tool-category[data-tool-category="operate"] .tool-category-body {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 5px;
}
.tool-category[data-tool-category="operate"] .tool {
grid-template-columns: 1fr;
min-height: 38px;
padding: 4px;
justify-items: center;
align-items: center;
text-align: center;
background: rgba(255,255,255,0.96);
border-color: rgba(82,128,190,0.28);
}
.tool-category[data-tool-category="operate"] .tool-label {
display: none;
}
.tool-category[data-tool-category="operate"] .tool::before {
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(var(--tool-icon-zoom, 1));
}
.tool-category[data-tool-category="operate"] .tool-sprite-preview {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
@media (max-width: 980px) {
.tool-category[data-tool-category="operate"]:not(.collapsed) { top: 58px; }
}

View file

@ -4,11 +4,19 @@
.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="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); }
.tool[data-tool="area_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[data-tool="copy"] { --tool-emoji: "⧉"; }
.tool[data-tool="area_delete"] { --tool-emoji: "✕"; }
.tool[data-tool="rope"] { --tool-emoji: "╰"; }
.tool[data-tool="rod"] { --tool-emoji: "━"; }
.tool[data-tool="reciprocator"] { --tool-emoji: "⇄"; }
.tool[data-tool="undo"] { --tool-emoji: "↶"; }
.tool[data-tool="redo"] { --tool-emoji: "↷"; }
.tool.tool-emoji-icon { --tool-icon: none; }
.tool.tool-emoji-icon::before {
content: var(--tool-emoji);
@ -16,11 +24,35 @@
display: grid;
place-items: center;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 29px;
font-size: 34px;
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[data-tool="poke"].tool-emoji-icon::before,
.tool[data-tool="water_hose"].tool-emoji-icon::before { font-size: 25px; }
.tool[data-tool="rope"].tool-emoji-icon::before,
.tool[data-tool="rod"].tool-emoji-icon::before,
.tool[data-tool="reciprocator"].tool-emoji-icon::before,
.tool[data-tool="undo"].tool-emoji-icon::before,
.tool[data-tool="redo"].tool-emoji-icon::before { font-size: 29px; font-family: var(--font, sans-serif); }
.tool[data-tool="delete"],
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; }
.tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; }
.tool[data-tool="area_delete"].tool-emoji-icon::before {
font-size: 30px;
color: #f3a7aa;
text-shadow: 0 1px 0 rgba(255,255,255,0.65), 0 2px 5px rgba(146,54,62,0.16);
filter: none;
}
.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; }
.tool[data-tool="observe"],
.tool[data-tool="new"] { --tool-icon-zoom: 1.20; }
.tool[data-tool="giant_drug"] { --tool-icon-zoom: 1.58; }
.tool[data-tool="dwarf_drug"],
.tool[data-tool="oshibyo"] { --tool-icon-zoom: 0.62; }
.tool.selected {
background: linear-gradient(180deg, #ffffff, #efffde);
border-color: rgba(103, 178, 44, 0.52);
@ -105,7 +137,7 @@
box-shadow: 0 1px 4px rgba(42,36,29,0.16);
pointer-events: none;
}
.chart-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin: 3px 0 8px; }
.chart-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: 3px 0 8px; }
.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; }
.chart-tabs button.active { background: #efffde; border-color: rgba(103,178,44,0.46); font-weight: 700; }
.log-accident { border-left-color: rgba(226, 136, 54, 0.76); }
@ -316,6 +348,7 @@ meter {
.tool-category[data-tool-category="food"] { --tool-cat-a: rgba(236,252,219,0.82); --tool-cat-b: rgba(214,244,194,0.74); --tool-cat-line: rgba(96,156,72,0.26); }
.tool-category[data-tool-category="medicine"] { --tool-cat-a: rgba(243,231,255,0.84); --tool-cat-b: rgba(230,214,252,0.78); --tool-cat-line: rgba(136,94,184,0.28); }
.tool-category[data-tool-category="habitat"] { --tool-cat-a: rgba(255,245,218,0.84); --tool-cat-b: rgba(250,229,186,0.76); --tool-cat-line: rgba(174,125,54,0.28); }
.tool-category[data-tool-category="physics"] { --tool-cat-a: rgba(226,247,246,0.84); --tool-cat-b: rgba(199,232,231,0.76); --tool-cat-line: rgba(70,145,148,0.28); }
.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)); }

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -3,24 +3,25 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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=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" />
<title>たりない観察</title>
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=16.07.17" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=16.07.17" />
<link rel="shortcut icon" href="favicon.ico?v=16.07.17" />
<link rel="stylesheet" href="css/base.css?v=38.00.00" />
<link rel="stylesheet" href="css/layout.css?v=38.00.00" />
<link rel="stylesheet" href="css/panel.css?v=38.00.00" />
<link rel="stylesheet" href="css/components.css?v=38.00.00" />
<link rel="stylesheet" href="css/mobile.css?v=38.00.00" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
<div class="loading-panel">
<div class="loading-kicker">個体&#x30B3;&#x30ED;&#x30CB;&#x30FC;</div>
<div class="loading-kicker">たりない観察</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>&#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,13 +29,13 @@
<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>個体&#x89B3;&#x5BDF;</h1>
<h1>たりない観察</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">個体&#x306E;&#x751F;&#x614B;</button>
<button id="ecologyBtn" class="btn">たりないの生態</button>
<div id="soundMenu" class="sound-menu">
<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;">
@ -53,7 +54,7 @@
<main class="layout">
<section class="stage-wrap">
<canvas id="gameCanvas" aria-label="個体&#x89B3;&#x5BDF;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;"></canvas>
<canvas id="gameCanvas" aria-label="たりない観察キャンバス"></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>
@ -72,7 +73,7 @@
</nav>
<section id="selectedCard" class="card selected-card hidden">
<div class="selected-head">
<h2>個体&#x30C7;&#x30FC;&#x30BF;</h2>
<h2>たりないデータ</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>
@ -83,7 +84,6 @@
<div class="panel-card-body">
<div class="tool-body">
<div id="toolPalette" class="tool-palette" aria-live="polite"></div>
<p class="hint">&#x540C;&#x3058;&#x9053;&#x5177;&#x30DC;&#x30BF;&#x30F3;&#x3092;&#x3082;&#x3046;&#x4E00;&#x5EA6;&#x62BC;&#x3059;&#x3068;&#x3001;&#x5BFE;&#x5FDC;&#x9053;&#x5177;&#x306E;&#x5927;&#x304D;&#x3055;&#x304C;&#x5C0F;&#x30FB;&#x4E2D;&#x30FB;&#x5927;&#x3067;&#x5207;&#x308A;&#x66FF;&#x308F;&#x308A;&#x307E;&#x3059;&#x3002;</p>
</div>
</div>
</section>
@ -95,9 +95,9 @@
<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 class="stat ground-stat" data-ground-toggle role="button" tabindex="0"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="地面&#x3092;&#x304D;&#x308A;&#x304B;&#x3048;&#x308B;"><strong id="statGroundType"></strong></button></div>
</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="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">個体数</button><button data-chart="life">欲求</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>
@ -158,9 +158,8 @@
<h2 id="creditsDialogTitle">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</h2>
<div class="credits-content">
<p><strong>&#x88FD;&#x4F5C;:</strong> 333</p>
<p><strong>個体:</strong> &#x3080;&#x3089;&#x3055;&#x304D;&#x6C0F;</p>
<p><strong>イラスト:</strong> むらさき氏 / いらすとや: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></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>
<div class="field-dialog-actions credits-actions">
<button id="creditsCloseBtn" class="btn" type="button">&#x9589;&#x3058;&#x308B;</button>
@ -171,7 +170,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;物量&#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;&#x3082;&#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>
@ -181,7 +180,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">個体&#x306E;&#x751F;&#x614B;</h2>
<h2 id="ecologyDialogTitle">たりないの生態</h2>
<div id="ecologyGrid" class="ecology-grid"></div>
<div class="field-dialog-actions">
<button id="ecologyCloseBtn" class="btn" type="button">&#x9589;&#x3058;&#x308B;</button>
@ -190,94 +189,140 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<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>
<script src="js/version.js?v=38.00.00" defer></script>
<script src="js/event_bus.js?v=38.00.00" defer></script>
<script src="js/registry_base.js?v=38.00.00" defer></script>
<script src="js/disease_registry.js?v=38.00.00" defer></script>
<script src="js/sound_pack.js?v=38.00.00" defer></script>
<script src="js/item_registry.js?v=38.00.00" defer></script>
<script src="js/data.js?v=38.00.00" defer></script>
<script src="js/ground_types.js?v=38.00.00" defer></script>
<script src="js/input_mode_manager.js?v=38.00.00" defer></script>
<script src="js/math.js?v=38.00.00" defer></script>
<script src="js/physics_helpers.js?v=38.00.00" defer></script>
<script src="js/collision_footprint_system.js?v=38.00.00" defer></script>
<script src="js/mechanical_system.js?v=38.00.00" defer></script>
<script src="js/constraint_system.js?v=38.00.00" defer></script>
<script src="js/assets.js?v=38.00.00" defer></script>
<script src="js/audio.js?v=38.00.00" defer></script>
<script src="js/perf_profiler.js?v=38.00.00" defer></script>
<script src="js/render.js?v=38.00.00" defer></script>
<script src="js/sim_core.js?v=38.00.00" defer></script>
<script src="js/tarinai_seed_factory.js?v=38.00.00" defer></script>
<script src="js/structures.js?v=38.00.00" defer></script>
<script src="js/items.js?v=38.00.00" defer></script>
<script src="js/item_type_initializers.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_support.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_runtime.js?v=38.00.00" defer></script>
<script src="js/item_update_policy.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_tool_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_ball_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_pin_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_system.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=38.00.00" defer></script>
<script src="js/item_runtime.js?v=38.00.00" defer></script>
<script src="js/structure_lifecycle.js?v=38.00.00" defer></script>
<script src="js/item_render_runtime.js?v=38.00.00" defer></script>
<script src="js/ants.js?v=38.00.00" defer></script>
<script src="js/health.js?v=38.00.00" defer></script>
<script src="js/tarinai.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_spec.js?v=38.00.00" defer></script>
<script src="js/tarinai_behavior_state.js?v=38.00.00" defer></script>
<script src="js/tarinai_identity_social.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_state.js?v=38.00.00" defer></script>
<script src="js/tarinai_disease_nest.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_effects.js?v=38.00.00" defer></script>
<script src="js/tarinai_needs_core.js?v=38.00.00" defer></script>
<script src="js/tarinai_behavior_text.js?v=38.00.00" defer></script>
<script src="js/tarinai_forced_behavior.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_targeting.js?v=38.00.00" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=38.00.00" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=38.00.00" defer></script>
<script src="js/tarinai_building_behavior.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_definitions.js?v=38.00.00" defer></script>
<script src="js/tarinai_needs_items.js?v=38.00.00" defer></script>
<script src="js/tarinai_forced_action_planner_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_planner_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_need_planner_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=38.00.00" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_contact_item_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_sunbath_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_local_environment_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_social_move_life.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_frame.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_ai.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_environment.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_movement.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_health.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_pipeline.js?v=38.00.00" defer></script>
<script src="js/tarinai_runtime.js?v=38.00.00" defer></script>
<script src="js/tarinai_render.js?v=38.00.00" defer></script>
<script src="js/world.js?v=38.00.00" defer></script>
<script src="js/world_view.js?v=38.00.00" defer></script>
<script src="js/family_graph.js?v=38.00.00" defer></script>
<script src="js/world_family_social.js?v=38.00.00" defer></script>
<script src="js/world_combat_effects.js?v=38.00.00" defer></script>
<script src="js/world_environment.js?v=38.00.00" defer></script>
<script src="js/world_spatial_budget.js?v=38.00.00" defer></script>
<script src="js/world_ants_system.js?v=38.00.00" defer></script>
<script src="js/weather_system.js?v=38.00.00" defer></script>
<script src="js/item_update_scheduler.js?v=38.00.00" defer></script>
<script src="js/simulation_runtime_helpers.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_policy.js?v=38.00.00" defer></script>
<script src="js/simulation_environment_system.js?v=38.00.00" defer></script>
<script src="js/simulation_item_ant_system.js?v=38.00.00" defer></script>
<script src="js/simulation_effects_system.js?v=38.00.00" defer></script>
<script src="js/simulation_creature_system.js?v=38.00.00" defer></script>
<script src="js/simulation_maintenance_system.js?v=38.00.00" defer></script>
<script src="js/simulation_ambient_system.js?v=38.00.00" defer></script>
<script src="js/simulation_systems.js?v=38.00.00" defer></script>
<script src="js/world_update_phases.js?v=38.00.00" defer></script>
<script src="js/system_order.js?v=38.00.00" defer></script>
<script src="js/simulation.js?v=38.00.00" defer></script>
<script src="js/world_update.js?v=38.00.00" defer></script>
<script src="js/world_tool_actions.js?v=38.00.00" defer></script>
<script src="js/world_placement_log.js?v=38.00.00" defer></script>
<script src="js/world_event_effects.js?v=38.00.00" defer></script>
<script src="js/command_dispatcher.js?v=38.00.00" defer></script>
<script src="js/text_catalog.js?v=38.00.00" defer></script>
<script src="js/ui.js?v=38.00.00" defer></script>
<script src="js/ui_log.js?v=38.00.00" defer></script>
<script src="js/ui_helpers.js?v=38.00.00" defer></script>
<script src="js/ui_selected.js?v=38.00.00" defer></script>
<script src="js/save_schema.js?v=38.00.00" defer></script>
<script src="js/snapshot_system.js?v=38.00.00" defer></script>
<script src="js/restore_coordinator.js?v=38.00.00" defer></script>
<script src="js/history_system.js?v=38.00.00" defer></script>
<script src="js/save_codec.js?v=38.00.00" defer></script>
<script src="js/save_storage.js?v=38.00.00" defer></script>
<script src="js/save_system.js?v=38.00.00" defer></script>
<script src="js/patch_helpers.js?v=38.00.00" defer></script>
<script src="js/ui_tooltips.js?v=38.00.00" defer></script>
<script src="js/ui_layout_dialogs.js?v=38.00.00" defer></script>
<script src="js/ui_ground.js?v=38.00.00" defer></script>
<script src="js/ui_tools.js?v=38.00.00" defer></script>
<script src="js/ui_input_shared.js?v=38.00.00" defer></script>
<script src="js/ui_input_touch.js?v=38.00.00" defer></script>
<script src="js/ui_input_mouse.js?v=38.00.00" defer></script>
<script src="js/ui_bind.js?v=38.00.00" defer></script>
<script src="js/ui_charts.js?v=38.00.00" defer></script>
<script src="js/ui_family_data.js?v=38.00.00" defer></script>
<script src="js/ui_family_async.js?v=38.00.00" defer></script>
<script src="js/ui_family_layout.js?v=38.00.00" defer></script>
<script src="js/ui_family_paths.js?v=38.00.00" defer></script>
<script src="js/ui_family_render.js?v=38.00.00" defer></script>
<script src="js/main.js?v=38.00.00" defer></script>
<script src="js/debug_tools.js?v=38.00.00" defer></script>
</body>
</html>

View file

@ -175,7 +175,7 @@ class AntActor {
const haulers = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId === t.id && a.targetToken === t.liveToken).length;
if (haulers >= ANT_NEST_MAX_OUTSIDE) continue;
}
const weak = clamp((100 - (t.energy || 0)) / 100, 0, 1);
const weak = 1 - (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(t) : clamp((t.energy || 0) / Math.max(1, t.maxEnergy || 100), 0, 1));
const score = d - weak * 18 + stableUnit(`${this.id}:${t.id}`, "ant-prey") * 6;
if (score < bestScore) {
best = t;
@ -314,7 +314,7 @@ class AntActor {
if (leadHauler) {
target.x = clamp(target.x + ux * speed * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
target.y = clamp(target.y + uy * speed * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
target.energy = clamp((target.energy || 0) - dt * 0.028, 0, 100);
target.energy = clamp((target.energy || 0) - dt * 0.028, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(target) : (Number(target.maxEnergy) || 100));
}
const offsetAngle = this.seed + this.age * 2.0 + (haulers > 1 ? this.activeHaulersForTarget().findIndex(a => a === this) * 1.7 : 0);
const ring = target.radius * (0.64 + 0.06 * Math.min(haulers, 3));
@ -327,8 +327,8 @@ class AntActor {
const stillSleeping = Boolean(target.sleeping || targetWasSleeping || target.state === "sleep");
const aggressiveAttack = !stillSleeping && (target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack");
const struggle = stillSleeping ? 0 : (aggressiveAttack
? (1.25 + aggression * 1.6 + clamp((target.energy || 0) / 100, 0, 1) * 0.35) / Math.max(1, haulers * 0.9)
: (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers));
? (1.25 + aggression * 1.6 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.35) / Math.max(1, haulers * 0.9)
: (0.42 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.18) / Math.max(1, haulers));
this.hp -= dt * struggle;
if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) {

View file

@ -0,0 +1,238 @@
"use strict";
// Layer: physics/footprints
// Shared hit-testing and placement-overlap geometry for oriented rect, circle,
// and item footprint checks. This keeps placement, grabbing, and physics using
// the same current-frame shape instead of separate radius approximations.
(function (global) {
function num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function rectAabb(rect) {
if (!rect) return null;
if (!rect.oriented) return rect;
return {
left: num(rect.left), right: num(rect.right), top: num(rect.top), bottom: num(rect.bottom),
cx: num(rect.cx), cy: num(rect.cy), halfW: num(rect.halfW), halfH: num(rect.halfH),
angle: num(rect.angle), cos: Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle)),
sin: Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle)), oriented: true,
type: rect.type, item: rect.item,
};
}
function rectLocalPoint(rect, x, y) {
const dx = num(x) - num(rect?.cx);
const dy = num(y) - num(rect?.cy);
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
function rectWorldVector(rect, x, y) {
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: num(x) * c - num(y) * s, y: num(x) * s + num(y) * c };
}
function pointInRect(x, y, rect, padding = 0) {
if (!rect) return false;
const pad = Math.max(0, num(padding));
if (rect.oriented) {
const local = rectLocalPoint(rect, x, y);
return local.x >= -num(rect.halfW) - pad && local.x <= num(rect.halfW) + pad
&& local.y >= -num(rect.halfH) - pad && local.y <= num(rect.halfH) + pad;
}
return num(x) >= num(rect.left) - pad && num(x) <= num(rect.right) + pad
&& num(y) >= num(rect.top) - pad && num(y) <= num(rect.bottom) + pad;
}
function rectCorners(rect, padding = 0) {
const r = rectAabb(rect);
const pad = Math.max(0, num(padding));
if (!r) return [];
if (!r.oriented) {
return [
{ x: num(r.left) - pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.bottom) + pad },
{ x: num(r.left) - pad, y: num(r.bottom) + pad },
];
}
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(num(r.angle));
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(num(r.angle));
const hw = num(r.halfW) + pad;
const hh = num(r.halfH) + pad;
const out = [];
for (const [lx, ly] of [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]) {
out.push({ x: num(r.cx) + lx * c - ly * s, y: num(r.cy) + lx * s + ly * c });
}
return out;
}
function rectAxes(rect) {
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
const s = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
return [{ x: c, y: s }, { x: -s, y: c }];
}
function projectPoints(points, axis) {
let min = Infinity;
let max = -Infinity;
for (const p of points || []) {
const v = num(p.x) * axis.x + num(p.y) * axis.y;
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
function aabbOverlap(a, b, padding = 0) {
const pad = Math.max(0, num(padding));
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
}
function rectOverlapInfo(a, b, padding = 0) {
if (!aabbOverlap(a, b, padding)) return null;
const ptsA = rectCorners(a, padding);
const ptsB = rectCorners(b, padding);
if (!ptsA.length || !ptsB.length) return null;
let bestOverlap = Infinity;
let bestAxis = null;
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
const len = Math.hypot(axis.x, axis.y) || 1;
const n = { x: axis.x / len, y: axis.y / len };
const pa = projectPoints(ptsA, n);
const pb = projectPoints(ptsB, n);
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
if (overlap <= 0) return null;
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
}
if (!bestAxis) return null;
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
return {
overlap: bestOverlap,
nx: bestAxis.x,
ny: bestAxis.y,
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
};
}
function rectsOverlap(a, b, margin = 0) {
return Boolean(rectOverlapInfo(a, b, margin));
}
function rectCircleOverlap(rect, cx, cy, radius, margin = 0) {
if (!rect) return false;
const r = Math.max(0, num(radius));
const m = Math.max(0, num(margin));
if (rect.oriented) {
const local = rectLocalPoint(rect, cx, cy);
const qx = Math.max(-num(rect.halfW) - m, Math.min(num(rect.halfW) + m, local.x));
const qy = Math.max(-num(rect.halfH) - m, Math.min(num(rect.halfH) + m, local.y));
return Math.hypot(local.x - qx, local.y - qy) < r + m;
}
const px = Math.max(num(rect.left) - m, Math.min(num(rect.right) + m, num(cx)));
const py = Math.max(num(rect.top) - m, Math.min(num(rect.bottom) + m, num(cy)));
return Math.hypot(num(cx) - px, num(cy) - py) < r + m;
}
function itemFootprintRadius(worldRef, item, rects = null) {
if (!item) return 14;
const ownRects = Array.isArray(rects) ? rects : (worldRef?.solidObstacleRects?.(item) || []);
if (ownRects.length) {
let reach = 14;
const ix = num(item.x);
const iy = num(item.y);
for (const r of ownRects) {
for (const p of rectCorners(r, 0)) reach = Math.max(reach, Math.hypot(p.x - ix, p.y - iy));
}
return Math.max(14, reach);
}
const radiusFor = typeof global.itemRadiusFor === "function" ? global.itemRadiusFor : ((type, fallback = 12) => fallback);
return Math.max(14, num(item.r, radiusFor(item.type, 12)) || radiusFor(item.type, 12));
}
function hitTestItem(worldRef, item, x, y, opts = {}) {
if (!item || item.dead) return { hit: false, distance: Infinity };
const padding = Math.max(0, num(opts.padding, 0));
const rects = opts.rects || worldRef?.solidObstacleRects?.(item) || [];
let bestDistance = Math.hypot(num(x) - num(item.x), num(y) - num(item.y));
let hit = false;
if (rects.length) {
for (const rect of rects) {
if (pointInRect(x, y, rect, padding)) hit = true;
bestDistance = Math.min(bestDistance, Math.hypot(num(x) - num(rect.cx, item.x), num(y) - num(rect.cy, item.y)));
}
}
const mechHit = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type);
if (mechHit) {
const hub = Math.max(18, Math.min(40, num(item.rotatorThickness, 12) * 2.3));
if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true;
} else if (!rects.length) {
const mul = num(opts.radiusMultiplier, item.type === "ball" ? 4.0 : 2.2);
const r = Math.max(24, num(item.r, 12) * mul);
hit = bestDistance <= r + padding;
}
return { hit, distance: bestDistance };
}
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "nest_box", "water"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type)));
if (!important.has(item.type) && !isPhysicsItem) return false;
const placingGrass = item.type === "grass";
const itemRects = worldRef.solidObstacleRects?.(item) || [];
const itemRadius = itemFootprintRadius(worldRef, item, itemRects);
const search = Math.max(132, itemRadius * 3.2);
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) {
if (!other || other === item || other.dead) continue;
if (isPhysicsItem && (other.type === "water" || other.type === "grass_bed")) continue;
if (!important.has(other.type)) continue;
if (!placingGrass && other.type === "grass") continue;
const otherRects = worldRef.solidObstacleRects?.(other) || [];
const otherRadius = itemFootprintRadius(worldRef, other, otherRects);
const hasMechanism = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || global.TarinaiMechanicalSystem?.isMechanicalType?.(other.type);
const margin = hasMechanism ? 1.25 : Math.max(4, Math.min(itemRadius, otherRadius) * 0.18);
if (itemRects.length && otherRects.length) {
if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true;
continue;
}
if (itemRects.length) {
if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true;
continue;
}
if (otherRects.length) {
if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true;
continue;
}
if (Math.hypot(num(item.x) - num(other.x), num(item.y) - num(other.y)) < itemRadius + otherRadius + margin) return true;
}
return false;
}
global.TarinaiCollisionFootprints = Object.freeze({
rectLocalPoint,
rectWorldVector,
pointInRect,
rectCorners,
rectAxes,
projectPoints,
aabbOverlap,
rectOverlapInfo,
rectsOverlap,
rectCircleOverlap,
itemFootprintRadius,
hitTestItem,
itemPlacementOverlapBlocked,
});
})(typeof window !== "undefined" ? window : globalThis);

203
js/command_dispatcher.js Normal file
View file

@ -0,0 +1,203 @@
"use strict";
// Layer: commands
// UI calls this facade instead of mutating cross-cutting world state directly.
// Keep this file small: commands normalize user intent, then delegate game rules to World/system modules.
(function (global) {
const DEFAULT_SPEEDS = Object.freeze([1, 2, 4]);
const DEFAULT_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
function emit(worldRef, type, payload) {
worldRef?.emit?.(type, payload);
return payload;
}
function ok(payload = {}) {
return { ok: true, ...payload };
}
function fail(reason, payload = {}) {
return { ok: false, reason, ...payload };
}
function selectTool(worldRef, toolId) {
if (!worldRef) return fail("missing_world");
const id = String(toolId || "observe");
if (worldRef.tool === id) return ok({ toolId: id, changed: false });
worldRef.tool = id;
emit(worldRef, "tool:selected", { toolId: id });
return ok({ toolId: id, changed: true });
}
function setSelection(worldRef, target) {
if (!worldRef) return fail("missing_world");
const next = target || null;
if (worldRef.selected === next) return ok({ selected: next, changed: false });
worldRef.selected = next;
emit(worldRef, "selection:changed", { selected: worldRef.selected });
return ok({ selected: worldRef.selected, changed: true });
}
function clearSelection(worldRef) {
return setSelection(worldRef, null);
}
function focusSelection(worldRef, target, options = {}) {
if (!worldRef) return fail("missing_world");
if (!target || target.dead) return fail("missing_target");
const result = setSelection(worldRef, target);
target.focusPulseTimer = Math.max(target.focusPulseTimer || 0, Number(options.pulse ?? 1.15) || 1.15);
if (options.selectObserve !== false) selectTool(worldRef, "observe");
if (options.center !== false && Number.isFinite(target.x) && Number.isFinite(target.y)) {
const visibleW = worldRef.visibleWorldW ? worldRef.visibleWorldW() : (worldRef.viewportW || worldRef.w || 0);
const visibleH = worldRef.visibleWorldH ? worldRef.visibleWorldH() : (worldRef.viewportH || worldRef.h || 0);
worldRef.cameraX = target.x - visibleW / 2;
worldRef.cameraY = target.y - visibleH / 2;
worldRef.clampCamera?.();
}
return ok({ selected: result.selected || target, target });
}
function toggleSelectedFavorite(worldRef, target = worldRef?.selected || null) {
if (!worldRef) return fail("missing_world");
if (!target || target.dead) return fail("missing_target");
target.favorite = !target.favorite;
emit(worldRef, "selection:favorite-changed", { selected: target, favorite: Boolean(target.favorite) });
return ok({ selected: target, favorite: Boolean(target.favorite) });
}
function scalableToolSet() {
if (typeof global.scalableToolIds === "function") return new Set(global.scalableToolIds());
return new Set();
}
function setToolSize(worldRef, tool, size) {
if (!worldRef) return fail("missing_world");
const id = String(tool || worldRef.tool || "");
const next = String(size || "medium");
if (!id || !scalableToolSet().has(id)) return fail("not_scalable", { toolId: id });
if (!worldRef.toolSizes) worldRef.toolSizes = {};
worldRef.toolSizes[id] = next;
worldRef.toolSize = next;
emit(worldRef, "tool:size-changed", { toolId: id, size: next });
return ok({ toolId: id, size: next });
}
function cycleToolSize(worldRef, tool, order = DEFAULT_SIZE_ORDER) {
if (!worldRef) return fail("missing_world");
const id = String(tool || worldRef.tool || "");
if (!id || !scalableToolSet().has(id)) return fail("not_scalable", { toolId: id });
const sizes = Array.isArray(order) && order.length ? order.map(String) : Array.from(DEFAULT_SIZE_ORDER);
const current = typeof global.normalizedToolSizeFor === "function"
? global.normalizedToolSizeFor(worldRef, id)
: ((worldRef.toolSizes && worldRef.toolSizes[id]) || worldRef.toolSize || "medium");
const idx = sizes.indexOf(current);
const next = sizes[(idx + 1 + sizes.length) % sizes.length];
return setToolSize(worldRef, id, next);
}
function togglePaused(worldRef) {
if (!worldRef) return fail("missing_world");
worldRef.paused = !worldRef.paused;
emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) });
return ok({ paused: Boolean(worldRef.paused) });
}
function setPaused(worldRef, paused) {
if (!worldRef) return fail("missing_world");
worldRef.paused = Boolean(paused);
emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) });
return ok({ paused: Boolean(worldRef.paused) });
}
function cycleSpeed(worldRef, speeds = DEFAULT_SPEEDS) {
if (!worldRef) return fail("missing_world");
const list = Array.isArray(speeds) && speeds.length ? speeds.map(Number).filter(Number.isFinite) : Array.from(DEFAULT_SPEEDS);
const i = list.indexOf(Number(worldRef.speed) || 1);
worldRef.speed = list[(i + 1 + list.length) % list.length];
emit(worldRef, "simulation:speed-changed", { speed: worldRef.speed });
return ok({ speed: worldRef.speed });
}
function primaryPointer(worldRef, x, y) {
if (!worldRef) return fail("missing_world");
const px = Number(x);
const py = Number(y);
if (!Number.isFinite(px) || !Number.isFinite(py)) return fail("invalid_point", { x, y });
if (typeof worldRef.handleClick !== "function") return fail("missing_pointer_handler");
if (worldRef.tool && worldRef.tool !== "observe") global.TarinaiHistory?.capture?.(worldRef, `tool:${worldRef.tool}`);
worldRef.handleClick(px, py);
emit(worldRef, "pointer:primary", { x: px, y: py, toolId: worldRef.tool || "" });
return ok({ x: px, y: py, toolId: worldRef.tool || "" });
}
function dispatch(worldRef, command = {}) {
switch (command.type) {
case "tool.select":
return selectTool(worldRef, command.toolId);
case "tool.size.set":
return setToolSize(worldRef, command.toolId, command.size);
case "tool.size.cycle":
return cycleToolSize(worldRef, command.toolId, command.order);
case "selection.set":
return setSelection(worldRef, command.target || null);
case "selection.clear":
return clearSelection(worldRef);
case "selection.focus":
return focusSelection(worldRef, command.target || null, command.options || {});
case "selection.favorite.toggle":
return toggleSelectedFavorite(worldRef, command.target || worldRef?.selected || null);
case "simulation.paused.set":
return setPaused(worldRef, command.paused);
case "simulation.paused.toggle":
return togglePaused(worldRef);
case "simulation.speed.cycle":
return cycleSpeed(worldRef, command.speeds);
case "pointer.primary":
return primaryPointer(worldRef, command.x, command.y);
case "history.undo": {
const result = global.TarinaiHistory?.undo?.(worldRef) || { ok: false, reason: "missing_history" };
return result.ok ? ok({ action: "undo" }) : fail(result.reason || "undo_failed");
}
case "history.redo": {
const result = global.TarinaiHistory?.redo?.(worldRef) || { ok: false, reason: "missing_history" };
return result.ok ? ok({ action: "redo" }) : fail(result.reason || "redo_failed");
}
default:
return fail("unknown_command", { type: command.type || "" });
}
}
function installWorldFacade() {
const World = global.World;
if (!World?.prototype || World.prototype.dispatchCommand) return;
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
dispatchCommand(command = {}) { return dispatch(this, command); },
selectToolCommand(toolId) { return selectTool(this, toolId); },
setSelectionCommand(target) { return setSelection(this, target); },
clearSelectionCommand() { return clearSelection(this); },
focusSelectionCommand(target, options = {}) { return focusSelection(this, target, options); },
primaryPointerCommand(x, y) { return primaryPointer(this, x, y); },
}));
}
installWorldFacade();
global.TarinaiCommands = Object.freeze({
dispatch,
selectTool,
setSelection,
clearSelection,
focusSelection,
toggleSelectedFavorite,
setToolSize,
cycleToolSize,
setPaused,
togglePaused,
cycleSpeed,
primaryPointer,
undo(worldRef = global.world) { return global.TarinaiHistory?.undo?.(worldRef) || fail("missing_history"); },
redo(worldRef = global.world) { return global.TarinaiHistory?.redo?.(worldRef) || fail("missing_history"); },
});
})(typeof window !== "undefined" ? window : globalThis);

294
js/constraint_system.js Normal file
View file

@ -0,0 +1,294 @@
"use strict";
// Constraint solver for link-like tools.
// Owns attachment resolution and distance constraints for flexible ropes and rigid rods.
(function (global) {
function reciprocatorAxis(item) {
if (global.TarinaiMechanicalSystem?.axis) return global.TarinaiMechanicalSystem.axis(item);
const fallback = typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
const a = typeof normalizedItemAngle === "function" ? normalizedItemAngle(item?.reciprocatorAxisAngle, fallback) : (Number.isFinite(Number(item?.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : fallback);
return { x: Math.cos(a), y: Math.sin(a), angle: a };
}
function reciprocatorHalfTravel(item) {
return Math.max(24, Number(item?.reciprocatorTravel || 150) || 150) * 0.5;
}
function resetReciprocatorAnchorIfMissing(item) {
if (!Number.isFinite(Number(item.reciprocatorAnchorX))) item.reciprocatorAnchorX = item.x || 0;
if (!Number.isFinite(Number(item.reciprocatorAnchorY))) item.reciprocatorAnchorY = item.y || 0;
}
function reciprocatorPhaseFromPosition(item) {
resetReciprocatorAnchorIfMissing(item);
const axis = reciprocatorAxis(item);
const travel = reciprocatorHalfTravel(item);
const dx = (Number(item.x || 0) || 0) - (Number(item.reciprocatorAnchorX || 0) || 0);
const dy = (Number(item.y || 0) || 0) - (Number(item.reciprocatorAnchorY || 0) || 0);
return clamp((dx * axis.x + dy * axis.y) / Math.max(10, travel), -1, 1);
}
function resolveEndpointObject(endpoint, worldRef) {
if (!endpoint || !worldRef) return null;
if (endpoint.kind === "tarinai") {
let found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id);
if (!found && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
found = (worldRef.tarinai || []).find(t => t && !t.dead && distXY(t.x, t.y, endpoint.x, endpoint.y) <= Math.max(40, (t.radius || 20) * 1.6));
if (found) endpoint.id = found.id;
}
return found || null;
}
let found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id);
if (!found && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
found = (worldRef.items || []).find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8));
if (found) endpoint.id = found.id;
}
return found || null;
}
function endpointWorld(endpoint, worldRef, depth = 0, seen = null) {
const obj = resolveEndpointObject(endpoint, worldRef);
if (obj && (obj.type === "rope" || obj.type === "rod") && worldRef) obj.world = worldRef;
if (!obj) return Number.isFinite(endpoint?.x) && Number.isFinite(endpoint?.y) ? { x: endpoint.x, y: endpoint.y, obj: null } : null;
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint.attachT))) {
if (depth > 6) return { x: obj.x || 0, y: obj.y || 0, obj };
const guard = seen || new Set();
if (guard.has(obj.id)) return { x: obj.x || 0, y: obj.y || 0, obj };
guard.add(obj.id);
const a = endpointWorld(obj.linkA, worldRef, depth + 1, guard);
const b = endpointWorld(obj.linkB, worldRef, depth + 1, guard);
guard.delete(obj.id);
if (a && b) {
const t = clamp(Number(endpoint.attachT || 0), 0, 1);
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, obj };
}
}
const lx = Number(endpoint.localX || 0) || 0;
const ly = Number(endpoint.localY || 0) || 0;
if (endpoint.center || (!lx && !ly)) return { x: obj.x || 0, y: obj.y || 0, obj };
const a = typeof itemAngleFor === "function" ? itemAngleFor(obj) : (Number(obj.angle) || 0);
const c = Math.cos(a), s = Math.sin(a);
return { x: (obj.x || 0) + lx * c - ly * s, y: (obj.y || 0) + lx * s + ly * c, obj };
}
function endpointMass(obj) {
if (!obj || obj.dead) return Infinity;
if (obj._heldByPlayer || obj.playerHeld) return Infinity;
if (obj.radius && obj.name) return 1.0;
if (obj.type === "ball") return 0.7;
if (obj.type === "zunchi") return 0.55;
if (obj.type === "rope" || obj.type === "rod") return 1.6;
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) return 3.8;
if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5;
return 2.2;
}
function moveEndpointObject(obj, dx, dy, endpoint, dt, strength = 1, depth = 0) {
if (!obj || !Number.isFinite(dx) || !Number.isFinite(dy)) return false;
if (obj._heldByPlayer || obj.playerHeld) return false;
if (depth > 5) return false;
const moveX = dx * strength;
const moveY = dy * strength;
if (Math.hypot(moveX, moveY) < 0.001) return false;
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint?.attachT))) {
const t = clamp(Number(endpoint.attachT || 0), 0, 1);
let changed = false;
const wA = 1 - t;
const wB = t;
if (obj.linkA) changed = moveEndpointObject(resolveEndpointObject(obj.linkA, obj.world || null), moveX * wA, moveY * wA, obj.linkA, dt, 0.72, depth + 1) || changed;
if (obj.linkB) changed = moveEndpointObject(resolveEndpointObject(obj.linkB, obj.world || null), moveX * wB, moveY * wB, obj.linkB, dt, 0.72, depth + 1) || changed;
return changed;
}
if (obj.radius && obj.name) {
obj.x += moveX;
obj.y += moveY;
obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.18, -180, 180);
obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.18, -180, 180);
obj.target = null;
return true;
}
if (obj.type === "reciprocator") {
const mech = global.TarinaiMechanicalSystem;
mech?.resetAnchor?.(obj) || resetReciprocatorAnchorIfMissing(obj);
const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y };
if (mech?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 34, moveY * 34, 0.55)) return true;
const axis = mech?.axis?.(obj) || reciprocatorAxis(obj);
const along = moveX * axis.x + moveY * axis.y;
obj.reciprocatorVelocity = clamp((obj.reciprocatorVelocity || 0) + along / Math.max(0.016, dt || 0.016) * 0.035, -150, 150);
return Math.abs(along) > 0.001;
}
if (obj.type === "rotator") {
const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y };
if (global.TarinaiMechanicalSystem?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 38, moveY * 38, 0.58)) return true;
const rx = (p.x || obj.x) - obj.x;
const ry = (p.y || obj.y) - obj.y;
const torque = rx * moveY - ry * moveX;
obj.rotatorAngularVelocity = clamp((obj.rotatorAngularVelocity || 0) + torque * 0.0009, -2.0, 2.0);
return true;
}
if (obj.type && obj.type.includes("fence")) return false;
obj.prevX = obj.x;
obj.prevY = obj.y;
obj.x += moveX;
obj.y += moveY;
obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.10, -140, 140);
obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.10, -140, 140);
return true;
}
function updateFlexibleMidParticle(item, a, b, dt, length) {
if (!a || !b || item.type !== "rope") return false;
const ax = a.x, ay = a.y, bx = b.x, by = b.y;
const dx = bx - ax, dy = by - ay;
const d = Math.max(0.001, Math.hypot(dx, dy));
if (!Number.isFinite(item.linkMidX) || !Number.isFinite(item.linkMidY)) {
item.linkMidX = (ax + bx) * 0.5;
item.linkMidY = (ay + by) * 0.5 + Math.max(4, (length - d) * 0.25);
item.linkMidVX = 0;
item.linkMidVY = 0;
}
const prevX = item.linkMidX;
const prevY = item.linkMidY;
const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016));
item.linkMidVX = (Number(item.linkMidVX || 0) || 0) * Math.pow(0.62, step * 60);
item.linkMidVY = (Number(item.linkMidVY || 0) || 0) * Math.pow(0.62, step * 60) + 90 * step;
item.linkMidX += item.linkMidVX * step;
item.linkMidY += item.linkMidVY * step;
const half = Math.max(12, length * 0.5);
if (d >= length * 0.985) {
item.linkMidX = (ax + bx) * 0.5;
item.linkMidY = (ay + by) * 0.5;
item.linkMidVX *= 0.25;
item.linkMidVY *= 0.25;
} else {
for (let i = 0; i < 4; i += 1) {
for (const pnt of [[ax, ay], [bx, by]]) {
const vx = item.linkMidX - pnt[0];
const vy = item.linkMidY - pnt[1];
const dd = Math.max(0.001, Math.hypot(vx, vy));
if (dd > half) {
item.linkMidX = pnt[0] + vx / dd * half;
item.linkMidY = pnt[1] + vy / dd * half;
}
}
}
const minY = Math.min(ay, by) - length * 0.35;
const maxY = Math.max(ay, by) + length * 0.70;
item.linkMidY = clamp(item.linkMidY, minY, maxY);
}
item.linkMidVX = (item.linkMidX - prevX) / step;
item.linkMidVY = (item.linkMidY - prevY) / step;
return Math.hypot(item.linkMidX - prevX, item.linkMidY - prevY) > 0.01;
}
function applyDistanceConstraint(item, a, b, dt, length, mode) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.max(0.001, Math.hypot(dx, dy));
const delta = d - length;
if (mode === "rope" && delta <= 0) return false;
if (mode === "rod" && Math.abs(delta) < 0.35) return false;
const nx = dx / d;
const ny = dy / d;
const ma = endpointMass(a.obj);
const mb = endpointMass(b.obj);
const ia = Number.isFinite(ma) ? 1 / Math.max(0.1, ma) : 0;
const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0;
const sum = ia + ib || 1;
let changed = false;
if (mode === "rope") {
const corr = Math.min(delta, Math.max(2, length * 0.08));
changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 0.85) || changed;
changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 0.85) || changed;
} else {
const iterations = 3;
for (let i = 0; i < iterations; i += 1) {
const corr = clamp(delta / iterations, -Math.max(3, length * 0.18), Math.max(3, length * 0.18));
changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 1.05) || changed;
changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 1.05) || changed;
}
}
return changed;
}
function primeLinkState(item, a, b) {
const d = Math.max(0.001, distXY(a.x, a.y, b.x, b.y));
const length = Math.max(24, Number(item.linkLength || d) || d);
item.x = (a.x + b.x) * 0.5;
item.y = (a.y + b.y) * 0.5;
item.r = Math.max(18, length * 0.5);
item.linkLength = length;
item.linkA.x = a.x; item.linkA.y = a.y;
item.linkB.x = b.x; item.linkB.y = b.y;
return length;
}
function updateFlexibleLink(item, dt, worldRef) {
if (!item || item.dead || item.type !== "rope") return false;
item.amount = 999;
item.world = worldRef || item.world || null;
const a = endpointWorld(item.linkA, worldRef);
const b = endpointWorld(item.linkB, worldRef);
if (!a || !b || !a.obj || !b.obj) {
item.amount = 0;
if (worldRef) worldRef.drawListDirty = true;
return true;
}
const length = primeLinkState(item, a, b);
let changed = updateFlexibleMidParticle(item, a, b, dt, length);
changed = applyDistanceConstraint(item, a, b, dt, length, "rope") || changed;
if (changed && worldRef) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("rope-tension");
}
return changed;
}
function updateRigidLink(item, dt, worldRef) {
if (!item || item.dead || item.type !== "rod") return false;
item.amount = 999;
item.world = worldRef || item.world || null;
const a = endpointWorld(item.linkA, worldRef);
const b = endpointWorld(item.linkB, worldRef);
if (!a || !b || !a.obj || !b.obj) {
item.amount = 0;
if (worldRef) worldRef.drawListDirty = true;
return true;
}
const length = primeLinkState(item, a, b);
const changed = applyDistanceConstraint(item, a, b, dt, length, "rod");
if (changed && worldRef) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("rod-constraint");
}
return changed;
}
function updateLink(item, dt, worldRef) {
if (item?.type === "rope") return updateFlexibleLink(item, dt, worldRef);
if (item?.type === "rod") return updateRigidLink(item, dt, worldRef);
return false;
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
const vx = bx - ax;
const vy = by - ay;
const len2 = vx * vx + vy * vy || 1;
const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1);
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
}
const api = Object.freeze({
endpointWorld,
resolveEndpointObject,
endpointMass,
moveEndpointObject,
updateFlexibleLink,
updateRigidLink,
updateLink,
pointSegmentDistance,
});
global.TarinaiConstraintSystem = api;
global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -26,7 +26,7 @@ const SPRITES = [
{ id: "stress_dizzy", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3050\u308b\u3050\u308b", path: "assets/sprites/tarinai_24_stress_dizzy.webp", actionOnly: true },
{ id: "stress_sweat", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u6c57", path: "assets/sprites/tarinai_25_stress_sweat.webp", actionOnly: true },
{ id: "intimidate", label: "\u5a01\u5687\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_26_intimidate.webp", actionOnly: true },
{ id: "birth_ritual", label: "\u7e41\u6b96\u524d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_27_birth_ritual.webp", actionOnly: true },
{ id: "birth_ritual", label: "へこへこたりない", path: "assets/sprites/tarinai_27_birth_ritual.webp", actionOnly: true },
{ id: "zunchi_slave", label: "\u305a\u3093\u3061\u3069\u308c\u3044", path: "assets/sprites/tarinai_28_zunchi_slave.webp", displayOnly: true },
{ id: "hungry_70", label: "\u7a7a\u817970\u4ee5\u4e0a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_29_hungry_70.webp", displayOnly: true },
{ id: "zunchi_slave_alt", label: "\u305a\u3093\u3061\u3069\u308c\u3044 2", path: "assets/sprites/tarinai_30_zunchi_slave_alt.webp", displayOnly: true },
@ -44,6 +44,7 @@ const DECOR_ASSETS = [
{ id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" },
{ id: "oshibyo", path: "assets/objects/oshibyo.webp" },
{ id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" },
{ id: "plushie_bear", path: "assets/objects/plushie_bear.png" },
];
const ALPHA_BOUNDS = {
@ -84,6 +85,7 @@ const ALPHA_BOUNDS = {
sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 },
sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
plushie_bear: { x: 0, y: 0, w: 150, h: 192, imageW: 150, imageH: 192 },
};
const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"];

View file

@ -1,27 +1,44 @@
"use strict";
(function (global) {
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");
function deps() {
return {
Store: global.TarinaiFreezeStore,
Adapter: global.TarinaiFreezeSnapshotAdapter,
Panel: global.TarinaiFreezePanel,
};
}
function depsReady() {
const d = deps();
return Boolean(d.Store && d.Adapter && d.Panel);
}
function warnMissing(context) {
if (global.console?.warn) console.warn(`freeze_system.js dependencies are not available yet (${context})`);
}
let pendingThawId = "";
function frozenList(worldRef = global.world) {
const { Store } = deps();
if (!Store?.frozenList) return [];
return Store.frozenList(worldRef);
}
function renderFrozenPanel(worldRef = global.world) {
const { Panel } = deps();
if (!Panel?.render) return;
Panel.render(frozenList(worldRef));
}
function saveFrozenStore(worldRef = global.world) {
const { Store } = deps();
if (!Store?.save) return;
Store.save(worldRef);
}
function loadFrozenStore(worldRef = global.world) {
return Store.load(worldRef);
const { Store } = deps();
return Store?.load ? Store.load(worldRef) : [];
}
function refreshWorldAfterFreezeChange(worldRef = global.world, selected = null) {
@ -36,6 +53,8 @@
}
function freezeTarinai(t, worldRef = global.world) {
const { Adapter } = deps();
if (!Adapter?.makeEntry) { warnMissing("freezeTarinai"); return false; }
if (!t || t.dead || !worldRef) return false;
const entry = Adapter.makeEntry(t, worldRef);
worldRef.clearLiveReferencesTo?.(t);
@ -44,13 +63,15 @@
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] });
global.showToast?.(`${t.name}を冷凍保存しました。`);
worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] });
worldRef.emit?.("tarinai:frozen", { entry, tarinai: t });
return true;
}
function thawFrozen(freezeId, x, y, worldRef = global.world) {
const { Adapter } = deps();
if (!Adapter?.thawEntry) { warnMissing("thawFrozen"); return false; }
const list = frozenList(worldRef);
const index = list.findIndex(e => (e.freezeId || e.id) === freezeId);
if (index < 0 || !worldRef) return false;
@ -65,37 +86,44 @@
worldRef.recordFamily?.(t);
list.splice(index, 1);
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] });
global.showToast?.(`${t.name}を解凍しました。`);
worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] });
worldRef.emit?.("tarinai:thawed", { entry, tarinai: t });
return true;
}
function bindFreezeSystem() {
if (!depsReady()) {
warnMissing("bindFreezeSystem");
global.setTimeout?.(() => { if (depsReady()) bindFreezeSystem(); }, 0);
return false;
}
const { Panel } = deps();
const worldRef = global.world;
loadFrozenStore(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");
global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。");
},
onDrop(id, x, y) {
thawFrozen(id, x, y, global.world);
},
});
return true;
}
function tryPendingThawAt(x, y, worldRef = global.world) {
if (!pendingThawId) return false;
const id = pendingThawId;
pendingThawId = "";
Panel.clearPending();
deps().Panel?.clearPending?.();
return thawFrozen(id, x, y, worldRef);
}
function afterWorldRestored(worldRef = global.world) {
Store.ensure(worldRef);
deps().Store?.ensure?.(worldRef);
saveFrozenStore(worldRef);
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
}

View file

@ -1,11 +1,15 @@
"use strict";
const GROUND_TYPE_ORDER = Object.freeze(["soil", "concrete", "blanket", "foot_massage"]);
const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
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 }),
// デフォルト地面ID。表示名は「砂」。
soil: Object.freeze({ id: "soil", label: "砂", description: "普通の地面。", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
dirt: Object.freeze({ id: "dirt", label: "土", description: "草がよく育つ地面。", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }),
concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "青いまだら地面。ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
ice: Object.freeze({ id: "ice", label: "氷", description: "つるつる滑る。草は生えにくい", stressMultiplier: 1.1, grassMultiplier: 0.15, grassGrowthMultiplier: 0.35 }),
laboratory: Object.freeze({ id: "laboratory", label: "実験室", description: "常に晴れ。草は生えず、ずんちはすぐ消える。", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0, fixedWeather: "sunny", zunchiStageMultiplier: 8.0, zunchiDecayMultiplier: 520.0 }),
});
function groundDefinition(id = "soil") {
@ -17,7 +21,7 @@ function groundExists(id = "soil") {
}
function groundLabel(id = "soil") {
return groundDefinition(id).label || "";
return groundDefinition(id).label || "";
}
function groundDescription(id = "soil") {
@ -34,6 +38,12 @@ function normalizedGroundGrassMultiplier(id = "soil") {
return Number.isFinite(mult) && mult >= 0 ? mult : 1;
}
function normalizedGroundGrassGrowthMultiplier(id = "soil") {
const def = groundDefinition(id);
const mult = Number(def.grassGrowthMultiplier ?? def.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 };
@ -70,7 +80,11 @@ window.TarinaiGround = Object.freeze({
description: groundDescription,
stressMultiplier: normalizedGroundStressMultiplier,
grassMultiplier: normalizedGroundGrassMultiplier,
grassGrowthMultiplier: normalizedGroundGrassGrowthMultiplier,
grassLimit: groundGrassLimit,
nextId: nextGroundTypeId,
tooltipText: groundTooltipText,
fixedWeather(id = "soil") { return groundDefinition(id).fixedWeather || ""; },
zunchiStageMultiplier(id = "soil") { const v = Number(groundDefinition(id).zunchiStageMultiplier); return Number.isFinite(v) && v > 0 ? v : 1; },
zunchiDecayMultiplier(id = "soil") { const v = Number(groundDefinition(id).zunchiDecayMultiplier); return Number.isFinite(v) && v > 0 ? v : 1; },
});

View file

@ -126,9 +126,11 @@ const HEALTH = (() => {
}
function applyDamage(t, amount, reason = "") {
const before = t.energy;
const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100);
t.maxEnergy = maxEnergy;
const before = clamp(Number(t.energy) || 0, 0, maxEnergy);
const loss = Math.max(0, amount || 0);
t.energy = clamp(t.energy - loss, 0, 100);
t.energy = clamp(before - loss, 0, maxEnergy);
const actualLoss = Math.max(0, before - t.energy);
let cause = "";
if (actualLoss > 0.01) {

91
js/history_system.js Normal file
View file

@ -0,0 +1,91 @@
"use strict";
// User-facing undo/redo for direct editing actions.
(function (global) {
const MAX_HISTORY = 48;
function cloneSnapshot(snapshot) {
if (!snapshot) return null;
if (typeof structuredClone === "function") return structuredClone(snapshot);
return JSON.parse(JSON.stringify(snapshot));
}
function ensure(worldRef) {
if (!worldRef) return null;
if (!Array.isArray(worldRef._undoStack)) worldRef._undoStack = [];
if (!Array.isArray(worldRef._redoStack)) worldRef._redoStack = [];
return worldRef;
}
function snapshotKey(snapshot) {
try {
const clone = cloneSnapshot(snapshot);
if (clone) clone.c = 0;
return JSON.stringify(clone);
} catch (_) {
return "";
}
}
function capture(worldRef = global.world, label = "action") {
const world = ensure(worldRef);
if (!world || world._historyRestoring) return false;
const snap = global.TarinaiSnapshot?.createSnapshot?.(world);
if (!snap) return false;
const key = snapshotKey(snap);
if (key && key === world._lastUndoSnapshotKey) return false;
world._undoStack.push({ snapshot: cloneSnapshot(snap), key, label: String(label || "action"), time: world.time || 0 });
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
world._redoStack.length = 0;
world._lastUndoSnapshotKey = key;
return true;
}
function restore(worldRef, entry, source) {
if (!worldRef || !entry?.snapshot || !global.TarinaiRestoreCoordinator?.restoreSnapshot) return false;
worldRef._historyRestoring = true;
try {
global.TarinaiRestoreCoordinator.restoreSnapshot(cloneSnapshot(entry.snapshot), worldRef, { source, syncUi: true });
worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot?.createSnapshot?.(worldRef));
return true;
} finally {
worldRef._historyRestoring = false;
}
}
function undo(worldRef = global.world) {
const world = ensure(worldRef);
if (!world || !world._undoStack.length) return { ok: false, reason: "empty" };
const current = global.TarinaiSnapshot?.createSnapshot?.(world);
const prev = world._undoStack.pop();
if (current) world._redoStack.push({ snapshot: cloneSnapshot(current), key: snapshotKey(current), label: "redo", time: world.time || 0 });
if (world._redoStack.length > MAX_HISTORY) world._redoStack.splice(0, world._redoStack.length - MAX_HISTORY);
const ok = restore(world, prev, "undo");
return { ok, label: prev.label || "" };
}
function redo(worldRef = global.world) {
const world = ensure(worldRef);
if (!world || !world._redoStack.length) return { ok: false, reason: "empty" };
const current = global.TarinaiSnapshot?.createSnapshot?.(world);
const next = world._redoStack.pop();
if (current) world._undoStack.push({ snapshot: cloneSnapshot(current), key: snapshotKey(current), label: "undo", time: world.time || 0 });
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
const ok = restore(world, next, "redo");
return { ok, label: next.label || "" };
}
function clear(worldRef = global.world) {
const world = ensure(worldRef);
if (!world) return false;
world._undoStack.length = 0;
world._redoStack.length = 0;
world._lastUndoSnapshotKey = "";
return true;
}
function canUndo(worldRef = global.world) { return !!worldRef?._undoStack?.length; }
function canRedo(worldRef = global.world) { return !!worldRef?._redoStack?.length; }
global.TarinaiHistory = Object.freeze({ capture, undo, redo, clear, canUndo, canRedo });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,249 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns ball item motion and obstacle bounce behavior. Item.prototype ball
// methods delegate to this system.
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead || item.type !== "ball") return false;
item.prevX = item.x;
item.prevY = item.y;
const moving = Math.hypot(item.vx || 0, item.vy || 0);
if (moving > 0.04) {
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.72; item.spinVelocity *= -0.72; }
if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.72; item.spinVelocity *= -0.72; }
if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; }
if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; }
resolveObstacleCollisions(item, dt, worldRef);
item.x = clamp(item.x, p, worldRef.w - p);
item.y = clamp(item.y, p, worldRef.h - p);
const groundFriction = Math.pow(0.72, dt);
item.vx *= groundFriction;
item.vy *= groundFriction;
}
item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt + (item.vx || 0) * dt / Math.max(8, item.r || 18);
item.spinVelocity *= Math.pow(0.65, dt);
if (Math.hypot(item.vx || 0, item.vy || 0) < 0.18) { item.vx = 0; item.vy = 0; }
return true;
}
function resolveObstacleCollisions(item, dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || [];
const seen = new Set(items);
// 大きな機構は中心が近傍セル外でも当たる可能性がある。
// 中心ではなく外接半径で追加走査して、長い棒・往復幅にも衝突させる。
if (Array.isArray(worldRef.items)) {
for (const it of worldRef.items) {
if (!it || it.dead || it === item || !global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type) || seen.has(it)) continue;
const reach = global.TarinaiMechanicalSystem?.reach?.(it) || worldRef.rotatorExtent?.(it) || Math.max(64, it.r || 64);
if (distXY(item.x, item.y, it.x, it.y) <= searchRadius + reach + 36) {
items.push(it);
seen.add(it);
}
}
}
for (const it of items) {
if (!it || it === item || it.dead) continue;
if (it.type === "bed") {
applyHayDrag(item, it, dt, worldRef);
} else if (it.type === "stone") {
resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), 0.82, worldRef);
}
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
if (!rects.length) continue;
for (const rect of rects) resolveRectBounce(item, rect, worldRef, it);
}
}
function applyHayDrag(item, bed, dt, worldRef) {
const rx = Math.max(26, (bed.r || 37) * 1.72 + (item.r || 18) * 0.40);
const ry = Math.max(18, (bed.r || 37) * 0.92 + (item.r || 18) * 0.34);
const dx = item.x - bed.x;
const dy = item.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));
item.vx *= slow;
item.vy *= slow;
item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
if ((item.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(item.vx || 0, item.vy || 0) > 120) {
item.lastHaySlowLogAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }));
}
}
function resolveCircleBounce(item, obstacle, hitRadius, restitution, worldRef) {
let dx = item.x - obstacle.x;
let dy = item.y - obstacle.y;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = item.prevX;
const py = item.prevY;
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
const sx = item.x - px;
const sy = item.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(item.vx || 0, item.vy || 0);
if (speed > 0.001) { dx = -(item.vx || 0) / speed; dy = -(item.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(item.vx || 0, item.vy || 0);
if (speed > 0.001) { dx = -(item.vx || 0) / speed; dy = -(item.vy || 0) / speed; d = 1; }
else { dx = Math.cos(item.seed || 0); dy = Math.sin(item.seed || 0); d = 1; }
}
const nx = dx / d;
const ny = dy / d;
const toward = (item.vx || 0) * nx + (item.vy || 0) * ny;
item.x = obstacle.x + nx * (hitRadius + 0.5);
item.y = obstacle.y + ny * (hitRadius + 0.5);
if (toward < 0) {
item.vx = (item.vx || 0) - (1 + restitution) * toward * nx;
item.vy = (item.vy || 0) - (1 + restitution) * toward * ny;
} else {
item.vx = (item.vx || 0) + nx * 24;
item.vy = (item.vy || 0) + ny * 24;
}
item.vx *= 0.96;
item.vy *= 0.96;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
emitBounce(item, worldRef, obstacle);
}
function resolveRectBounce(item, rect, worldRef, source = null) {
if (!rect) return;
const hitRadius = (item.r || 18) + 2;
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78;
const velocityDamp = rect.bounce ? 1.0 : 0.94;
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
if (rect.oriented && global.TarinaiPhysics?.bounceCircleOffRect) {
const bounced = global.TarinaiPhysics.bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { minBounceSpeed });
if (!bounced) return;
item.vx *= velocityDamp;
item.vy *= velocityDamp;
emitBounce(item, worldRef, source || rect.item || rect);
return;
}
const ensureMinNormalSpeed = (nx, ny) => {
if (!(rect.bounce && minBounceSpeed > 0)) return;
const normalSpeed = (item.vx || 0) * nx + (item.vy || 0) * ny;
if (normalSpeed >= minBounceSpeed) return;
const add = minBounceSpeed - normalSpeed;
item.vx = (item.vx || 0) + nx * add;
item.vy = (item.vy || 0) + ny * add;
};
const cx = clamp(item.x, rect.left, rect.right);
const cy = clamp(item.y, rect.top, rect.bottom);
let dx = item.x - cx;
let dy = item.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = item.prevX;
const py = item.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, item.x, item.y, expanded)) return;
let nx = 0;
let ny = 0;
const vx = item.vx || 0;
const vy = item.vy || 0;
const candidates = [];
const sx = item.x - px;
const sy = item.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) item.x = expanded.left - 0.5;
else if (nx > 0) item.x = expanded.right + 0.5;
if (ny < 0) item.y = expanded.top - 0.5;
else if (ny > 0) item.y = expanded.bottom + 0.5;
if (nx) item.y = clamp(item.y, expanded.top, expanded.bottom);
if (ny) item.x = clamp(item.x, expanded.left, expanded.right);
if (toward < 0) {
item.vx = vx - (1 + restitution) * toward * nx;
item.vy = vy - (1 + restitution) * toward * ny;
} else {
item.vx = vx + nx * 18;
item.vy = vy + ny * 18;
}
ensureMinNormalSpeed(nx, ny);
item.vx *= velocityDamp;
item.vy *= velocityDamp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
emitBounce(item, worldRef, source || rect.item || rect);
return;
}
if (d < 0.001) {
const left = Math.abs(item.x - rect.left);
const right = Math.abs(rect.right - item.x);
const top = Math.abs(item.y - rect.top);
const bottom = Math.abs(rect.bottom - item.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 = (item.vx || 0) * nx + (item.vy || 0) * ny;
item.x = cx + nx * (hitRadius + 0.5);
item.y = cy + ny * (hitRadius + 0.5);
if (toward < 0) {
item.vx = (item.vx || 0) - (1 + restitution) * toward * nx;
item.vy = (item.vy || 0) - (1 + restitution) * toward * ny;
} else {
item.vx = (item.vx || 0) + nx * 18;
item.vy = (item.vy || 0) + ny * 18;
}
ensureMinNormalSpeed(nx, ny);
item.vx *= velocityDamp;
item.vy *= velocityDamp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
emitBounce(item, worldRef, source || rect.item || rect);
}
function emitBounce(item, worldRef, obstacle) {
const now = worldRef?.time || 0;
if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return;
item.lastObstacleBounceAt = now;
const color = (obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)");
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }));
}
global.TarinaiItemDynamicBallSystem = Object.freeze({
update,
resolveObstacleCollisions,
applyHayDrag,
resolveCircleBounce,
resolveRectBounce,
emitBounce,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,104 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns duplicator item loading/reloading behavior. Direct stored-type helpers
// remain exposed from TarinaiDuplicatorRuntime for UI/tool commands and
// save/restore stability.
(function (global) {
function support() {
return global.TarinaiItemLifecycleRuntimeSupport || {};
}
function update(item, dt, worldRef) {
if (!item || item.dead || item.type !== "duplicator") return false;
const s = support();
const syncRoles = s.syncDuplicatorRoles || global.TarinaiDuplicatorRuntime?.syncRoles;
const applyStoredPin = s.duplicatorApplyStoredPin;
const canLoadNow = s.duplicatorCanLoadNow;
const loadPointFor = s.duplicatorLoadPoint || global.TarinaiDuplicatorRuntime?.loadPoint;
const loadTypeForItem = s.duplicatorLoadTypeForItem || global.TarinaiDuplicatorRuntime?.loadTypeForItem;
const candidateReach = s.duplicatorCandidateReach;
const setStoredType = s.duplicatorSetStoredType || global.TarinaiDuplicatorRuntime?.setStoredType;
if (typeof syncRoles !== "function" || typeof loadPointFor !== "function" || typeof loadTypeForItem !== "function" || typeof setStoredType !== "function") return false;
item.amount = 999;
syncRoles(item);
if (typeof applyStoredPin === "function") applyStoredPin(item, worldRef);
item.duplicatorPinFlash = Math.max(0, (item.duplicatorPinFlash || 0) - dt);
if (!worldRef?.nearbyItems) return true;
if (typeof canLoadNow === "function" && !canLoadNow(item, worldRef)) {
item._duplicatorCandidateKey = "";
item._duplicatorCandidateSince = 0;
return true;
}
const loadPoint = loadPointFor(item);
const pickupRadius = Math.max(68, (item.r || 34) + 44);
const candidates = Array.from(worldRef.nearbyItems(item.x, item.y, pickupRadius, true) || []);
// SpatialGrid の分類漏れを補う。ただし、複製機からかなり近いものだけに限定し、
// 移動中の複製機が周辺アイテムを吸い込む挙動を避ける。
if (worldRef.items) {
for (const candidate of worldRef.items) {
if (!candidate || candidates.includes(candidate) || candidate === item || candidate.dead) continue;
const approxReach = pickupRadius + Math.max(16, candidate.r || 12);
if (distXY(item.x, item.y, candidate.x, candidate.y) <= approxReach) candidates.push(candidate);
}
}
let best = null, bestType = "", bestSlotD = Infinity, bestScore = Infinity;
for (const it of candidates) {
if (!it || it === item || it.dead || it.playerHeld || it._heldByPlayer) continue;
const loadType = loadTypeForItem(it);
if (!loadType) continue;
if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue;
const slotD = distXY(loadPoint.x, loadPoint.y, it.x, it.y);
const centerD = distXY(item.x, item.y, it.x, it.y);
const slotReach = typeof candidateReach === "function" ? candidateReach(it) : Math.max(20, Math.min(36, (it?.r || 12) * 0.82 + 13));
const bodyReach = Math.max(22, (item.r || 34) * 0.46 + (it.r || 12) * 0.36);
if (slotD > slotReach && centerD > bodyReach) continue;
// 基本は投入口に近いものを優先。同距離なら置かれたばかりのものを優先し、
// 複数候補があるときに意図しない古いアイテムへ寄りにくくする。
const score = slotD + Math.max(0, Number(it.age || 0)) * 0.012;
if (score < bestScore) {
best = it;
bestType = loadType;
bestSlotD = slotD;
bestScore = score;
}
}
if (!best || !bestType) {
item._duplicatorCandidateKey = "";
item._duplicatorCandidateSince = 0;
return true;
}
const candidateKey = `${best.id || ""}:${bestType}`;
const now = Number(worldRef.time || 0);
if (item._duplicatorCandidateKey !== candidateKey) {
item._duplicatorCandidateKey = candidateKey;
item._duplicatorCandidateSince = now;
return true;
}
const dwell = item.storedFoodType ? (s.DUPLICATOR_REPLACE_DWELL_SECONDS ?? 0.70) : (s.DUPLICATOR_LOAD_DWELL_SECONDS ?? 0.38);
if (now - Number(item._duplicatorCandidateSince || 0) < dwell) return true;
const changed = item.storedFoodType !== bestType;
if (!changed && item.storedFoodType) {
item._duplicatorCandidateKey = "";
item._duplicatorCandidateSince = 0;
return true;
}
setStoredType(item, bestType, worldRef, { consumeItem: best, direct: false, force: false, log: false, emit: false });
// 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: item, foodType: item.storedFoodType, replaced: changed, slotDistance: bestSlotD });
worldRef.log?.(`複製機に${item.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(item.storedFoodType) ? "observe" : "food");
return true;
}
global.TarinaiItemDynamicDuplicatorSystem = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,195 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns pushpin/oshibyo item motion, attachment, lodged damage, and detach
// behavior. Prototype methods delegate here.
(function (global) {
function isPin(item) {
return Boolean(item && typeof global.isPinType === "function" && global.isPinType(item.type));
}
function update(item, dt, worldRef) {
if (!isPin(item) || item.dead) return false;
if (!worldRef) return false;
if (item.pinState === "lodged") {
updateLodged(item, dt, worldRef);
return true;
}
if (global.TarinaiPhysics?.applyKinematicItemMotion) {
global.TarinaiPhysics.applyKinematicItemMotion(item, 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 {
item.prevX = item.x;
item.prevY = item.y;
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
}
tryStick(item, worldRef);
return true;
}
function tryStick(item, worldRef) {
if (!worldRef?.tarinai?.length) return false;
if (item.pinState === "lodged") return false;
const now = worldRef.time || 0;
if ((item.noStickUntil || 0) > now) return false;
let best = null;
let bestD = Infinity;
const hitRange = Math.max(10, (item.r || 8) * 1.25);
const candidates = worldRef.nearbyTarinai?.(item.x, item.y, hitRange + 32, true) || worldRef.tarinai;
for (const t of candidates) {
if (!t || t.dead) continue;
if (item.noStickTargetId && item.noStickTargetId === t.id && (item.noStickTargetUntil || 0) > now) continue;
const d = distXY(item.x, item.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 attach(item, best, worldRef, bestD);
}
function attach(item, t, worldRef, d = null) {
if (!t || t.dead) return false;
if (t.stuckPushpinId && t.stuckPushpinId !== item.id) return false;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
const dx = item.x - t.x;
const dy = item.y - t.y;
const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy);
item.pinState = "lodged";
item.deletable = false;
item.pinTargetId = t.id;
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = isOshibyo ? -faceDir : faceDir;
item.pinVisualSide = visualSide;
item.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide);
item.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
item.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
item.pinDamageTick = 0;
item.pinFallCheckTimer = 0;
item.vx = 0;
item.vy = 0;
item.spinVelocity = 0;
item.spin = item.pinAttachAngle;
t.stuckPushpinId = item.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: item, 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(item) : { 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) >= (item.pinLogAt || -999) + 1.2) {
item.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;
}
function updateLodged(item, dt, worldRef) {
const t = worldRef.liveTarinaiById?.(item.pinTargetId) || null;
if (!t) {
detach(item, worldRef, "owner-lost");
return;
}
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
t.stuckPushpinId = item.id;
item.deletable = false;
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = isOshibyo ? -faceDir : faceDir;
item.pinVisualSide = visualSide;
item.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36;
item.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04;
item.prevX = item.x;
item.prevY = item.y;
item.spin = visualSide * (isOshibyo ? 0.52 : 0.28);
item.vx = t.vx || 0;
item.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;
}
item.pinDamageTick = (item.pinDamageTick || 0) + dt;
while (item.pinDamageTick >= 0.55) {
item.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: item, 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(item) : { 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);
}
item.pinFallCheckTimer = (item.pinFallCheckTimer || 0) + dt;
while (item.pinFallCheckTimer >= 1.0) {
item.pinFallCheckTimer -= 1.0;
if (Math.random() < 0.10) {
detach(item, worldRef, "fall");
return;
}
}
}
function detach(item, worldRef, reason = "released") {
const t = worldRef?.liveTarinaiById?.(item.pinTargetId) || null;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null;
const wasOshibyo = Boolean(behavior?.blocksZunchi);
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
if (t && t.stuckPushpinId === item.id) t.stuckPushpinId = null;
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
if (reason === "pinch") {
const now = worldRef?.time || 0;
item.noStickUntil = now + 1.0;
item.noStickTargetId = t?.id || "";
item.noStickTargetUntil = now + 1.0;
} else if (t) {
const now = worldRef?.time || 0;
item.noStickTargetId = t.id || "";
item.noStickTargetUntil = now + 0.55;
}
item.pinState = "loose";
item.pinTargetId = "";
item.pinDamageTick = 0;
item.pinFallCheckTimer = 0;
item.deletable = true;
if (reason === "pinch") {
item.vx = 0;
item.vy = 0;
item.spinVelocity = 0;
} else {
item.vx = rand(-24, 24);
item.vy = rand(-10, 18);
item.spinVelocity = rand(-1.6, 1.6);
item.spin += rand(-0.25, 0.25);
if (t) {
item.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30));
item.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30));
}
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.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 || item, storedZunchi, item);
}
}
global.TarinaiItemDynamicPinSystem = Object.freeze({ update, isPin, tryStick, attach, updateLodged, detach });
})(typeof window !== "undefined" ? window : globalThis);

33
js/item_dynamic_system.js Normal file
View file

@ -0,0 +1,33 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Central dispatch table for active item behavior. Lifecycle steps should call
// this module instead of knowing every dynamic item type.
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead) return false;
switch (item.type) {
case "firecracker":
case "genkotsu":
case "rotator":
case "reciprocator":
case "gate_fence":
case "rope":
case "rod":
return global.TarinaiItemDynamicToolSystem?.update(item, dt, worldRef) !== false;
case "ball":
return global.TarinaiItemDynamicBallSystem?.update(item, dt, worldRef) !== false;
case "duplicator":
return global.TarinaiItemDynamicDuplicatorSystem?.update(item, dt, worldRef) !== false;
case "zunchi":
return global.TarinaiItemDynamicZunchiSystem?.updateMotion(item, dt, worldRef) !== false;
default:
if (global.TarinaiItemDynamicPinSystem?.isPin(item)) {
return global.TarinaiItemDynamicPinSystem.update(item, dt, worldRef) !== false;
}
return false;
}
}
global.TarinaiItemDynamicSystem = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,81 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns dynamic tool behavior for rotator, reciprocator, firecracker, genkotsu,
// links, and static gate-fence ticking. Item.prototype methods delegate here for
// direct Item.prototype calls; lifecycle dispatch also calls this boundary directly.
(function (global) {
function support() {
return global.TarinaiItemLifecycleRuntimeSupport || {};
}
function updateFirecracker(item, dt, worldRef) {
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
if (item.fuseTimer <= 0) {
if (worldRef?.explodeFirecracker) worldRef.explodeFirecracker(item);
else item.amount = 0;
}
}
function updateGenkotsu(item, dt) {
item.impactFlash = Math.max(0, (item.impactFlash || 0) - dt);
if (item.dropImpactDone) {
item.lingerTimer = Math.max(0, (item.lingerTimer || 0) - dt);
if ((item.lingerTimer || 0) <= 0) item.amount = 0;
} else {
item.amount = Math.max(item.amount || 0, 100);
}
}
function updateRotator(item, dt, worldRef) {
return Boolean(global.TarinaiMechanicalSystem?.updateRotator?.(item, dt, worldRef));
}
function updateReciprocator(item, dt, worldRef) {
return Boolean(global.TarinaiMechanicalSystem?.updateReciprocator?.(item, dt, worldRef));
}
function resolveMechanicalInteractions(item, dt, worldRef) {
return Boolean(global.TarinaiMechanicalSystem?.resolveInteractions?.(item, dt, worldRef));
}
function updateFlexibleLink(item, dt, worldRef) {
return Boolean(global.TarinaiConstraintSystem?.updateFlexibleLink?.(item, dt, worldRef));
}
function updateRigidLink(item, dt, worldRef) {
return Boolean(global.TarinaiConstraintSystem?.updateRigidLink?.(item, dt, worldRef));
}
function updateGateFence(item) {
if (item) item.amount = 999;
return Boolean(item);
}
function update(item, dt, worldRef) {
if (!item || item.dead) return false;
if (item.type === "firecracker") return updateFirecracker(item, dt, worldRef);
if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef);
if (item.type === "rotator") return updateRotator(item, dt, worldRef);
if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef);
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef);
if (item.type === "rod") return updateRigidLink(item, dt, worldRef);
return false;
}
global.TarinaiItemDynamicToolSystem = Object.freeze({
update,
updateFirecracker,
updateGenkotsu,
updateRotator,
updateReciprocator,
updateFlexibleLink,
updateRigidLink,
resolveMechanicalInteractions,
updateGateFence,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,112 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns zunchi item motion, high-speed collision, and lifecycle stage ticking.
// Prototype methods delegate here.
(function (global) {
function updateMotion(item, dt, worldRef) {
if (!item || item.dead || item.type !== "zunchi") return false;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
if (speed < 0.08) {
item.vx = 0; item.vy = 0;
global.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(item, dt, worldRef);
return true;
}
if (global.TarinaiPhysics?.applyKinematicItemMotion) {
global.TarinaiPhysics.applyKinematicItemMotion(item, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
} else {
item.prevX = item.x;
item.prevY = item.y;
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.34; }
if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.34; }
if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.34; }
if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.34; }
const slow = Math.pow(0.68, dt * 2.4);
item.vx *= slow;
item.vy *= slow;
worldRef.drawListDirty = true;
}
global.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(item, dt, worldRef);
resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed);
item.spin = (item.spin || 0) + speed * dt / Math.max(8, item.r || 14);
return true;
}
function resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed = 0) {
if (!worldRef || speed < 140 || (item.amount || 0) <= 0) return;
const search = Math.max(38, (item.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18);
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, search, true) || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(item.x, item.y, t.x, t.y);
if (d > (t.radius || 20) * 0.72 + (item.r || 14)) continue;
const now = worldRef.time || 0;
if ((item.lastHitTarinaiAt || {})[t.id] && item.lastHitTarinaiAt[t.id] + 0.55 > now) continue;
item.lastHitTarinaiAt = item.lastHitTarinaiAt || {};
item.lastHitTarinaiAt[t.id] = now;
const nx = speed > 0 ? (item.vx || 1) / speed : rand(-1, 1);
const ny = speed > 0 ? (item.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);
if (speed >= 185) {
const damage = Math.min(18, Math.max(1, (speed - 185) / 22));
worldRef.applyImpactDamage?.(t, damage, "衝突", { source: item }) || t.damage?.(damage, "衝突");
}
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: item, 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(item) : 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", item.x, item.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" }));
item.vx *= -0.18;
item.vy *= -0.18;
break;
}
}
function updateLifecycle(item, dt, worldRef) {
if (!item || item.dead || item.type !== "zunchi") return false;
const rainy = worldRef.weather === "light_rain";
const rainBoost = rainy ? 1.28 : 1;
const groundId = worldRef?.groundType || "soil";
const stageMul = Math.max(0.1, Number(global.TarinaiGround?.zunchiStageMultiplier?.(groundId) || 1) || 1);
const groundDecayMul = Math.max(0.1, Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1);
const decayBoost = (rainy ? 1.35 : 1) * groundDecayMul;
let waterBoost = 0;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(item.x, item.y, 84) : (worldRef.items || []);
for (const it of nearby) {
if (it.type !== "water") continue;
waterBoost += clamp(1 - distXY(item.x, item.y, it.x, it.y) / 80, 0, 1);
}
item.stageTimer += dt * 0.70 * stageMul * (rainBoost + waterBoost * 0.65);
const decayDt = ((item.spawnGrace || 0) > 0 && groundDecayMul <= 1.01) ? 0 : dt;
if (item.stage === "fresh") {
item.freshness = clamp(1 - item.stageTimer / 110, 0, 1);
item.amount -= decayDt * 0.018 * decayBoost;
if (item.stageTimer > 110) { item.stage = "dry"; item.stageTimer = 0; }
} else if (item.stage === "dry") {
item.freshness = clamp(0.55 - item.stageTimer / 220, 0.20, 0.55);
item.amount -= decayDt * 0.032 * decayBoost;
if (item.stageTimer > 150) { item.stage = "decomposing"; item.stageTimer = 0; item.fertility = 0.35; }
} else if (item.stage === "decomposing") {
item.fertility = clamp(item.fertility + dt * 0.015, 0, 1);
item.amount -= decayDt * 0.046 * decayBoost;
if (item.stageTimer > 190) { item.stage = "fertile_soil"; item.stageTimer = 0; item.amount = Math.min(item.amount, 140); }
} else if (item.stage === "fertile_soil") {
item.fertility = clamp(1 - item.stageTimer / 360, 0, 1);
item.amount -= decayDt * 0.090 * decayBoost;
}
return true;
}
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, resolveHighSpeedTarinaiCollision, updateLifecycle });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,66 @@
"use strict";
// Layer: entity-runtime/items/decay-system
// Owns passive amount decay and spoilage side effects for water, food,
// transient traces, splats, and ant corpses. The lifecycle step is now only a
// thin pipeline adapter.
(function (global) {
const MEDICINE_LIKE_FOOD_TYPES = Object.freeze([
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein",
"niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
]);
function decayServingFood(item, dt, worldRef) {
if (typeof isServingFoodType !== "function" || !isServingFoodType(item.type)) return false;
const interval = passiveFoodDecayInterval(worldRef);
item.passiveFoodDecayTimer = (item.passiveFoodDecayTimer || 0) + dt;
let changed = false;
let ticks = 0;
while (item.passiveFoodDecayTimer >= interval && ticks < 3) {
item.passiveFoodDecayTimer -= interval;
ticks++;
if (Number.isFinite(item.foodServingsRemaining)) {
const before = Math.max(0, item.foodServingsRemaining || 0);
const lost = Math.min(before, interval * passiveFoodDecayRate(item));
if (lost > 0) {
changed = true;
item.foodServingsRemaining = Math.max(0, before - lost);
item.amount = item.foodServingsRemaining;
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING;
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true });
if (item.foodServingsRemaining <= 0.015) item.amount = 0;
}
} else if (MEDICINE_LIKE_FOOD_TYPES.includes(item.type)) {
const before = item.amount || 0;
const lost = Math.min(before, interval * 0.12);
item.amount -= lost;
if (lost > 0) {
changed = true;
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? 0.002;
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true });
}
}
}
return changed;
}
function update(item, dt, worldRef) {
if (!item || item.dead) return { done: true };
if (item.type === "water") item.amount -= dt * 0.9;
decayServingFood(item, dt, worldRef);
if (item.type === "trace") item.amount -= dt * 1.35;
if (item.type === "splat") item.amount -= dt * 1.05;
if (item.type === "ant_corpse") item.amount -= dt * 0.018;
return { done: false };
}
global.TarinaiItemDecaySystem = Object.freeze({
MEDICINE_LIKE_FOOD_TYPES,
decayServingFood,
update,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,38 @@
"use strict";
// Layer: entity-runtime/items/growth-system
// Owns passive grass normalization and zunchi lifecycle stage ticking. The
// lifecycle step is kept as a pipeline adapter only.
(function (global) {
function updateGrass(item) {
if (!item || item.dead || item.type !== "grass") return false;
// Grass is intentionally not simulated per item. World.update randomly
// advances one grass by one discrete stage every few seconds.
normalizeGrassStage(item);
return true;
}
function updateZunchiLifecycle(item, dt, worldRef) {
if (!item || item.dead || item.type !== "zunchi") return false;
item.lifecycleTimer += dt;
const interval = item.lifecycleInterval || (0.95 + stableUnit(item.id, "zunchi-life") * 0.45);
if (item.lifecycleTimer < interval) return false;
const lifecycleDt = Math.min(item.lifecycleTimer, 2.4);
item.lifecycleTimer = 0;
global.TarinaiItemDynamicZunchiSystem?.updateLifecycle(item, lifecycleDt, worldRef);
return true;
}
function update(item, dt, worldRef) {
if (!item || item.dead) return { done: true };
if (item.type === "grass") updateGrass(item);
else if (item.type === "zunchi") updateZunchiLifecycle(item, dt, worldRef);
return { done: false };
}
global.TarinaiItemGrowthSystem = Object.freeze({
updateGrass,
updateZunchiLifecycle,
update,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,96 @@
"use strict";
// Layer: entity-runtime/items/legacy-lifecycle
// Compatibility fallback for the pre-pipeline monolithic Item update body.
// Normal runtime goes through item_lifecycle_pipeline.js; this exists only so
// old callers and partial-load environments have a stable fallback.
(function (global) {
function updateOne(item, dt, worldRef = global.world) {
item.age += dt;
if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt);
if (item.dropTimer > 0) {
const beforeDrop = item.dropTimer;
item.dropTimer = Math.max(0, item.dropTimer - dt);
if (beforeDrop > 0 && item.dropTimer <= 0 && !item.dropImpactDone) {
item.dropImpactDone = true;
if (worldRef.itemDropImpact) worldRef.itemDropImpact(item);
}
}
if (item.type === "water") item.amount -= dt * 0.9;
if (isServingFoodType(item.type)) {
const interval = passiveFoodDecayInterval(worldRef);
item.passiveFoodDecayTimer = (item.passiveFoodDecayTimer || 0) + dt;
let ticks = 0;
while (item.passiveFoodDecayTimer >= interval && ticks < 3) {
item.passiveFoodDecayTimer -= interval;
ticks++;
if (Number.isFinite(item.foodServingsRemaining)) {
const before = Math.max(0, item.foodServingsRemaining || 0);
const lost = Math.min(before, interval * passiveFoodDecayRate(item));
if (lost > 0) {
item.foodServingsRemaining = Math.max(0, before - lost);
item.amount = item.foodServingsRemaining;
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING;
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true });
if (item.foodServingsRemaining <= 0.015) item.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(item.type)) {
const before = item.amount || 0;
const lost = Math.min(before, interval * 0.12);
item.amount -= lost;
if (lost > 0) {
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? 0.002;
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true });
}
}
}
}
if (item.type === "trace") item.amount -= dt * 1.35;
if (item.type === "splat") item.amount -= dt * 1.05;
if (item.type === "ant_corpse") item.amount -= dt * 0.018;
if (item.type === "firecracker") {
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
if (item.fuseTimer <= 0) {
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(item);
else item.amount = 0;
}
}
if (item.type === "genkotsu") {
item.impactFlash = Math.max(0, (item.impactFlash || 0) - dt);
if (item.dropImpactDone) {
item.lingerTimer = Math.max(0, (item.lingerTimer || 0) - dt);
if ((item.lingerTimer || 0) <= 0) item.amount = 0;
} else {
item.amount = Math.max(item.amount || 0, 100);
}
}
if (item.type === "ball") item.updateBall(dt, worldRef);
if (item.type === "fan") item.updateFan(dt, worldRef);
if (item.type === "rotator") item.updateRotator(dt, worldRef);
if (item.type === "gate_fence") item.amount = 999;
if (item.type === "duplicator") item.updateDuplicator(dt, worldRef);
if (isPinType(item.type)) item.updatePushpin(dt, worldRef);
if (item.type === "zunchi") item.updateZunchiMotion(dt, worldRef);
if (item.type === "grass") {
// Grass is intentionally not simulated per item. World.update randomly
// advances one grass by one discrete stage every few seconds.
normalizeGrassStage(item);
} else if (item.type === "zunchi") {
item.lifecycleTimer += dt;
const interval = item.lifecycleInterval || (0.95 + stableUnit(item.id, "zunchi-life") * 0.45);
if (item.lifecycleTimer >= interval) {
const lifecycleDt = Math.min(item.lifecycleTimer, 2.4);
item.lifecycleTimer = 0;
item.updateZunchi(lifecycleDt, worldRef);
}
}
return true;
}
global.TarinaiItemLifecycleLegacySystem = Object.freeze({ updateOne });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,37 @@
"use strict";
// Layer: entity-runtime/items/pipeline
// Owns the ordered item lifecycle pipeline. There is no monolithic fallback path:
// every item update must be handled by the explicit lifecycle steps below.
(function (global) {
const CONCRETE_STEP_NAMES = Object.freeze([
"TarinaiItemFrameLifecycleStep",
"TarinaiItemDecayLifecycleStep",
"TarinaiItemDynamicLifecycleStep",
"TarinaiItemGrowthLifecycleStep",
]);
function concreteSteps() {
return CONCRETE_STEP_NAMES.map(name => global[name]).filter(step => step && typeof step.update === "function");
}
function canRunConcretePipeline() {
return concreteSteps().length === CONCRETE_STEP_NAMES.length;
}
function updateOne(item, dt, worldRef, options = {}) {
if (!item || item.dead || !canRunConcretePipeline()) return false;
const context = options.context || {};
for (const step of concreteSteps()) {
const result = step.update(item, dt, worldRef, context) || {};
if (result.done) return result.ok !== false;
}
return true;
}
global.TarinaiItemLifecyclePipeline = Object.freeze({
stepNames: CONCRETE_STEP_NAMES,
canRunConcretePipeline,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,626 +1,42 @@
"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");
}
// Layer: entity-runtime/items/prototype-boundary
// Keeps Item.prototype methods available while implementation ownership
// lives in lifecycle/dynamic systems.
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);
}
}
},
update(dt, worldRef = world) { return window.TarinaiItemRuntime?.updateOne?.(this, dt, worldRef) ?? window.TarinaiItemLifecyclePipeline?.updateOne?.(this, dt, worldRef) ?? false; },
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);
},
updateRotator(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateRotator?.(this, dt, worldRef); },
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;
}
},
updateDuplicator(dt, worldRef) { return window.TarinaiItemDynamicDuplicatorSystem?.update?.(this, dt, worldRef); },
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; }
},
updateZunchiMotion(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateMotion?.(this, dt, worldRef); },
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);
}
},
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { return window.TarinaiItemDynamicZunchiSystem?.resolveHighSpeedTarinaiCollision?.(this, dt, worldRef, speed); },
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)" }));
}
},
updateBall(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.update?.(this, dt, worldRef); },
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);
},
resolveBallObstacleCollisions(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(this, dt, worldRef); },
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);
},
applyBallHayDrag(bed, dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.applyHayDrag?.(this, bed, dt, worldRef); },
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)" }));
},
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveCircleBounce?.(this, obstacle, hitRadius, restitution, worldRef); },
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);
},
resolveBallRectBounce(rect, worldRef, source = null) { return window.TarinaiItemDynamicBallSystem?.resolveRectBounce?.(this, rect, worldRef, source); },
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);
},
emitBallBounce(worldRef, obstacle) { return window.TarinaiItemDynamicBallSystem?.emitBounce?.(this, worldRef, obstacle); },
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;
},
updatePushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.update?.(this, dt, worldRef); },
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;
}
}
},
tryStickPushpin(worldRef) { return window.TarinaiItemDynamicPinSystem?.tryStick?.(this, worldRef); },
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);
}
},
attachPushpin(t, worldRef, d = null) { return window.TarinaiItemDynamicPinSystem?.attach?.(this, t, worldRef, d); },
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;
}
}
updateLodgedPushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.updateLodged?.(this, dt, worldRef); },
detachPushpin(worldRef, reason = "released") { return window.TarinaiItemDynamicPinSystem?.detach?.(this, worldRef, reason); },
updateZunchi(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateLifecycle?.(this, dt, worldRef); }
});

View file

@ -0,0 +1,13 @@
"use strict";
// Layer: entity-runtime/items/lifecycle-step
// Pipeline adapter for item decay. The decay implementation lives in
// js/item_lifecycle_decay_system.js.
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead) return { done: true };
return global.TarinaiItemDecaySystem?.update(item, dt, worldRef) || { done: false };
}
global.TarinaiItemDecayLifecycleStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,14 @@
"use strict";
// Layer: entity-runtime/items/lifecycle-step
// Owns the dynamic item lifecycle slot. Type-specific dispatch lives in
// js/item_dynamic_system.js and its family-specific submodules.
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead) return { done: true };
global.TarinaiItemDynamicSystem?.update(item, dt, worldRef);
return { done: false };
}
global.TarinaiItemDynamicLifecycleStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,23 @@
"use strict";
// Layer: entity-runtime/items/lifecycle-step
// Owns per-item frame bookkeeping that is common across item types: age,
// zunchi spawn grace, and delayed drop impact handling.
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead) return { done: true };
item.age += dt;
if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt);
if (item.dropTimer > 0) {
const beforeDrop = item.dropTimer;
item.dropTimer = Math.max(0, item.dropTimer - dt);
if (beforeDrop > 0 && item.dropTimer <= 0 && !item.dropImpactDone) {
item.dropImpactDone = true;
if (worldRef?.itemDropImpact) worldRef.itemDropImpact(item);
}
}
return { done: false };
}
global.TarinaiItemFrameLifecycleStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,13 @@
"use strict";
// Layer: entity-runtime/items/lifecycle-step
// Pipeline adapter for item growth/lifecycle stage ticking. The implementation
// lives in js/item_lifecycle_growth_system.js.
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead) return { done: true };
return global.TarinaiItemGrowthSystem?.update(item, dt, worldRef) || { done: false };
}
global.TarinaiItemGrowthLifecycleStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,15 @@
"use strict";
// Layer: entity-runtime/items/lifecycle-step
// Fallback step for environments where the split item lifecycle pipeline cannot
// run. The captured legacy Item.prototype.update is kept by item_runtime.js.
(function (global) {
function update(item, dt, worldRef, context = {}) {
const legacyUpdate = context.legacyUpdate;
if (!item || item.dead || typeof legacyUpdate !== "function") return { done: true, ok: false };
legacyUpdate.call(item, dt, worldRef);
return { done: true, ok: true };
}
global.TarinaiItemLegacyLifecycleStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,161 @@
"use strict";
// Layer: entity-runtime/items/support
// Owns shared lifecycle support helpers used by dynamic item systems and
// placement/tool commands. The runtime imports these via
// globals instead of carrying helper implementations inline.
(function (global) {
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", "water", "pushpin", "oshibyo"
]);
function duplicatorLoadTypeForItem(item) {
if (!item || item.dead || item.type === "duplicator") return "";
const type = String(item.type || "");
if (!type || type === "water_bowl") return "";
if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") 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 = global.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 itemAngleForRuntime(item) {
return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
}
const DUPLICATOR_LOAD_DWELL_SECONDS = 0.38;
const DUPLICATOR_REPLACE_DWELL_SECONDS = 0.70;
function duplicatorLoadPoint(duplicator) {
const r = duplicator?.r || 34;
return { x: (duplicator?.x || 0) + r * 0.62, y: (duplicator?.y || 0) + r * 0.24 };
}
function duplicatorCandidateReach(item) {
return Math.max(20, Math.min(36, (item?.r || 12) * 0.82 + 13));
}
function duplicatorCanLoadNow(duplicator, worldRef) {
if (!duplicator || duplicator.type !== "duplicator") return false;
if (duplicator.playerHeld || duplicator._heldByPlayer) return false;
const now = Number(worldRef?.time || 0);
if (now < Number(duplicator.duplicatorLoadSuppressedUntil || 0)) return false;
if (Math.hypot(duplicator.vx || 0, duplicator.vy || 0) > 24) return false;
return true;
}
function syncDuplicatorRoles(item) {
if (!item || item.type !== "duplicator") return;
const type = String(item.storedFoodType || "");
const pinLoaded = Boolean(type && typeof isPinType === "function" && isPinType(type));
item.roles.food = Boolean(type && !pinLoaded);
item.roles.medicine = Boolean(type && !pinLoaded && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))));
item.roles.drink = Boolean(!pinLoaded && (type === "water" || type === "zunda_juice"));
item.roles.danger = pinLoaded;
}
function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, options = {}) {
if (!duplicator || duplicator.type !== "duplicator") return false;
const type = String(storedType || "");
if (!type) return false;
const probe = { type, amount: 1, foodServingsRemaining: 1, dead: false };
const loadType = duplicatorLoadTypeForItem(probe);
if (!loadType) return false;
const changed = duplicator.storedFoodType !== loadType;
if (!changed && duplicator.storedFoodType && !options.force) return true;
const def = typeof itemDefinition === "function" ? itemDefinition(loadType) : null;
const newLabel = def?.label || (typeof toolLabel === "function" ? toolLabel(loadType) : loadType);
duplicator.storedFoodType = loadType;
duplicator.storedFoodLabel = newLabel;
syncDuplicatorRoles(duplicator);
duplicator.loadedAt = Number(worldRef?.time || 0);
duplicator._duplicatorCandidateKey = "";
duplicator._duplicatorCandidateSince = 0;
duplicator.duplicatorPinFlash = typeof isPinType === "function" && isPinType(loadType) ? 0.28 : (duplicator.duplicatorPinFlash || 0);
const consume = options.consumeItem;
if (consume && typeof consume === "object") {
consume.amount = 0;
consume.foodServingsRemaining = 0;
}
worldRef?.markItemBucketsDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
worldRef?.markSpatialDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
worldRef?.markTerrainDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
if (options.emit !== false) worldRef?.emit?.("duplicator:loaded", { duplicator, foodType: loadType, replaced: changed, direct: Boolean(options.direct) });
if (options.log !== false) worldRef?.log?.(`複製機に${duplicator.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(loadType) ? "observe" : "food");
return true;
}
global.TarinaiDuplicatorRuntime = Object.freeze({
loadTypeForItem: duplicatorLoadTypeForItem,
loadPoint: duplicatorLoadPoint,
setStoredType: duplicatorSetStoredType,
syncRoles: syncDuplicatorRoles,
});
function duplicatorApplyStoredPin(duplicator, worldRef) {
const storedType = String(duplicator?.storedFoodType || "");
if (!duplicator || duplicator.type !== "duplicator" || !(typeof isPinType === "function" && isPinType(storedType))) return false;
const now = Number(worldRef?.time || 0);
if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false;
const dish = duplicatorLoadPoint(duplicator);
const reach = Math.max(24, (duplicator.r || 34) * 0.56);
const candidates = worldRef?.nearbyTarinai?.(dish.x, dish.y, reach + 72, true) || [];
for (const t of candidates) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
if (t.stuckPushpinId || t.currentLodgedPin?.()) continue;
const hit = reach + Math.max(10, (t.radius || 18) * 0.78);
if (distXY(dish.x, dish.y, t.x, t.y) > hit) continue;
const pin = new Item(storedType, dish.x, dish.y);
pin.amount = 999;
pin.noStickUntil = 0;
pin.noStickTargetId = "";
pin.noStickTargetUntil = 0;
pin.duplicatedFromId = duplicator.id || "";
const added = worldRef?.addItem?.(pin, "duplicator-pin", { terrain: false }) || null;
if (!added && worldRef?.items) {
pin.world = worldRef;
worldRef.items.push(pin);
worldRef.markItemBucketsDirty?.("duplicator-pin");
worldRef.markSpatialDirty?.("duplicator-pin");
}
const ok = pin.attachPushpin?.(t, worldRef, distXY(dish.x, dish.y, t.x, t.y));
if (!ok) {
pin.amount = 0;
continue;
}
duplicator.usedCount = (duplicator.usedCount || 0) + 1;
duplicator.nextDuplicatedPinAt = now + 0.82;
duplicator.duplicatorPinFlash = 0.24;
worldRef.effects?.push(new Effect("ring", dish.x, dish.y, { size: Math.max(15, (duplicator.r || 34) * 0.62), life: 0.18, color: storedType === "oshibyo" ? "rgba(73,119,205,0.40)" : "rgba(214,72,72,0.42)" }));
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("duplicator-pin-lodged");
return true;
}
return false;
}
global.TarinaiItemLifecycleRuntimeSupport = Object.freeze({
itemAngleForRuntime,
duplicatorLoadTypeForItem,
duplicatorLoadPoint,
duplicatorCandidateReach,
duplicatorCanLoadNow,
syncDuplicatorRoles,
duplicatorSetStoredType,
duplicatorApplyStoredPin,
DUPLICATOR_LOAD_DWELL_SECONDS,
DUPLICATOR_REPLACE_DWELL_SECONDS,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -5,15 +5,20 @@
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" },
area_delete: { id: "area_delete", label: "範囲削除", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "カーソル周囲の広い範囲にある置きものや汚れをまとめて消す。" },
copy: { id: "copy", label: "コピー", placeable: false, scalable: false, iconText: "⧉", tooltip: "置いたアイテムをクリックしてコピーし、次のクリックで設定ごと貼り付ける。" },
undo: { id: "undo", label: "戻す", placeable: false, scalable: false, iconText: "↶", tooltip: "直前の操作を取り消す。" },
redo: { id: "redo", label: "進む", placeable: false, scalable: false, iconText: "↷", tooltip: "取り消した操作をやり直す。" },
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" },
rope: { id: "rope", label: "ヒモ", placeable: false, scalable: false, iconText: "╰", tooltip: "2つの対象を順にクリックして、伸び縮みしないヒモで接続する。機械体・柵・ヒモ・棒はクリック地点、それ以外は中心につなぐ。" },
rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。距離を常に一定に保つ。" },
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" },
@ -33,14 +38,19 @@ const TOOL_DEFINITIONS = Object.freeze({
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" },
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\u30fb\u85ac\u30fb\u753b\u92f2\u30fb\u304a\u3057\u308a\u92f2\u3092\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002\u92f2\u7cfb\u306f\u89e6\u308c\u305f\u305f\u308a\u306a\u3044\u306b\u523a\u3055\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 },
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
rotator: { id: "rotator", itemType: "rotator", label: "回転体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "↻", collisionShape: "custom_rotator", tooltip: "回転する機械。クリックすると形状や速度を設定できる。" },
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "往復体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "⇄", collisionShape: "custom_reciprocator", tooltip: "往復する機械。クリックすると形状や速度を設定できる。" },
water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" },
trace: { id: "trace", itemType: "trace", label: "死骸", 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 },
@ -48,15 +58,45 @@ const TOOL_DEFINITIONS = Object.freeze({
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"]),
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "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"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]),
});
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]));
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "reciprocator"]));
const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"]));
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "reciprocator"]);
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES]));
function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); }
function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); }
function isLinkItemType(type = "") { return LINK_ITEM_TYPES.has(String(type || "")); }
function isPhysicsToolId(id = "") { return PHYSICS_TOOL_IDS.includes(String(id || "")); }
function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); }
function defaultItemAngle(type = "") {
const t = String(type || "");
if (t === "fence_v" || t === "bounce_fence_v") return Math.PI / 2;
return 0;
}
function normalizedItemAngle(angle = 0, fallback = 0) {
const twoPi = Math.PI * 2;
const n = Number(angle);
const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0);
return ((base % twoPi) + twoPi) % twoPi;
}
function itemAngleFor(itemOrType = "") {
if (itemOrType && typeof itemOrType === "object") return normalizedItemAngle(itemOrType.angle, defaultItemAngle(itemOrType.type));
return defaultItemAngle(String(itemOrType || ""));
}
function rotatableToolIds() {
return Object.values(TOOL_DEFINITIONS).filter(def => isRotatableItemType(def.itemType || def.id)).map(def => def.id);
}
function itemHasTrait(type = "", trait = "") {
return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || "")));
}
@ -89,10 +129,11 @@ 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: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "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: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard"] },
{ id: "physics", label: "物理", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
]);
@ -306,8 +347,8 @@ function toolTipsFromDefinitions() {
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 },
water: { label: "水滴", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water_bowl: { label: "水の皿", 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 }
};
@ -481,7 +522,7 @@ function toolTipsFromDefinitions() {
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.energy = clamp(tarinai.energy - nutrition * 0.20, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 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);
@ -676,7 +717,23 @@ function toolTipsFromDefinitions() {
radius: itemRadiusFor,
amount: itemAmountFor,
categories: toolCategories,
physicsToolIds: () => [...PHYSICS_TOOL_IDS],
isFence: isFenceItemType,
isMechanical: isMechanicalItemType,
isLink: isLinkItemType,
scalableToolIds,
pinBehavior: pinBehaviorFor,
});
})(typeof window !== "undefined" ? window : globalThis);
if (typeof window !== "undefined") {
window.isFenceItemType = isFenceItemType;
window.isMechanicalItemType = isMechanicalItemType;
window.isLinkItemType = isLinkItemType;
window.isPhysicsToolId = isPhysicsToolId;
window.isRotatableItemType = isRotatableItemType;
window.defaultItemAngle = defaultItemAngle;
window.normalizedItemAngle = normalizedItemAngle;
window.itemAngleFor = itemAngleFor;
window.rotatableToolIds = rotatableToolIds;
}

View file

@ -2,6 +2,7 @@
// Item field rendering. Kept separate from construction and lifecycle logic.
Object.assign(Item.prototype, {
draw(ctx, t, lighting = null) {
const lightState = lighting || getLightingState(world);
@ -469,27 +470,249 @@ draw(ctx, t, lighting = null) {
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";
} else if (this.type === "fence_v" || this.type === "fence_h" || this.type === "bounce_fence" || this.type === "bounce_fence_v" || this.type === "gate_fence") {
const bounce = this.type === "bounce_fence" || this.type === "bounce_fence_v";
const gate = this.type === "gate_fence";
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const len = Math.max(112, this.r * 3.55);
const thick = Math.max(10, this.r * 0.31);
const open = gate && this.gateOpen;
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.rotate(angle);
const wood = bounce ? (styleNight ? "#4e6f9f" : "#5aa2e0") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"));
const edge = bounce ? (styleNight ? "#243755" : "#286093") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25");
const hi = bounce ? (styleNight ? "rgba(190,230,255,0.24)" : "rgba(235,252,255,0.54)") : gate ? (styleNight ? "rgba(214,240,210,0.18)" : "rgba(220,255,210,0.34)") : (styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)");
const drawRail = (x0, y0, w, h, alpha = 1) => {
ctx.save();
ctx.globalAlpha *= alpha;
ctx.fillStyle = wood;
ctx.strokeStyle = edge;
ctx.lineWidth = bounce ? 2.8 : 2.4;
ctx.beginPath();
roundedRect(ctx, x0, y0, w, h, thick / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = hi;
ctx.beginPath();
roundedRect(ctx, x0 + 6, y0 + thick * 0.25, Math.max(4, w - 12), thick * 0.20, thick / 3);
ctx.fill();
ctx.restore();
};
if (gate) {
const pivotX = -len / 2;
ctx.save();
drawRail(pivotX, -thick / 2, len, thick, open ? 0.34 : 1);
ctx.strokeStyle = styleNight ? "rgba(210,230,210,0.55)" : "rgba(255,255,255,0.54)";
ctx.lineWidth = Math.max(1.6, thick * 0.12);
for (let x = pivotX + len * 0.26; x < pivotX + len * 0.88; x += len * 0.24) {
ctx.beginPath();
ctx.moveTo(x, -thick * 0.42);
ctx.lineTo(x, thick * 0.42);
ctx.stroke();
}
ctx.restore();
ctx.fillStyle = styleNight ? "#c7d7bf" : "#e8f2d8";
ctx.strokeStyle = edge;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(pivotX, 0, thick * 0.54, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
if (open) {
ctx.fillStyle = "rgba(80,120,72,0.65)";
ctx.font = `bold ${Math.max(9, thick * 0.62)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("OPEN", 0, -thick * 1.08);
}
} else {
drawRail(-len / 2, -thick / 2, len, thick, 1);
if (bounce) {
ctx.strokeStyle = styleNight ? "rgba(204,236,255,0.58)" : "rgba(255,255,255,0.72)";
ctx.lineWidth = Math.max(1.3, thick * 0.11);
ctx.beginPath();
const coils = 9;
for (let i = 0; i <= coils; i += 1) {
const x = -len * 0.42 + len * 0.84 * (i / coils);
const y = (i % 2 === 0 ? -1 : 1) * thick * 0.23;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.fillStyle = styleNight ? "rgba(35,53,84,0.78)" : "rgba(36,93,145,0.72)";
for (const xx of [-0.32, 0, 0.32]) {
ctx.beginPath();
ctx.ellipse(len * xx, 0, thick * 0.19, thick * 0.37, 0, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.restore();
} else if (this.type === "rotator") {
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const powered = this.rotatorPowered !== false;
const speed = Number((powered ? this.rotatorSpeed : this.rotatorAngularVelocity) || 0) || 0;
const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]]));
const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12));
ctx.save();
ctx.rotate(angle);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha *= powered ? 1 : 0.72;
ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5";
ctx.lineWidth = thick;
ctx.shadowColor = powered ? (styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)") : "rgba(88,80,96,0.20)";
ctx.shadowBlur = Math.max(4, thick * 0.28);
ctx.beginPath();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)";
ctx.lineWidth = Math.max(1.4, thick * 0.22);
ctx.beginPath();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
ctx.restore();
ctx.fillStyle = powered ? (styleNight ? "#3c324e" : "#f4eaff") : (styleNight ? "#34343a" : "#ece8ef");
ctx.strokeStyle = powered ? (styleNight ? "#a28cc8" : "#6e4fa4") : "rgba(92,88,98,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(0, 0, Math.max(8, thick * 0.72), 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = powered ? (styleNight ? "#d8c5ff" : "#6e4fa4") : "rgba(70,68,76,0.82)";
ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(powered ? (speed >= 0 ? "↻" : "↺") : "○", 0, 0);
} else if (this.type === "reciprocator") {
const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const axisAngle = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(this) ?? (Number.isFinite(Number(this.reciprocatorAxisAngle)) ? Number(this.reciprocatorAxisAngle) : bodyAngle);
const powered = this.reciprocatorPowered !== false;
const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12));
const travel = Math.max(24, Number(this.reciprocatorTravel || 150) || 150);
const halfTravel = travel * 0.5;
const phase = Math.max(-1, Math.min(1, Number(this.reciprocatorPhase || 0) || 0));
const reciprocatorDir = Math.sign(Number(this.reciprocatorDirection || 1) || 1) || 1;
const passiveVelocity = Number(this.reciprocatorVelocity || 0) || 0;
const motorVelocity = powered ? reciprocatorDir * Math.max(0, Number(this.reciprocatorSpeed || 0) || 0) : 0;
const visualVelocity = motorVelocity + passiveVelocity;
const visualDirection = Math.abs(visualVelocity) > 0.8 ? Math.sign(visualVelocity) : reciprocatorDir;
const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0]]));
ctx.save();
ctx.rotate(axisAngle);
ctx.save();
ctx.globalAlpha *= styleNight ? 0.24 : 0.30;
ctx.strokeStyle = styleNight ? "rgba(130,174,220,0.50)" : "rgba(70,120,170,0.42)";
ctx.lineWidth = 2;
ctx.setLineDash([7, 6]);
const trackCenterX = -phase * halfTravel;
ctx.beginPath();
ctx.moveTo(trackCenterX - halfTravel, 0);
ctx.lineTo(trackCenterX + halfTravel, 0);
ctx.stroke();
ctx.setLineDash([]);
for (const xx of [trackCenterX - halfTravel, trackCenterX + halfTravel]) {
ctx.beginPath();
ctx.arc(xx, 0, 4.5, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
ctx.rotate(bodyAngle - axisAngle);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha *= powered ? 1 : 0.72;
ctx.strokeStyle = styleNight ? "#4f7192" : "#6da6cf";
ctx.lineWidth = thick;
ctx.shadowColor = powered ? (styleNight ? "rgba(86,130,180,0.34)" : "rgba(80,150,205,0.28)") : "rgba(74,82,90,0.20)";
ctx.shadowBlur = Math.max(4, thick * 0.28);
ctx.beginPath();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = powered ? (styleNight ? "rgba(220,238,255,0.44)" : "rgba(255,255,255,0.48)") : "rgba(255,255,255,0.28)";
ctx.lineWidth = Math.max(1.4, thick * 0.22);
ctx.beginPath();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
ctx.restore();
ctx.fillStyle = powered ? (styleNight ? "#2d3c4d" : "#eaf6ff") : (styleNight ? "#34383e" : "#e9edf0");
ctx.strokeStyle = powered ? (styleNight ? "#7faed0" : "#4d8cba") : "rgba(82,88,94,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(0, 0, Math.max(8, thick * 0.72), 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = powered ? (styleNight ? "#d8ecff" : "#235f91") : "rgba(72,76,82,0.82)";
ctx.font = `bold ${Math.max(10, thick * 0.78)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (powered || Math.abs(visualVelocity) > 0.8) {
ctx.save();
ctx.rotate(axisAngle);
ctx.fillText(visualDirection >= 0 ? "→" : "←", 0, 0);
ctx.restore();
} else {
ctx.fillText("○", 0, 0);
}
} else if (this.type === "rope" || this.type === "rod") {
const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(this.linkA, world);
const b = runtime?.endpointWorld?.(this.linkB, world);
if (a && b) {
const ax = a.x - this.x, ay = a.y - this.y;
const bx = b.x - this.x, by = b.y - this.y;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (this.type === "rod") {
ctx.strokeStyle = styleNight ? "rgba(176,166,150,0.96)" : "rgba(92,82,68,0.96)";
ctx.lineWidth = 7.2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(236,232,220,0.60)" : "rgba(255,248,228,0.56)";
ctx.lineWidth = 2.0;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
} else {
const mx = Number.isFinite(Number(this.linkMidX)) ? Number(this.linkMidX) - this.x : (ax + bx) * 0.5;
const my = Number.isFinite(Number(this.linkMidY)) ? Number(this.linkMidY) - this.y : (ay + by) * 0.5;
ctx.strokeStyle = styleNight ? "rgba(180,150,116,0.88)" : "rgba(132,94,54,0.92)";
ctx.lineWidth = 4.2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(mx, my, bx, by);
ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(236,214,180,0.56)" : "rgba(255,236,190,0.52)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(mx, my, bx, by);
ctx.stroke();
}
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)");
for (const [kx, ky] of [[ax, ay], [bx, by]]) {
ctx.beginPath(); ctx.arc(kx, ky, this.type === "rod" ? 4.8 : 4.2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
} else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "zunchi";
const img = images.get(zunchiId) || images.get("zunchi");

54
js/item_runtime.js Normal file
View file

@ -0,0 +1,54 @@
"use strict";
// Layer: entity-runtime/items
// Owns the public item update boundary. Item.prototype.update routes directly
// through the explicit lifecycle pipeline.
(function (global) {
function installPrototypeBoundary() {
const proto = global.Item?.prototype;
if (!proto || proto._tarinaiItemRuntimeInstalled) return false;
proto.update = function update(dt, worldRef = global.world) {
return global.TarinaiItemRuntime.updateOne(this, dt, worldRef);
};
Object.defineProperty(proto, "_tarinaiItemRuntimeInstalled", {
value: true,
configurable: true,
writable: true,
});
return true;
}
function updateOne(item, dt, worldRef = global.world) {
if (!item || item.dead || !global.TarinaiItemLifecyclePipeline?.updateOne) return false;
return global.TarinaiItemLifecyclePipeline.updateOne(item, dt, worldRef) !== false;
}
function updateScheduled(worldRef, dt) {
if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt);
const helpers = global.TarinaiSimulationRuntime?.helpers;
if (!helpers?.forScheduledItems || !helpers?.itemUpdateInterval) return 0;
let ran = 0;
helpers.forScheduledItems(worldRef, (item) => {
const interval = helpers.itemUpdateInterval(item);
if (!Number.isFinite(interval)) return;
if (interval <= 0) {
if (updateOne(item, dt, worldRef)) ran += 1;
return;
}
item._scheduledUpdateDt = Math.min(10, (item._scheduledUpdateDt || 0) + dt);
if (item._scheduledUpdateDt < interval) return;
const scheduledDt = item._scheduledUpdateDt;
item._scheduledUpdateDt = 0;
if (updateOne(item, scheduledDt, worldRef)) ran += 1;
});
return ran;
}
function trackedDynamicItems(worldRef) {
return global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef) || worldRef?._itemUpdateRealtime || [];
}
const api = Object.freeze({ installPrototypeBoundary, updateOne, updateScheduled, trackedDynamicItems });
global.TarinaiItemRuntime = api;
installPrototypeBoundary();
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -8,7 +8,7 @@ function initializeItemTypeState(item, type, x, y) {
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 (["firecracker", "genkotsu", "pushpin", "oshibyo", "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));
@ -101,6 +101,52 @@ function initializeItemTypeState(item, type, x, y) {
item.pinLogAt = -999;
item.deletable = true;
}
if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) {
item.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0;
item.amount = Math.max(item.amount || 0, 999);
}
if (type === "gate_fence") {
item.gateOpen = false;
item.gateLastToggleAt = -999;
}
if (type === "rotator") {
item.rotatorPowered = true;
item.rotatorSpeed = Math.PI * 0.65;
item.rotatorAngularVelocity = 0;
item.rotatorThickness = 12;
item.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]];
item.rotatorEditorOpenAt = -999;
item.amount = 999;
}
if (type === "rope" || type === "rod") {
item.amount = 999;
item.r = 48;
item.deletable = true;
item.linkA = null;
item.linkB = null;
item.linkLength = 80;
item.linkMidX = x;
item.linkMidY = y + 10;
item.linkMidVX = 0;
item.linkMidVY = 0;
}
if (type === "reciprocator") {
item.reciprocatorPowered = true;
item.reciprocatorSpeed = 92;
item.reciprocatorTravel = 150;
item.reciprocatorPhase = 0;
item.reciprocatorDirection = 1;
item.reciprocatorAxisAngle = Number.isFinite(Number(item.angle)) ? item.angle : 0;
item.reciprocatorVelocity = 0;
item.reciprocatorAnchorX = x;
item.reciprocatorAnchorY = y;
item.rotatorThickness = 12;
item.rotatorSegments = [[-78, 0, 78, 0]];
item.amount = 999;
}
if (isServingFoodType(type)) {
item.toolSize = "medium";
item.foodServingScale = 1;

82
js/item_update_policy.js Normal file
View file

@ -0,0 +1,82 @@
"use strict";
// Layer: entity-runtime/items/policy
// Owns the item update cadence and scheduled-item candidate set. Simulation
// helpers and the item scheduler both read this single policy instead of
// duplicating item-type timing rules.
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "rotator", "reciprocator", "rope", "rod"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = Object.freeze([
"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", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "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 0.5;
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 global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return 5.0;
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0;
return Infinity;
}
function ensureBuckets(worldRef) {
if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-policy");
}
function forScheduledItems(worldRef, visitor) {
if (!worldRef || typeof visitor !== "function") return 0;
let count = 0;
if (!worldRef.itemsOfType) {
for (const it of worldRef.items || []) {
if (!it || it.dead || it.type === "grass") continue;
visitor(it);
count += 1;
}
return count;
}
ensureBuckets(worldRef);
for (const type of SCHEDULED_ITEM_TYPES) {
const bucket = worldRef.itemsOfType(type);
for (const it of bucket || []) {
if (!it || it.dead) continue;
visitor(it);
count += 1;
}
}
return count;
}
function candidateItems(worldRef) {
const out = [];
forScheduledItems(worldRef, (item) => out.push(item));
return out;
}
function isRealtime(item) {
return itemUpdateInterval(item) <= 0;
}
global.TarinaiItemUpdatePolicy = Object.freeze({
REALTIME_ITEM_TYPES,
PIN_ITEM_TYPES,
SCHEDULED_ITEM_TYPES,
itemUpdateInterval,
forScheduledItems,
ensureBuckets,
candidateItems,
isRealtime,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,30 +1,22 @@
"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",
];
const policy = () => global.TarinaiItemUpdatePolicy || {};
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;
const interval = policy().itemUpdateInterval?.(item);
return Number.isFinite(interval) ? interval : Infinity;
}
function candidateItems(worldRef) {
if (policy().candidateItems) return policy().candidateItems(worldRef);
return (worldRef?.items || []).filter(it => it && !it.dead && it.type !== "grass");
}
function ensureBuckets(worldRef) {
if (policy().ensureBuckets) return policy().ensureBuckets(worldRef);
if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-scheduler");
return undefined;
}
function heapPush(heap, entry) {
@ -66,19 +58,6 @@
].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;
@ -132,7 +111,8 @@
continue;
}
if (interval <= 0) {
item.update(dt, worldRef);
if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(item, dt, worldRef);
else item.update(dt, worldRef);
item._schedulerLastAt = now;
ran += 1;
if (Number.isFinite(itemUpdateInterval(item)) && itemUpdateInterval(item) <= 0) nextRealtime.push(item);
@ -163,7 +143,8 @@
continue;
}
const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval)));
item.update(elapsed, worldRef);
if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef);
else item.update(elapsed, worldRef);
item._schedulerLastAt = now;
ran += 1;
dueRuns += 1;

View file

@ -389,14 +389,45 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {})
}
function drawStoredConsumableSprite(ctx, type = "", r = 10, seed = 1) {
drawConsumableFieldSprite(ctx, type || "food", r, seed, { compact: true });
const key = String(type || "food");
if (typeof isPinType === "function" && isPinType(key)) {
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(key) : null;
const assetId = behavior?.looseAsset || key;
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
ctx.save();
ctx.rotate(-0.35);
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
const ratio = metrics?.ratio || 2.0;
const w = key === "oshibyo" ? r * 1.30 : r * 1.70;
const h = w * ratio;
ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h);
} else {
ctx.fillStyle = key === "oshibyo" ? "#4d86d9" : "#d92736";
ctx.strokeStyle = "rgba(84,36,42,0.72)";
ctx.lineWidth = Math.max(1, r * 0.10);
ctx.beginPath();
ctx.ellipse(0, -r * 0.18, r * 0.58, r * 0.38, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.strokeStyle = "rgba(220,220,226,0.92)";
ctx.beginPath();
ctx.moveTo(0, r * 0.18);
ctx.lineTo(0, r * 1.25);
ctx.stroke();
}
ctx.restore();
return;
}
drawConsumableFieldSprite(ctx, key, r, seed, { compact: true });
}
function previewItemRadiusFor(type = "") {
const def = typeof itemDefinition === "function" ? itemDefinition(type) : null;
const base = Number(def?.radius || itemRadiusFor?.(type, 16) || 16);
if (type === "genkotsu") return 30;
if (type === "fence_v" || type === "fence_h") return 24;
if (type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") return 24;
if (type === "rotator") return 25;
if (type === "reciprocator") return 26;
if (type === "nest_box") return 26;
if (type === "duplicator") return 24;
if (type === "signboard") return 22;
@ -405,6 +436,60 @@ function previewItemRadiusFor(type = "") {
return clamp(base, 8, 24);
}
function canvasAlphaBounds(canvas, alphaThreshold = 8) {
const w = Math.max(1, Math.floor(canvas?.width || 0));
const h = Math.max(1, Math.floor(canvas?.height || 0));
if (!w || !h) return null;
const ctx = canvas.getContext?.("2d", { willReadFrequently: true });
if (!ctx) return null;
let data = null;
try {
data = ctx.getImageData(0, 0, w, h).data;
} catch (_) {
return null;
}
let minX = w;
let minY = h;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (data[(y * w + x) * 4 + 3] <= alphaThreshold) continue;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
if (maxX < minX || maxY < minY) return null;
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
}
function normalizeToolPreviewCanvas(targetCtx, sourceCanvas, w, h, opts = {}) {
if (!targetCtx || !sourceCanvas) return false;
const box = canvasAlphaBounds(sourceCanvas);
targetCtx.clearRect(0, 0, w, h);
if (!box) return false;
const watermark = !!opts.watermark;
const targetScale = Number.isFinite(Number(opts.targetScale)) ? Math.max(0.35, Number(opts.targetScale)) : 1;
const maxW = w * (watermark ? 0.68 : 0.86) * targetScale;
const maxH = h * (watermark ? 0.68 : 0.82) * targetScale;
const scale = Math.min(maxW / Math.max(1, box.w), maxH / Math.max(1, box.h));
const drawW = box.w * scale;
const drawH = box.h * scale;
const centerX = watermark ? w * 0.78 : w * 0.50;
const centerY = watermark ? h * 0.80 : h * 0.48;
const dx = centerX - drawW / 2;
const dy = centerY - drawH / 2;
targetCtx.save();
targetCtx.imageSmoothingEnabled = true;
targetCtx.imageSmoothingQuality = "high";
targetCtx.drawImage(sourceCanvas, box.x, box.y, box.w, box.h, dx, dy, drawW, drawH);
targetCtx.restore();
return true;
}
function drawToolItemPreview(ctx, type = "", opts = {}) {
if (!ctx || !type) return false;
const w = Math.max(1, Number(opts.width || ctx.canvas?.width || 72));
@ -443,9 +528,25 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
toolSize: "medium",
});
if (type === "water") item.amount = 50;
const outputCtx = ctx;
let sourceCanvas = null;
let drawCtx = ctx;
try {
const ownerDocument = ctx.canvas?.ownerDocument || (typeof document !== "undefined" ? document : null);
sourceCanvas = ownerDocument?.createElement?.("canvas") || null;
if (sourceCanvas) {
sourceCanvas.width = w;
sourceCanvas.height = h;
drawCtx = sourceCanvas.getContext("2d") || ctx;
}
} catch (_) {
sourceCanvas = null;
drawCtx = ctx;
}
ctx = drawCtx;
ctx.save();
ctx.clearRect(0, 0, w, h);
const baseOffsetY = type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" ? 1 : 4;
const baseOffsetY = type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence" ? 1 : 4;
const centerX = watermark ? w * 0.78 : w / 2;
const centerY = watermark ? (h * 0.80 + baseOffsetY * 0.15) : (h / 2 + baseOffsetY);
ctx.translate(centerX, centerY);
@ -457,17 +558,23 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
duplicator: 0.82,
fence_v: 0.70,
fence_h: 0.70,
bounce_fence: 0.74,
bounce_fence_v: 0.74,
gate_fence: 0.74,
reciprocator: 0.76,
ant_nest: 0.92,
ball: 0.92,
pushpin: 0.92,
oshibyo: 0.92,
oshibyo: 0.62,
firecracker: 0.90,
giant_drug: 1.22,
dwarf_drug: 0.56,
};
let scale = scaleMap[type] || 0.92;
if (isConsumableSpriteType(type)) scale *= 1.95;
else if (["grass", "water", "water_bowl", "zunda_juice"].includes(type)) scale *= 1.85;
else if (type === "duplicator") scale *= 1.55;
else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "firecracker", "genkotsu"].includes(type)) scale *= 1.08;
else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "genkotsu"].includes(type)) scale *= 1.08;
else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) scale *= 1.35;
if (watermark) scale *= 1.42;
ctx.scale(scale, scale);
@ -478,6 +585,10 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
return false;
}
ctx.restore();
if (sourceCanvas && outputCtx !== ctx) {
const previewScaleOverrides = { giant_drug: 1.55, dwarf_drug: 0.55, oshibyo: 0.58 };
normalizeToolPreviewCanvas(outputCtx, sourceCanvas, w, h, { watermark, targetScale: previewScaleOverrides[type] || 1 });
}
return true;
}

View file

@ -1,4 +1,20 @@
"use strict";
function ensureWorldBootstrap() {
let currentWorld = globalThis.world;
if (currentWorld?.update) return currentWorld;
try {
currentWorld = new World();
if (typeof window !== "undefined") window.world = currentWorld;
if (typeof globalThis !== "undefined") globalThis.world = currentWorld;
return currentWorld;
} catch (error) {
if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error);
throw error;
}
}
let world = ensureWorldBootstrap();
let last = nowSec();
let statsTimer = 0;
let fpsTimer = 0;
@ -114,6 +130,7 @@ loadImages(null, (done, total) => {
setLoadingProgress("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u69cb\u7bc9\u4e2d", 0.72);
applyFieldLayout("garden");
world.reset(null, "garden");
window.bindGroundUI?.(world);
selectTool("observe");
setLoadingProgress("\u8868\u793a\u3092\u521d\u671f\u5316\u4e2d", 0.84);
renderLog(world.logs);

499
js/mechanical_system.js Normal file
View file

@ -0,0 +1,499 @@
"use strict";
// Layer: physics/mechanical-body
// Common body, footprint, motion, and contact runtime for 回転体 and 往復体.
(function (global) {
const FP = () => global.TarinaiCollisionFootprints || {};
function num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function typeOf(itemOrType) { return typeof itemOrType === "string" ? itemOrType : String(itemOrType?.type || ""); }
function isMechanicalType(itemOrType) { return typeOf(itemOrType) === "rotator" || typeOf(itemOrType) === "reciprocator"; }
function motionType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" ? "rotate" : (t === "reciprocator" ? "reciprocate" : "none"); }
function isPowered(item) { return item?.type === "rotator" ? item.rotatorPowered !== false : item?.reciprocatorPowered !== false; }
function itemAngle(item) { return typeof global.itemAngleFor === "function" ? global.itemAngleFor(item) : num(item?.angle, 0); }
function normalizeAngle(angle = 0) { return typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(angle, 0) : angle; }
function reciprocatorAxisAngle(item) {
const fallback = itemAngle(item);
return normalizeAngle(num(item?.reciprocatorAxisAngle, fallback));
}
function sanitizeSegments(item) {
const raw = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : [];
const out = [];
const clampCoord = (v) => Math.max(-420, Math.min(420, num(v)));
for (const seg of raw) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = clampCoord(seg[0]), y1 = clampCoord(seg[1]), x2 = clampCoord(seg[2]), y2 = clampCoord(seg[3]);
if (Math.hypot(x2 - x1, y2 - y1) < 4) continue;
out.push([x1, y1, x2, y2]);
if (out.length >= 96) break;
}
if (!out.length) out.push(item?.type === "rotator" ? [-78, 0, 78, 0] : [-78, 0, 78, 0], ...(item?.type === "rotator" ? [[0, -52, 0, 52]] : []));
return out;
}
function thickness(item) { return Math.max(4, Math.min(34, num(item?.rotatorThickness, 12))); }
function extent(item) {
let maxD = 48;
for (const seg of sanitizeSegments(item)) maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3]));
return Math.min(460, maxD + Math.max(8, thickness(item)) + 8);
}
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
const c = Math.cos(angle), s = Math.sin(angle);
const ex = Math.abs(c) * halfW + Math.abs(s) * halfH;
const ey = Math.abs(s) * halfW + Math.abs(c) * halfH;
return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s };
}
function worldSegments(item) {
if (!item || item.dead || !isMechanicalType(item)) return [];
const a = itemAngle(item);
const c = Math.cos(a), s = Math.sin(a);
const cx = num(item.x), cy = num(item.y);
return sanitizeSegments(item).map(([x1, y1, x2, y2]) => [
cx + x1 * c - y1 * s,
cy + x1 * s + y1 * c,
cx + x2 * c - y2 * s,
cy + x2 * s + y2 * c,
]);
}
function axis(item) {
const a = item?.type === "reciprocator" ? reciprocatorAxisAngle(item) : itemAngle(item);
return { x: Math.cos(a), y: Math.sin(a), angle: a };
}
function halfTravel(item) { return Math.max(24, num(item?.reciprocatorTravel, 150)) * 0.5; }
function resetAnchor(item) {
if (!item) return;
if (!Number.isFinite(Number(item.reciprocatorAnchorX))) item.reciprocatorAnchorX = num(item.x);
if (!Number.isFinite(Number(item.reciprocatorAnchorY))) item.reciprocatorAnchorY = num(item.y);
}
function positionFromPhase(item) {
resetAnchor(item);
const a = axis(item);
const travel = halfTravel(item);
const phase = Math.max(-1, Math.min(1, num(item.reciprocatorPhase, 0)));
return { x: num(item.reciprocatorAnchorX) + a.x * travel * phase, y: num(item.reciprocatorAnchorY) + a.y * travel * phase };
}
function phaseFromPosition(item) {
resetAnchor(item);
const a = axis(item);
const travel = halfTravel(item);
const dx = num(item.x) - num(item.reciprocatorAnchorX);
const dy = num(item.y) - num(item.reciprocatorAnchorY);
return Math.max(-1, Math.min(1, (dx * a.x + dy * a.y) / Math.max(10, travel)));
}
function signedDrive(item) {
if (!item) return 0;
if (item.type === "rotator") return (isPowered(item) ? num(item.rotatorSpeed) : 0) + num(item.rotatorAngularVelocity);
if (item.type === "reciprocator") {
const dir = Math.sign(num(item.reciprocatorDirection, 1)) || 1;
return (isPowered(item) ? dir * Math.max(0, num(item.reciprocatorSpeed, 0)) : 0) + num(item.reciprocatorVelocity);
}
return 0;
}
function pointVelocity(item, x, y) {
if (!item) return { x: 0, y: 0 };
if (item.type === "rotator") {
const omega = signedDrive(item);
const rx = num(x) - num(item.x);
const ry = num(y) - num(item.y);
return { x: -ry * omega, y: rx * omega };
}
if (item.type === "reciprocator") {
const a = axis(item);
const v = signedDrive(item);
return { x: a.x * v, y: a.y * v };
}
return { x: 0, y: 0 };
}
function segmentRect(item, x1, y1, x2, y2) {
const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1));
const angle = Math.atan2(y2 - y1, x2 - x1);
const halfW = len / 2;
const halfH = thickness(item) / 2;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle);
const base = {
left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom,
cx, cy, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true,
type: item.type, item, mechanical: true, motionType: motionType(item), restitution: item.type === "rotator" ? 0.72 : 0.70,
};
if (item.type === "rotator") {
return {
...base,
rotator: true,
angularVelocity: signedDrive(item),
centerX: num(item.x), centerY: num(item.y),
};
}
const a = axis(item);
const v = signedDrive(item);
return { ...base, reciprocator: true, motionVelocityX: a.x * v, motionVelocityY: a.y * v, axisX: a.x, axisY: a.y };
}
function obstacleRects(item) {
if (!item || item.dead || !isMechanicalType(item)) return [];
return worldSegments(item).map(seg => segmentRect(item, seg[0], seg[1], seg[2], seg[3]));
}
function boundsAabb(item) {
const rects = obstacleRects(item);
if (!rects.length) return null;
return {
left: Math.min(...rects.map(r => r.left)), right: Math.max(...rects.map(r => r.right)),
top: Math.min(...rects.map(r => r.top)), bottom: Math.max(...rects.map(r => r.bottom)),
type: item.type, item,
};
}
function reach(item) {
if (!item) return 64;
const base = Math.max(num(item.r, 64), extent(item));
if (item.type === "reciprocator") return Math.max(90, base + num(item.reciprocatorTravel, 150) * 0.55);
return base;
}
function applyImpulse(item, contactX, contactY, fx, fy, scale = 1) {
if (!item || item.dead || !isMechanicalType(item)) return false;
const powered = isPowered(item);
if (item.type === "rotator") {
const rx = num(contactX) - num(item.x);
const ry = num(contactY) - num(item.y);
const torque = rx * num(fy) - ry * num(fx);
const denom = powered ? 38000 : 15000;
const delta = clamp(torque / denom * scale, -0.46, 0.46);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0008) return false;
const limit = powered ? 3.2 : 2.25;
item.rotatorAngularVelocity = clamp(num(item.rotatorAngularVelocity) + delta, -limit, limit);
return true;
}
const a = axis(item);
const along = num(fx) * a.x + num(fy) * a.y;
const delta = clamp(along * (powered ? 0.28 : 0.52) * scale, -52, 52);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.05) return false;
const limit = powered ? 260 : 150;
item.reciprocatorVelocity = clamp(num(item.reciprocatorVelocity) + delta, -limit, limit);
return true;
}
function applyPassiveReciprocatorImpulse(item, axisX, axisY, nx, ny, vx, vy, scale = 1) {
if (!item || item.reciprocatorPowered !== false) return false;
const ax = num(axisX, 1), ay = num(axisY, 0), nX = num(nx), nY = num(ny);
const hitVx = num(vx), hitVy = num(vy);
const incomingNormal = Math.max(0, -(hitVx * nX + hitVy * nY));
const axisVel = hitVx * ax + hitVy * ay;
const normalDrive = -(nX * ax + nY * ay) * incomingNormal;
const axisDrive = Math.abs(axisVel) >= 3 ? axisVel : 0;
const delta = clamp((axisDrive * 0.030 + normalDrive * 0.080) * scale, -24, 24);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.08) return false;
item.reciprocatorVelocity = clamp(num(item.reciprocatorVelocity) * 0.84 + delta, -125, 125);
return true;
}
function applyPassiveRotatorImpulse(item, rx, ry, nx, ny, vx, vy, scale = 1) {
if (!item || item.rotatorPowered !== false) return false;
const hitVx = num(vx), hitVy = num(vy), nX = num(nx), nY = num(ny);
const arm2 = Math.max(4200, rx * rx + ry * ry);
const tangential = (hitVx * -ry + hitVy * rx) / arm2;
const incomingNormal = Math.max(0, -(hitVx * nX + hitVy * nY));
const normalTorque = ((-nX) * -ry + (-nY) * rx) / Math.max(28, Math.hypot(rx, ry));
const delta = clamp((tangential * 0.14 + normalTorque * incomingNormal * 0.0016) * scale, -0.18, 0.18);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.002) return false;
item.rotatorAngularVelocity = clamp(num(item.rotatorAngularVelocity) * 0.86 + delta, -1.75, 1.75);
return true;
}
function applySurfaceVelocityToCircle(obj, rect, nx, ny, preVx = 0, preVy = 0, opts = {}) {
if (!obj || !rect || !rect.mechanical) return false;
const nX = num(nx);
const nY = num(ny);
const vx = num(preVx, num(obj.vx));
const vy = num(preVy, num(obj.vy));
const maxSpeed = Math.max(80, num(opts.maxSpeed, 900));
const normalBoost = num(opts.normalBoost, rect.rotator ? 20 : 14);
const surfaceScale = num(opts.surfaceScale, rect.rotator ? 0.28 : 0.30);
const damping = clamp(num(opts.damping, 1), 0, 1.2);
let applied = false;
if (rect.rotator) {
const omega = clamp(num(rect.angularVelocity), -8, 8);
const rx = num(obj.x) - num(rect.centerX, num(rect.item?.x, num(rect.cx)));
const ry = num(obj.y) - num(rect.centerY, num(rect.item?.y, num(rect.cy)));
const tvx = -ry * omega;
const tvy = rx * omega;
obj.vx = clamp(num(obj.vx) * damping + tvx * surfaceScale + nX * normalBoost, -maxSpeed, maxSpeed);
obj.vy = clamp(num(obj.vy) * damping + tvy * surfaceScale + nY * normalBoost, -maxSpeed, maxSpeed);
if (opts.impulseVScale) {
obj.impulseVx = clamp(num(obj.impulseVx) + tvx * num(opts.impulseVScale), -num(opts.impulseMax, 360), num(opts.impulseMax, 360));
obj.impulseVy = clamp(num(obj.impulseVy) + tvy * num(opts.impulseVScale), -num(opts.impulseMax, 360), num(opts.impulseMax, 360));
}
applyPassiveRotatorImpulse(rect.item, rx, ry, nX, nY, vx, vy, num(opts.passiveImpulseScale, 1));
applied = true;
} else if (rect.reciprocator) {
const mvx = clamp(num(rect.motionVelocityX), -360, 360);
const mvy = clamp(num(rect.motionVelocityY), -360, 360);
obj.vx = clamp(num(obj.vx) * damping + mvx * surfaceScale + nX * normalBoost, -maxSpeed, maxSpeed);
obj.vy = clamp(num(obj.vy) * damping + mvy * surfaceScale + nY * normalBoost, -maxSpeed, maxSpeed);
if (opts.impulseVScale) {
obj.impulseVx = clamp(num(obj.impulseVx) + mvx * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320));
obj.impulseVy = clamp(num(obj.impulseVy) + mvy * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320));
}
applyPassiveReciprocatorImpulse(rect.item, rect.axisX || 1, rect.axisY || 0, nX, nY, vx, vy, num(opts.passiveImpulseScale, 1));
applied = true;
}
if (applied && Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp(num(obj.spinVelocity) + (nX >= 0 ? -1 : 1) * num(opts.spinKick, 7.0), -46, 46);
return applied;
}
function applyRailCorrection(item, nx, ny, overlap, scale = 1) {
if (!item || item.dead || item.type !== "reciprocator" || item.playerHeld || item._heldByPlayer) return false;
resetAnchor(item);
const a = axis(item);
const push = Math.max(0, num(overlap)) * Math.max(0, num(scale));
const along = (-num(nx) * a.x + -num(ny) * a.y) * push;
if (!Number.isFinite(along) || Math.abs(along) < 0.01) return false;
const before = num(item.reciprocatorPhase);
item.reciprocatorPhase = clamp(before + along / Math.max(10, halfTravel(item)), -1, 1);
const p = positionFromPhase(item);
item.x = p.x;
item.y = p.y;
return Math.abs(item.reciprocatorPhase - before) > 0.0001;
}
function strongestRectContact(rectsA, rectsB, padding = 0) {
let best = null;
const overlapInfo = FP().rectOverlapInfo;
if (typeof overlapInfo !== "function") return null;
for (const ra of rectsA || []) {
for (const rb of rectsB || []) {
const info = overlapInfo(ra, rb, padding);
if (!info) continue;
if (!best || info.overlap > best.info.overlap) best = { ra, rb, info };
}
}
return best;
}
function resolvePair(a, b, dt, worldRef) {
if (!a || !b || a.dead || b.dead || !isMechanicalType(a) || !isMechanicalType(b)) return false;
const contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75);
if (!contact) return false;
const now = worldRef?.time || 0;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
worldRef._mechanicalContactAt = worldRef._mechanicalContactAt || Object.create(null);
const recentlyHandled = (worldRef._mechanicalContactAt[key] || -999) + 0.045 > now;
worldRef._mechanicalContactAt[key] = now;
const { info } = contact;
const va = pointVelocity(a, info.x, info.y);
const vb = pointVelocity(b, info.x, info.y);
const relClosing = (va.x - vb.x) * info.nx + (va.y - vb.y) * info.ny;
const closing = Math.max(0, relClosing);
const powerMul = isPowered(a) && isPowered(b) ? 1.35 : (isPowered(a) || isPowered(b) ? 1.12 : 0.82);
const impulse = clamp((closing * 0.18 + info.overlap * 6.4 + 5.0) * powerMul, 4, 82);
applyImpulse(a, info.x, info.y, -info.nx * impulse, -info.ny * impulse, 1.0);
applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, 1.0);
applyRailCorrection(a, info.nx, info.ny, info.overlap, b.type === "reciprocator" ? 0.42 : 0.72);
applyRailCorrection(b, -info.nx, -info.ny, info.overlap, a.type === "reciprocator" ? 0.42 : 0.72);
if (!recentlyHandled) worldRef?.effects?.push(new Effect("ring", info.x, info.y, { size: Math.max(14, Math.min(28, 12 + info.overlap)), life: 0.14, color: "rgba(184,132,230,0.34)" }));
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact"); }
return true;
}
function fenceRectsNear(item, worldRef, radius) {
const out = [];
if (!item || !worldRef?.items) return out;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items;
for (const other of source) {
if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue;
if (other.type === "gate_fence" && other.gateOpen) continue;
for (const rect of worldRef.solidObstacleRects?.(other) || []) out.push(rect);
}
return out;
}
function resolveFenceContacts(item, dt, worldRef, radius = null) {
if (!item || !worldRef || !isMechanicalType(item)) return false;
const rects = obstacleRects(item);
const fences = fenceRectsNear(item, worldRef, radius || reach(item) + 80);
let changed = false;
const now = worldRef.time || 0;
for (const mechRect of rects) {
for (const fenceRect of fences) {
const info = FP().rectOverlapInfo?.(mechRect, fenceRect, 0.5);
if (!info) continue;
const v = pointVelocity(item, info.x, info.y);
const closing = Math.max(0, v.x * info.nx + v.y * info.ny);
const impulse = clamp(closing * 0.26 + info.overlap * 9.5 + 8.0, 6, 112);
applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, 1.25);
applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.95);
const key = `f:${item.id}:${fenceRect.item?.id || "?"}`;
worldRef._mechanicalContactAt = worldRef._mechanicalContactAt || Object.create(null);
if ((worldRef._mechanicalContactAt[key] || -999) + 0.08 <= now) {
worldRef._mechanicalContactAt[key] = now;
worldRef.effects?.push(new Effect("ring", info.x, info.y, { size: Math.max(14, Math.min(30, 13 + info.overlap)), life: 0.13, color: "rgba(145,120,94,0.34)" }));
}
changed = true;
}
}
if (changed) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanism-fence-contact"); }
return changed;
}
function resolveInteractions(item, dt, worldRef) {
if (!item || !worldRef?.items || !isMechanicalType(item)) return false;
let changed = false;
const radius = reach(item) + 80;
for (const other of worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items) {
if (!other || other === item || other.dead || !isMechanicalType(other)) continue;
changed = resolvePair(item, other, dt, worldRef) || changed;
}
changed = resolveFenceContacts(item, dt, worldRef, radius) || changed;
return changed;
}
function updateRotator(item, dt, worldRef) {
if (!item || item.dead || item.type !== "rotator") return false;
item.amount = 999;
if (item.playerHeld || item._heldByPlayer) return false;
const powered = isPowered(item);
const passiveSpeed = num(item.rotatorAngularVelocity);
const speed = (powered ? num(item.rotatorSpeed) : 0) + passiveSpeed;
item.rotatorAngularVelocity = passiveSpeed * Math.pow(powered ? 0.18 : 0.88, Math.max(0.016, dt || 0.016));
if (Math.abs(item.rotatorAngularVelocity) < 0.004) item.rotatorAngularVelocity = 0;
const ext = extent(item);
item.r = Math.max(item.r || 64, Math.min(460, ext));
if (!speed || !Number.isFinite(speed)) return resolveInteractions(item, dt, worldRef);
const totalDelta = speed * dt;
const maxAngleStep = clamp(thickness(item) / Math.max(90, ext) * 0.75, 0.018, 0.055);
const steps = Math.max(1, Math.min(36, Math.ceil(Math.abs(totalDelta) / maxAngleStep)));
const stepDelta = totalDelta / steps;
const stepDt = Math.max(0.001, (dt || 0.016) / steps);
for (let i = 0; i < steps; i += 1) {
item.prevAngle = num(item.angle);
item.angle = normalizeAngle(num(item.angle) + stepDelta);
resolveInteractions(item, stepDt, worldRef);
}
item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt;
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin"); }
return true;
}
function updateReciprocator(item, dt, worldRef) {
if (!item || item.dead || item.type !== "reciprocator") return false;
item.amount = 999;
resetAnchor(item);
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
const powered = isPowered(item);
const travel = halfTravel(item);
const prevX = num(item.x, item.reciprocatorAnchorX || 0), prevY = num(item.y, item.reciprocatorAnchorY || 0);
item.prevX = prevX; item.prevY = prevY;
const baseSpeed = Math.max(0, num(item.reciprocatorSpeed, 92));
const estimateVelocity = (powered ? Math.sign(num(item.reciprocatorDirection, 1)) * baseSpeed : 0) + num(item.reciprocatorVelocity);
const steps = Math.max(1, Math.min(32, Math.ceil(Math.abs(estimateVelocity * dt) / 8)));
const stepDt = Math.max(0.001, (dt || 0.016) / steps);
for (let i = 0; i < steps; i += 1) {
const dir = Math.sign(num(item.reciprocatorDirection, 1)) || 1;
const passive = num(item.reciprocatorVelocity);
const total = (powered ? dir * baseSpeed : 0) + passive;
if (Number.isFinite(total) && Math.abs(total) > 0.001) {
item.reciprocatorPhase = num(item.reciprocatorPhase) + total * stepDt / Math.max(10, travel);
if (item.reciprocatorPhase > 1) {
item.reciprocatorPhase = 1;
if (powered && total > 0) item.reciprocatorDirection = -1;
item.reciprocatorVelocity = passive > 0 ? -Math.abs(passive) * 0.28 : (!powered ? -Math.abs(total) * 0.20 : item.reciprocatorVelocity);
}
if (item.reciprocatorPhase < -1) {
item.reciprocatorPhase = -1;
if (powered && total < 0) item.reciprocatorDirection = 1;
item.reciprocatorVelocity = passive < 0 ? Math.abs(passive) * 0.28 : (!powered ? Math.abs(total) * 0.20 : item.reciprocatorVelocity);
}
}
const p = positionFromPhase(item);
item.x = p.x; item.y = p.y;
resolveInteractions(item, stepDt, worldRef);
}
item.reciprocatorVelocity = num(item.reciprocatorVelocity) * Math.pow(powered ? 0.22 : 0.90, Math.max(0.016, dt || 0.016));
if (Math.abs(item.reciprocatorVelocity) < 0.08) item.reciprocatorVelocity = 0;
const moved = Math.hypot(num(item.x) - prevX, num(item.y) - prevY);
if (moved > 0.001 || Math.abs(item.reciprocatorVelocity) > 0.001) {
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("reciprocator-motion"); }
return true;
}
return false;
}
function applyPokeImpulse(item, x, y, worldRef) {
if (!item || !isMechanicalType(item)) return false;
if (item.type === "rotator" && item.rotatorPowered === false) {
const a = itemAngle(item);
const side = Math.sign((num(x) - num(item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(item.y)) * Math.sin(a + Math.PI / 2)) || (Math.random() < 0.5 ? -1 : 1);
item.rotatorAngularVelocity = clamp(num(item.rotatorAngularVelocity) * 0.82 + side * 0.32, -1.6, 1.6);
worldRef?.markSpatialDirty?.("poke-passive-rotator");
return true;
}
if (item.type === "reciprocator" && item.reciprocatorPowered === false) {
const a = axis(item);
const side = Math.sign((num(x) - num(item.x)) * a.x + (num(y) - num(item.y)) * a.y) || (Math.random() < 0.5 ? -1 : 1);
item.reciprocatorVelocity = clamp(num(item.reciprocatorVelocity) * 0.76 + side * 34, -115, 115);
worldRef?.markSpatialDirty?.("poke-passive-reciprocator");
return true;
}
return false;
}
function hitTest(worldRef, item, x, y, opts = {}) {
return FP().hitTestItem?.(worldRef, item, x, y, opts) || { hit: false, distance: Infinity };
}
global.TarinaiMechanicalSystem = Object.freeze({
isMechanicalType,
motionType,
isPowered,
itemAngle,
sanitizeSegments,
thickness,
extent,
worldSegments,
obstacleRects,
boundsAabb,
axis,
reciprocatorAxisAngle,
halfTravel,
resetAnchor,
positionFromPhase,
phaseFromPosition,
signedDrive,
pointVelocity,
applyImpulse,
applyPassiveReciprocatorImpulse,
applyPassiveRotatorImpulse,
applyRailCorrection,
applySurfaceVelocityToCircle,
strongestRectContact,
resolvePair,
resolveFenceContacts,
resolveInteractions,
updateRotator,
updateReciprocator,
applyPokeImpulse,
hitTest,
reach,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,6 +1,103 @@
"use strict";
(function (global) {
function rectLocalPoint(rect, x, y) {
const dx = x - (rect.cx ?? 0);
const dy = y - (rect.cy ?? 0);
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(rect.angle || 0);
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(rect.angle || 0);
return { x: dx * c + dy * ss, y: -dx * ss + dy * c };
}
function rectWorldVector(rect, x, y) {
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(rect.angle || 0);
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(rect.angle || 0);
return { x: x * c - y * ss, y: x * ss + y * c };
}
function circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts = {}) {
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
const minBounceSpeed = Math.max(0, Number(opts.minBounceSpeed ?? rect.minBounceSpeed ?? 0) || 0);
const local = rectLocalPoint(rect, obj.x, obj.y);
let clx = clamp(local.x, -(rect.halfW || 0), rect.halfW || 0);
let cly = clamp(local.y, -(rect.halfH || 0), rect.halfH || 0);
let dx = local.x - clx;
let dy = local.y - cly;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = obj.prevX;
const py = obj.prevY;
if (!Number.isFinite(px) || !Number.isFinite(py)) return false;
const prev = rectLocalPoint(rect, px, py);
const expanded = { left: -(rect.halfW || 0) - hitRadius, right: (rect.halfW || 0) + hitRadius, top: -(rect.halfH || 0) - hitRadius, bottom: (rect.halfH || 0) + hitRadius };
const insideNow = local.x >= expanded.left && local.x <= expanded.right && local.y >= expanded.top && local.y <= expanded.bottom;
const insidePrev = prev.x >= expanded.left && prev.x <= expanded.right && prev.y >= expanded.top && prev.y <= expanded.bottom;
if (!insideNow && !insidePrev) return false;
const sx = local.x - prev.x;
const sy = local.y - prev.y;
const candidates = [];
if (prev.x < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - prev.x) / Math.max(sx, 0.001) });
if (prev.x > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (prev.x - expanded.right) / Math.max(-sx, 0.001) });
if (prev.y < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - prev.y) / Math.max(sy, 0.001) });
if (prev.y > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (prev.y - expanded.bottom) / Math.max(-sy, 0.001) });
if (!candidates.length) return false;
candidates.sort((a, b) => a.t - b.t);
dx = candidates[0].nx;
dy = candidates[0].ny;
d = 1;
} else if (d < 0.001) {
const left = Math.abs(local.x + (rect.halfW || 0));
const right = Math.abs((rect.halfW || 0) - local.x);
const top = Math.abs(local.y + (rect.halfH || 0));
const bottom = Math.abs((rect.halfH || 0) - local.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; clx = -(rect.halfW || 0); cly = local.y; }
else if (m === right) { dx = 1; dy = 0; clx = rect.halfW || 0; cly = local.y; }
else if (m === top) { dx = 0; dy = -1; clx = local.x; cly = -(rect.halfH || 0); }
else { dx = 0; dy = 1; clx = local.x; cly = rect.halfH || 0; }
d = 1;
}
const nxLocal = dx / d;
const nyLocal = dy / d;
const n = rectWorldVector(rect, nxLocal, nyLocal);
const nx = n.x;
const ny = n.y;
const worldClosest = rectWorldVector(rect, clx, cly);
obj.x = (rect.cx || 0) + worldClosest.x + nx * (hitRadius + 0.5);
obj.y = (rect.cy || 0) + worldClosest.y + ny * (hitRadius + 0.5);
const vx = obj.vx || 0;
const vy = obj.vy || 0;
const toward = vx * nx + vy * ny;
if (toward < 0) {
obj.vx = vx - (1 + restitution) * toward * nx;
obj.vy = vy - (1 + restitution) * toward * ny;
} else {
obj.vx = vx + nx * 36;
obj.vy = vy + ny * 36;
}
if (minBounceSpeed > 0) {
const normalSpeed = (obj.vx || 0) * nx + (obj.vy || 0) * ny;
if (normalSpeed < minBounceSpeed) {
const add = minBounceSpeed - normalSpeed;
obj.vx = (obj.vx || 0) + nx * add;
obj.vy = (obj.vy || 0) + ny * add;
}
}
if (rect.mechanical) {
global.TarinaiMechanicalSystem?.applySurfaceVelocityToCircle?.(obj, rect, nx, ny, vx, vy, {
damping: 1,
surfaceScale: rect.rotator ? 0.28 : 0.30,
normalBoost: rect.rotator ? 20 : 14,
maxSpeed: 900,
passiveImpulseScale: 1.0,
spinKick: 7.0,
});
} else if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
return true;
}
function bounceInsideWorld(obj, worldRef, opts = {}) {
if (!obj || !worldRef) return false;
const p = Math.max(opts.padding ?? 28, CONFIG.worldPadding || 30);
@ -16,6 +113,120 @@
return bounced;
}
function bounceCircleOffRect(obj, rect, radius, restitution = 1.08, worldRef = null, opts = {}) {
if (!obj || !rect) return false;
if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts);
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
const minBounceSpeed = Math.max(0, Number(opts.minBounceSpeed ?? rect.minBounceSpeed ?? 0) || 0);
const ensureMinNormalSpeed = (nx, ny) => {
if (!(minBounceSpeed > 0)) return;
const normalSpeed = (obj.vx || 0) * nx + (obj.vy || 0) * ny;
if (normalSpeed >= minBounceSpeed) return;
const add = minBounceSpeed - normalSpeed;
obj.vx = (obj.vx || 0) + nx * add;
obj.vy = (obj.vy || 0) + ny * add;
};
const cx = clamp(obj.x, rect.left, rect.right);
const cy = clamp(obj.y, rect.top, rect.bottom);
let dx = obj.x - cx;
let dy = obj.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = obj.prevX;
const py = obj.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, obj.x, obj.y, expanded)) return false;
const vx = obj.vx || 0;
const vy = obj.vy || 0;
const candidates = [];
const sx = obj.x - px;
const sy = obj.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) });
let nx = 0;
let ny = 0;
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) obj.x = expanded.left - 0.5;
else if (nx > 0) obj.x = expanded.right + 0.5;
if (ny < 0) obj.y = expanded.top - 0.5;
else if (ny > 0) obj.y = expanded.bottom + 0.5;
if (nx) obj.y = clamp(obj.y, expanded.top, expanded.bottom);
if (ny) obj.x = clamp(obj.x, expanded.left, expanded.right);
if (toward < 0) {
obj.vx = vx - (1 + restitution) * toward * nx;
obj.vy = vy - (1 + restitution) * toward * ny;
} else {
obj.vx = vx + nx * 36;
obj.vy = vy + ny * 36;
}
ensureMinNormalSpeed(nx, ny);
if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
return true;
}
if (d < 0.001) {
const left = Math.abs(obj.x - rect.left);
const right = Math.abs(rect.right - obj.x);
const top = Math.abs(obj.y - rect.top);
const bottom = Math.abs(rect.bottom - obj.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 vx = obj.vx || 0;
const vy = obj.vy || 0;
const toward = vx * nx + vy * ny;
obj.x = cx + nx * (hitRadius + 0.5);
obj.y = cy + ny * (hitRadius + 0.5);
if (toward < 0) {
obj.vx = vx - (1 + restitution) * toward * nx;
obj.vy = vy - (1 + restitution) * toward * ny;
} else {
obj.vx = vx + nx * 36;
obj.vy = vy + ny * 36;
}
ensureMinNormalSpeed(nx, ny);
if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
return true;
}
function resolveKinematicBounceFenceCollision(item, worldRef, dt = 0.016, opts = {}) {
if (!item || !worldRef?.nearbySolidObstacleRects) return false;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
const radius = Math.max(4, (item.r || item.radius || 12) + (opts.obstaclePadding ?? 2));
const searchRadius = Math.max(96, radius + speed * Math.max(dt || 0.016, 0.016) + 90);
let bounced = false;
const rects = worldRef.nearbySolidObstacleRects(item.x, item.y, searchRadius, { include: it => it?.type === "bounce_fence" || it?.type === "bounce_fence_v", exclude: item, maxChecks: 16 }) || [];
for (const rect of rects) {
if (!rect?.bounce) continue;
if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 1.08), worldRef, opts)) {
bounced = true;
const maxSpeed = opts.bounceFenceMaxSpeed ?? 760;
item.vx = clamp(item.vx || 0, -maxSpeed, maxSpeed);
item.vy = clamp(item.vy || 0, -maxSpeed, maxSpeed);
const now = worldRef.time || 0;
if ((item.lastBounceFenceEffectAt || -999) + 0.08 <= now) {
item.lastBounceFenceEffectAt = now;
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(12, radius * 1.15), life: 0.16, color: "rgba(82,153,230,0.45)" }));
}
}
}
return bounced;
}
function applyKinematicItemMotion(item, worldRef, dt, opts = {}) {
if (!item || !worldRef) return 0;
const stopSpeed = opts.stopSpeed ?? 0.05;
@ -34,7 +245,11 @@
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
bounceInsideWorld(item, worldRef, opts);
const friction = Math.pow(opts.frictionBase ?? 0.68, dt * (opts.frictionRate ?? 1));
resolveKinematicBounceFenceCollision(item, worldRef, dt, opts);
const ice = (worldRef?.groundType || "soil") === "ice";
const baseFriction = ice ? Math.max(Number(opts.frictionBase ?? 0.68), 0.93) : (opts.frictionBase ?? 0.68);
const frictionRate = ice ? Math.min(Number(opts.frictionRate ?? 1), 0.55) : (opts.frictionRate ?? 1);
const friction = Math.pow(baseFriction, dt * frictionRate);
item.vx *= friction;
item.vy *= friction;
if (opts.spin !== false) {
@ -50,5 +265,5 @@
return speed;
}
global.TarinaiPhysics = { bounceInsideWorld, applyKinematicItemMotion };
global.TarinaiPhysics = { bounceInsideWorld, bounceCircleOffRect, resolveKinematicBounceFenceCollision, applyKinematicItemMotion };
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,4 +1,5 @@
"use strict";
const TARINAI_RENDER_GLOBAL = typeof globalThis !== "undefined" ? globalThis : (typeof window !== "undefined" ? window : {});
function randSeed(seed, min, max) {
const s = Math.sin(seed * 12.9898) * 43758.5453;
return min + (s - Math.floor(s)) * (max - min);
@ -124,6 +125,15 @@ function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
return;
}
function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale = 1) {
if (!influence) return;
ctx.save();
ctx.setLineDash([10, 7]);
ctx.lineWidth = 1.6;
ctx.restore();
}
function placementPreviewFor(world) {
const p = world?.pointer;
const type = toolItemType(world?.tool || "");
@ -138,10 +148,37 @@ function placementPreviewFor(world) {
const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
let blocked = false;
let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false };
if ((type === "fence_v" || type === "fence_h") && world.fenceRect) {
if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0);
if (type === "reciprocator") {
tmp.reciprocatorAxisAngle = tmp.angle;
tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0;
}
if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) {
const rawRect = world.fenceRect(tmp);
return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) };
}
if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
tmp.rotatorSpeed = Math.PI * 0.65;
tmp.rotatorPowered = true;
tmp.rotatorThickness = 12;
tmp.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]];
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
tmp.reciprocatorPowered = true;
tmp.reciprocatorSpeed = 92;
tmp.reciprocatorTravel = 150;
tmp.reciprocatorPhase = 0;
tmp.reciprocatorAnchorX = x;
tmp.reciprocatorAnchorY = y;
tmp.rotatorThickness = 12;
tmp.rotatorSegments = [[-78, 0, 78, 0]];
const rects = world.reciprocatorObstacleRects(tmp) || [];
const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "nest_box" && world.nestBoxBaseRect) {
const rect = world.nestBoxBaseRect(tmp);
return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) };
@ -156,7 +193,7 @@ function placementPreviewFor(world) {
function drawPlacementPreview(ctx, world) {
const preview = placementPreviewFor(world);
if (!preview) return;
const { type, x, y, r, rect, blocked } = preview;
const { type, x, y, r, rect, rects, influence, blocked } = preview;
const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10;
ctx.save();
ctx.globalAlpha = blocked ? 0.44 : 0.72;
@ -164,10 +201,26 @@ function drawPlacementPreview(ctx, world) {
ctx.setLineDash(blocked ? [5, 4] : [7, 5]);
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.86)" : "rgba(110,178,55,0.88)";
ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)";
if (rect) {
roundedRect(ctx, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, 8);
ctx.fill();
ctx.stroke();
if (influence) drawDeviceInfluenceShape(ctx, influence, blocked, 1.15);
const drawRectPreview = (rr) => {
if (!rr) return;
if (rr.oriented) {
ctx.save();
ctx.translate(rr.cx || x, rr.cy || y);
ctx.rotate(rr.angle || 0);
roundedRect(ctx, -(rr.halfW || (rr.right - rr.left) / 2), -(rr.halfH || (rr.bottom - rr.top) / 2), (rr.halfW || (rr.right - rr.left) / 2) * 2, (rr.halfH || (rr.bottom - rr.top) / 2) * 2, Math.min(8, Math.max(2, rr.halfH || 4)));
ctx.fill();
ctx.stroke();
ctx.restore();
} else {
roundedRect(ctx, rr.left, rr.top, rr.right - rr.left, rr.bottom - rr.top, 8);
ctx.fill();
ctx.stroke();
}
};
if (rect || (rects && rects.length)) {
if (rects && rects.length) for (const rr of rects) drawRectPreview(rr);
else drawRectPreview(rect);
if (type === "nest_box" && world.nestBoxSolidRects) {
ctx.setLineDash([3, 4]);
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.38)" : "rgba(110,178,55,0.42)";
@ -197,6 +250,109 @@ function drawPlacementPreview(ctx, world) {
ctx.restore();
}
function drawOperationToolPreview(ctx, world) {
const p = world?.pointer;
if (!p?.inside) return;
if (world?.tool === "copy" && world.copyBuffer) {
const type = world.copyBuffer.type || "";
const item = world.copyPreviewItemAt?.(p.x, p.y);
if (item) {
let copyPreviewDrew = false;
if (typeof item.draw === "function") {
ctx.save();
ctx.globalAlpha = 0.74;
try { item.draw(ctx, world.time || 0, typeof getLightingState === "function" ? getLightingState(world) : null); copyPreviewDrew = true; } catch (_) { copyPreviewDrew = false; }
ctx.restore();
}
if (!copyPreviewDrew && typeof globalThis.drawToolItemPreview === "function") {
const previewSize = Math.max(48, Math.min(132, (item.r || itemRadiusFor?.(type, 18) || 18) * 3.4));
const buffer = document.createElement("canvas");
buffer.width = Math.ceil(previewSize);
buffer.height = Math.ceil(previewSize);
const bctx = buffer.getContext("2d");
if (bctx && globalThis.drawToolItemPreview(bctx, type, { width: buffer.width, height: buffer.height, compact: true }) !== false) {
ctx.save();
ctx.globalAlpha = 0.74;
ctx.drawImage(buffer, p.x - previewSize / 2, p.y - previewSize / 2, previewSize, previewSize);
ctx.restore();
}
}
ctx.save();
ctx.globalAlpha = 0.50;
ctx.setLineDash([7, 5]);
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(128,92,198,0.86)";
ctx.fillStyle = "rgba(150,110,220,0.12)";
const rects = world.solidObstacleRects?.(item) || [];
if (rects.length) {
for (const rr of rects) {
if (rr.oriented) {
ctx.save(); ctx.translate(rr.cx, rr.cy); ctx.rotate(rr.angle || 0);
roundedRect(ctx, -(rr.halfW || 10), -(rr.halfH || 5), (rr.halfW || 10) * 2, (rr.halfH || 5) * 2, 6);
ctx.fill(); ctx.stroke(); ctx.restore();
} else { roundedRect(ctx, rr.left, rr.top, rr.right - rr.left, rr.bottom - rr.top, 6); ctx.fill(); ctx.stroke(); }
}
} else {
const r = Math.max(10, item.r || itemRadiusFor?.(type, 16) || 16);
ctx.beginPath(); ctx.ellipse(p.x, p.y, r * 1.45, r * 0.95, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(p.x - 9, p.y); ctx.lineTo(p.x + 9, p.y); ctx.moveTo(p.x, p.y - 9); ctx.lineTo(p.x, p.y + 9); ctx.stroke();
ctx.restore();
}
return;
}
if ((world?.tool === "rope" || world?.tool === "rod") && world.pendingLinkEndpoint) {
const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(world.pendingLinkEndpoint, world) || world.pendingLinkEndpoint;
ctx.save();
ctx.globalAlpha = 0.72;
ctx.lineWidth = 3.2;
ctx.lineCap = "round";
if (world?.tool === "rope") ctx.setLineDash([8, 6]);
else ctx.setLineDash([]);
ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : "rgba(142,94,45,0.88)";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "rgba(142,94,45,0.85)";
ctx.beginPath(); ctx.arc(a.x, a.y, 5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
return;
}
if (world?.tool !== "area_delete") return;
const radius = world.areaDeleteRadius?.() || 138;
ctx.save();
ctx.globalAlpha = 0.42;
ctx.lineWidth = 2.2;
ctx.setLineDash([8, 7]);
ctx.strokeStyle = "rgba(218,82,70,0.82)";
ctx.fillStyle = "rgba(218,82,70,0.10)";
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.setLineDash([3, 8]);
ctx.globalAlpha = 0.34;
ctx.beginPath();
ctx.arc(p.x, p.y, radius * 0.58, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 0.70;
ctx.beginPath();
ctx.moveTo(p.x - 10, p.y);
ctx.lineTo(p.x + 10, p.y);
ctx.moveTo(p.x, p.y - 10);
ctx.lineTo(p.x, p.y + 10);
ctx.stroke();
ctx.restore();
}
function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
if (tarinai.dead || tarinai.world.selected !== tarinai) return;
ctx.save();
@ -237,6 +393,16 @@ function backgroundCacheKey(world, lighting) {
function invalidateRenderCaches(reason = "manual") {
if (backgroundCache) backgroundCache.key = "";
if (terrainCache) terrainCache.key = "";
if (terrainChunkCache) {
terrainChunkCache.key = "";
terrainChunkCache.chunks?.clear?.();
}
}
// Cached low-priority terrain layer: grass, footprints and splats are mostly
// static visual noise. Rendering them into visible chunks avoids repeating
// thousands of small strokes every frame and avoids redrawing the entire field
@ -423,7 +589,8 @@ function visibleWorldRect(worldRef, margin = 160) {
function renderRadiusForEntity(entity) {
if (!entity) return 40;
if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2);
if (entity.type === "fence_v" || entity.type === "fence_h") return Math.max(96, (entity.r || 24) * 4.2);
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2);
if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || ((entity.r || 64) * 2.8 + (entity.reciprocatorTravel || 150)));
if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2);
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
if (entity.kind === "queen") return 42;
@ -450,6 +617,18 @@ function renderStackSignature(worldRef) {
].join(":");
}
function syncCarriedPlushieToOwner(it, worldRef) {
if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return false;
const owner = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById);
if (!owner) return false;
const ox = it.x, oy = it.y;
it.ownerId = it.ownerId || owner.id;
it.onHead = true;
it.x = owner.x;
it.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
return Math.hypot((it.x || 0) - (ox || 0), (it.y || 0) - (oy || 0)) > 0.25;
}
function ensureWorldRenderStack(worldRef) {
if (!worldRef._renderStack) {
worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
@ -502,6 +681,7 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
stack.lodgedPins.length = 0;
const seen = new Set();
const addItem = (it) => {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
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") {
@ -535,6 +715,9 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
for (const a of antScratch) addLayered(a);
for (const it of worldRef.items || []) {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
}
} else {
for (const it of worldRef.items || []) addItem(it);
for (const t of worldRef.tarinai || []) addLayered(t);
@ -638,8 +821,8 @@ function drawGardenBed(w, h, lighting) {
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 = 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");
const borderTop = groundType === "laboratory" ? "#cfd8e6" : groundType === "ice" ? "#bfe6f4" : groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#79a9cf" : groundType === "dirt" ? "#8fb06a" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = groundType === "laboratory" ? "#8fa1ba" : groundType === "ice" ? "#77b7d2" : groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#4f79a3" : groundType === "dirt" ? "#5f7c43" : 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;
@ -659,9 +842,13 @@ function drawGardenBed(w, h, lighting) {
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: groundType === "blanket"
? ["#ffe1ec", "#f6c6dc", "#e3a2c6"]
: groundType === "foot_massage"
? ["#e6c59c", "#cfa677", "#a77a52"]
: fieldType === "park"
: groundType === "ice"
? ["#dff8ff", "#b8e7f5", "#8bcfe4"]
: groundType === "foot_massage"
? ["#d2e6f4", "#a8c8e6", "#7fa9cd"]
: groundType === "dirt"
? [lighting.goldenStrength > 0.25 ? "#d4b47d" : "#c9a46d", lighting.goldenStrength > 0.25 ? "#b89261" : "#a87948", lighting.goldenStrength > 0.25 ? "#927046" : "#7b5a38"]
: fieldType === "park"
? ["#cfe5a8", "#b8d990", "#91bd74"]
: fieldType === "cage"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
@ -678,25 +865,82 @@ function drawGardenBed(w, h, lighting) {
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
activeCtx.clip();
const speckCount = groundType === "blanket" ? 28 : groundType === "foot_massage" ? 86 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
const speckCount = groundType === "laboratory" ? 18 : groundType === "ice" ? 54 : groundType === "blanket" ? 28 : groundType === "foot_massage" ? 180 : groundType === "dirt" ? 96 : 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);
const r = randSeed(1600 + i, 1.2, groundType === "foot_massage" ? 5.2 : (groundType === "dirt" ? 4.6 : 3.8));
const a = randSeed(1900 + i, groundType === "foot_massage" ? 0.065 : (groundType === "dirt" ? 0.045 : 0.03), groundType === "foot_massage" ? 0.18 : (groundType === "dirt" ? 0.13 : 0.09)) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70);
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})`;
const warm = i % 5 === 0;
activeCtx.fillStyle = warm
? `rgba(${Math.round(randSeed(2100 + i, 176, 222))}, ${Math.round(randSeed(2400 + i, 206, 236))}, ${Math.round(randSeed(2700 + i, 238, 255))}, ${a * 0.92})`
: `rgba(${Math.round(randSeed(2000 + i, 52, 108))}, ${Math.round(randSeed(2300 + i, 118, 184))}, ${Math.round(randSeed(2600 + i, 182, 242))}, ${a * 1.45})`;
activeCtx.beginPath();
activeCtx.arc(px, py, r * 1.25, 0, Math.PI * 2);
activeCtx.ellipse(px, py, r * randSeed(3100 + i, 1.1, 2.0), r * randSeed(3400 + i, 0.65, 1.25), randSeed(3700 + i, -1.2, 1.2), 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.fillStyle = groundType === "blanket" ? `rgba(255,255,255,${a * 1.35})` : (groundType === "ice" ? `rgba(255,255,255,${a * 1.65})` : (groundType === "dirt" ? `rgba(${Math.round(randSeed(2000 + i, 94, 132))}, ${Math.round(randSeed(2300 + i, 66, 104))}, ${Math.round(randSeed(2600 + i, 38, 70))}, ${a})` : `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.save();
activeCtx.globalCompositeOperation = "multiply";
const largePatchCount = 28;
for (let i = 0; i < largePatchCount; i++) {
const px = randSeed(8100 + i, x + 30, x + bw - 30);
const py = randSeed(8400 + i, y + 30, y + bh - 32);
const rw = randSeed(8700 + i, 12, 36);
const rh = randSeed(9000 + i, 7, 22);
const alpha = (light > 0.42 ? 0.105 : 0.070) + randSeed(9300 + i, 0, 0.055);
activeCtx.fillStyle = i % 3 === 0 ? `rgba(72, 132, 190, ${alpha})` : `rgba(118, 178, 224, ${alpha})`;
activeCtx.beginPath();
activeCtx.ellipse(px, py, rw, rh, randSeed(9600 + i, -1.1, 1.1), 0, Math.PI * 2);
activeCtx.fill();
}
activeCtx.restore();
}
if (groundType === "ice") {
activeCtx.save();
activeCtx.globalAlpha = 0.20 * (light > 0.4 ? 1.0 : 0.55);
activeCtx.strokeStyle = "rgba(255,255,255,0.72)";
activeCtx.lineWidth = 1.1;
for (let i = 0; i < 22; i += 1) {
const x = randSeed(4100 + i, 30, world.w - 30);
const y = randSeed(4300 + i, 30, world.h - 30);
const len = randSeed(4500 + i, 24, 78);
const a = randSeed(4700 + i, -0.9, 0.9);
activeCtx.beginPath();
activeCtx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
activeCtx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
activeCtx.stroke();
}
activeCtx.restore();
}
if (groundType === "dirt") {
activeCtx.save();
activeCtx.globalAlpha = light > 0.42 ? 0.16 : 0.10;
activeCtx.strokeStyle = "rgba(72, 52, 32, 0.34)";
activeCtx.lineWidth = 1.0;
for (let i = 0; i < 34; i++) {
const px = randSeed(10100 + i, x + 30, x + bw - 30);
const py = randSeed(10400 + i, y + 30, y + bh - 32);
const len = randSeed(10700 + i, 8, 24);
const a = randSeed(11000 + i, -1.4, 1.4);
activeCtx.beginPath();
activeCtx.moveTo(px - Math.cos(a) * len * 0.45, py - Math.sin(a) * len * 0.45);
activeCtx.lineTo(px + Math.cos(a) * len * 0.55, py + Math.sin(a) * len * 0.55);
activeCtx.stroke();
}
activeCtx.restore();
}
if (groundType === "blanket") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.16)";
@ -728,7 +972,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const patchCount = groundType === "concrete" ? 3 : groundType === "blanket" ? 0 : fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
const patchCount = groundType === "laboratory" ? 0 : groundType === "concrete" ? 3 : groundType === "blanket" || groundType === "ice" ? 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);
@ -741,7 +985,7 @@ function drawGardenBed(w, h, lighting) {
}
// edge grasses and tiny flowers
const edgeGrassCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
const edgeGrassCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 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);
@ -761,7 +1005,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const flowerCount = groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
const flowerCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" || groundType === "ice" ? 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);
@ -780,7 +1024,7 @@ function drawGardenBed(w, h, lighting) {
}
}
const leafCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
const leafCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 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);
@ -973,6 +1217,7 @@ function render() {
beginFieldTransform();
drawPlacementPreview(ctx, world);
drawOperationToolPreview(ctx, world);
drawTerrainLayer(ctx, world, lighting, visibleRect);
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
@ -988,6 +1233,7 @@ function render() {
}
for (const it of renderStack.carriedPlushies) {
syncCarriedPlushieToOwner(it, world);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const it of renderStack.lodgedPins) {
@ -1047,17 +1293,6 @@ function render() {
ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12);
ctx.restore();
if (world.paused) {
ctx.save();
ctx.fillStyle = "rgba(255,255,255,0.50)";
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "rgba(42,36,29,0.70)";
ctx.textAlign = "center";
ctx.font = "bold 28px ui-rounded, sans-serif";
ctx.fillText("\u4e00\u6642\u505c\u6b62\u4e2d", w / 2, h / 2);
ctx.restore();
}
}
function drawPointerItemTooltip(ctx, world) {
@ -1144,7 +1379,7 @@ function drawSelectedCard(ctx, world, lighting) {
const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : "";
ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35);
const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : "";
ctx.fillText(`\u7dcf\u5408\u7684\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52);
ctx.fillText(`\u7dcf\u5408\u7684\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}/${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, x + 10, y + 52);
ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68);
ctx.restore();
}
@ -1160,4 +1395,4 @@ function roundedRect(ctx, x, y, w, h, r) {
}
window.TarinaiRender = { randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, fillScreenFallback, drawSelectedCard, render, roundedRect };
window.TarinaiRender = { invalidateRenderCaches, randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, fillScreenFallback, drawSelectedCard, render, roundedRect };

View file

@ -2,78 +2,298 @@
(function (global) {
const Snapshot = global.TarinaiSnapshot;
const SaveSchema = global.TarinaiSaveSchema;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
const EXPORT_PREFIX = "TN3!";
if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for save_codec.js");
const EXPORT_PREFIX = "たり";
const RAW_CODEC = "生";
const DEFLATE_CODEC = "縮";
const {
BINARY_SCHEMA_VERSION,
FIELD_IDS,
GROUND_IDS,
ITEM_TYPE_IDS,
STRUCTURE_TYPE_IDS,
SERVING_FOOD_TYPE_IDS,
PIN_TYPE_IDS,
ROTATABLE_ITEM_TYPE_IDS,
JSON_EXTRA_ITEM_TYPE_IDS,
} = SaveSchema;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const SAVE_TEXT_BITS = 11;
const SAVE_TEXT_BASE = 1 << SAVE_TEXT_BITS;
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 chars = [];
const addRange = (from, to) => {
for (let code = from; code <= to && chars.length < SAVE_TEXT_BASE; code++) chars.push(String.fromCharCode(code));
};
// 正規化で分解されにくい仮名とCJK統合漢字だけを使う。
addRange(0x3041, 0x3096); // ひらがな
addRange(0x30A1, 0x30FA); // カタカナ
addRange(0x4E00, 0x9FFF); // 常用漢字を含むCJK統合漢字。必要数に達したら停止。
return chars.join("");
})();
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);
let index = 0;
for (const ch of SAVE_TEXT_ALPHABET) map.set(ch, index++);
return map;
})();
if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars");
if ([...SAVE_TEXT_ALPHABET].length !== SAVE_TEXT_BASE) throw new Error("save alphabet must contain 2048 Japanese chars");
function base91Encode(bytes) {
function writeVaruintToArray(out, value = 0) {
let n = toSafeUInt(value);
while (n >= 128) {
out.push((n % 128) | 128);
n = Math.floor(n / 128);
}
out.push(n & 127);
}
function readVaruintFromArray(bytes, start = 0) {
let n = 0;
let mul = 1;
for (let i = 0; i < 10; i++) {
const byte = bytes[start + i];
if (byte == null) throw new Error("truncated Japanese save length");
n += (byte & 127) * mul;
if (!(byte & 128)) return { value: n, next: start + i + 1 };
mul *= 128;
}
throw new Error("invalid Japanese save length");
}
function jp2048Encode(bytes) {
const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const packet = [];
writeVaruintToArray(packet, source.length);
for (const byte of source) packet.push(byte & 255);
let b = 0;
let n = 0;
let out = "";
for (const byte of bytes || []) {
for (const byte of packet) {
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)];
while (n >= SAVE_TEXT_BITS) {
out += SAVE_TEXT_ALPHABET[b & (SAVE_TEXT_BASE - 1)];
b >>= SAVE_TEXT_BITS;
n -= SAVE_TEXT_BITS;
}
}
if (n) {
out += SAVE_TEXT_ALPHABET[b % 91];
if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)];
}
if (n > 0) out += SAVE_TEXT_ALPHABET[b & (SAVE_TEXT_BASE - 1)];
return out;
}
function base91Decode(text) {
const clean = String(text || "").trim();
let v = -1;
function jp2048Decode(text) {
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;
const packet = [];
for (const ch of String(text || "").trim()) {
const value = SAVE_TEXT_REVERSE.get(ch);
if (value == null) continue;
b |= value << n;
n += SAVE_TEXT_BITS;
while (n >= 8) {
packet.push(b & 255);
b >>= 8;
n -= 8;
}
}
if (v >= 0) out.push((b | (v << n)) & 255);
return new Uint8Array(out);
const { value: length, next } = readVaruintFromArray(packet, 0);
if (packet.length < next + length) throw new Error("truncated Japanese save payload");
return new Uint8Array(packet.slice(next, next + length));
}
function toSafeUInt(value = 0) {
const n = Math.floor(Number(value) || 0);
return Number.isFinite(n) && n > 0 ? Math.min(n, Number.MAX_SAFE_INTEGER) : 0;
}
function toSafeInt(value = 0) {
const n = Math.round(Number(value) || 0);
return Number.isFinite(n) ? Math.max(Number.MIN_SAFE_INTEGER, Math.min(Number.MAX_SAFE_INTEGER, n)) : 0;
}
function zigzagEncode(value = 0) {
const n = toSafeInt(value);
return n < 0 ? (-n * 2 - 1) : (n * 2);
}
function zigzagDecode(value = 0) {
const n = toSafeUInt(value);
return (n % 2) ? -((n + 1) / 2) : (n / 2);
}
class BinaryWriter {
constructor() { this.bytes = []; }
u(value = 0) {
let n = toSafeUInt(value);
while (n >= 128) {
this.bytes.push((n % 128) | 128);
n = Math.floor(n / 128);
}
this.bytes.push(n & 127);
}
s(value = 0) { this.u(zigzagEncode(value)); }
b(value = 0) { this.bytes.push((Number(value) || 0) & 255); }
str(value = "") {
const bytes = textEncoder.encode(String(value || ""));
this.u(bytes.length);
for (const byte of bytes) this.bytes.push(byte);
}
out() { return new Uint8Array(this.bytes); }
}
class BinaryReader {
constructor(bytes = []) { this.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []); this.pos = 0; }
ensure(count = 1) {
if (this.pos + count > this.bytes.length) throw new Error("truncated binary save");
}
u() {
let n = 0;
let mul = 1;
for (let i = 0; i < 10; i++) {
this.ensure(1);
const byte = this.bytes[this.pos++];
n += (byte & 127) * mul;
if (!(byte & 128)) return n;
mul *= 128;
}
throw new Error("invalid varuint in binary save");
}
s() { return zigzagDecode(this.u()); }
b() { this.ensure(1); return this.bytes[this.pos++]; }
str() {
const len = this.u();
this.ensure(len);
const slice = this.bytes.subarray(this.pos, this.pos + len);
this.pos += len;
return textDecoder.decode(slice);
}
}
function clampToBits(value = 0, bits = 8) {
const max = (2 ** Math.max(0, bits)) - 1;
return Math.max(0, Math.min(max, toSafeUInt(value)));
}
function writeBitFields(writer, values = [], bits = []) {
let acc = 0;
let accBits = 0;
for (let i = 0; i < bits.length; i++) {
const width = Math.max(0, Number(bits[i]) || 0);
if (!width) continue;
let value = clampToBits(values?.[i] || 0, width);
let left = width;
while (left > 0) {
const room = 8 - accBits;
const take = Math.min(room, left);
const mask = (1 << take) - 1;
acc |= (value & mask) << accBits;
accBits += take;
value = Math.floor(value / (2 ** take));
left -= take;
if (accBits === 8) {
writer.b(acc);
acc = 0;
accBits = 0;
}
}
}
if (accBits > 0) writer.b(acc);
}
function readBitFields(reader, bits = []) {
const out = [];
let acc = 0;
let accBits = 0;
for (const rawWidth of bits) {
const width = Math.max(0, Number(rawWidth) || 0);
let value = 0;
let shift = 0;
let left = width;
while (left > 0) {
if (accBits === 0) { acc = reader.b(); accBits = 8; }
const take = Math.min(accBits, left);
const mask = (1 << take) - 1;
value += (acc & mask) * (2 ** shift);
acc = Math.floor(acc / (2 ** take));
accBits -= take;
shift += take;
left -= take;
}
out.push(value);
}
return out;
}
function writeQuantizedBitFields(writer, values = [], count = 0, bits = 6, step = 1) {
const list = [];
for (let i = 0; i < count; i++) list.push(Math.round((Number(values?.[i]) || 0) / Math.max(1, step)));
writeBitFields(writer, list, Array.from({ length: count }, () => bits));
}
function readQuantizedBitFields(reader, count = 0, bits = 6, step = 1) {
return readBitFields(reader, Array.from({ length: count }, () => bits)).map(v => v * Math.max(1, step));
}
function makeProfile(snapshot = {}) {
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
return {
format: "TJ1",
schema: BINARY_SCHEMA_VERSION,
alphabetSize: SAVE_TEXT_BASE,
counts: { tarinai: tarinaiRows.length, items: itemRows.length },
sections: {},
itemTypes: {},
compression: {},
};
}
function addProfileBytes(profile, key, bytes = 0) {
if (!profile || !key) return;
const n = Math.max(0, Number(bytes) || 0);
profile.sections[key] = (profile.sections[key] || 0) + n;
}
function trackBytes(writer, profile, key, fn) {
if (!profile || !writer) return fn();
const start = writer.bytes.length;
const result = fn();
addProfileBytes(profile, key, writer.bytes.length - start);
return result;
}
function addItemTypeProfile(profile, typeId, bytes = 0) {
if (!profile) return;
const type = ITEM_TYPE_IDS[typeId] || `type_${typeId}`;
const entry = profile.itemTypes[type] || { count: 0, bytes: 0 };
entry.count += 1;
entry.bytes += Math.max(0, Number(bytes) || 0);
profile.itemTypes[type] = entry;
}
function finalizeProfile(profile, rawBytes, packedBytes, text, codec = "R") {
if (!profile) return null;
const raw = rawBytes?.length || 0;
const packed = packedBytes?.length || 0;
const payloadChars = Math.max(0, String(text || "").length - EXPORT_PREFIX.length - 1);
profile.sections.totalRaw = raw;
profile.compression = {
codec,
rawBytes: raw,
packedBytes: packed,
packedDelta: raw - packed,
ratio: raw > 0 ? packed / raw : 0,
prefixChars: EXPORT_PREFIX.length + 1,
payloadChars,
totalChars: String(text || "").length,
alphabetSize: SAVE_TEXT_BASE,
containsBacktick: false,
japaneseText: true,
bitsPerChar: SAVE_TEXT_BITS,
asciiOnly: false,
};
return profile;
}
async function compressBytes(bytes) {
if (typeof CompressionStream === "function") {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
@ -81,59 +301,462 @@
}
return bytes;
}
async function decompressBytes(bytes, codec = "R") {
if (codec === "D") {
if (codec === DEFLATE_CODEC) {
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());
}
if (codec !== RAW_CODEC) throw new Error("unsupported Japanese save codec");
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";
function writeFixed(writer, arr = [], count = 0, signed = true) {
for (let i = 0; i < count; i++) signed ? writer.s(arr[i] || 0) : writer.u(arr[i] || 0);
}
function readFixed(reader, count = 0, signed = true) {
const out = [];
for (let i = 0; i < count; i++) out.push(signed ? reader.s() : reader.u());
return out;
}
function writeFixedPacked(writer, arr = [], count = 0, bits = 8, step = 1) {
writeQuantizedBitFields(writer, arr, count, bits, step);
}
function readFixedPacked(reader, count = 0, bits = 8, step = 1) {
return readQuantizedBitFields(reader, count, bits, step);
}
function writeFixedDiffPacked(writer, base = [], current = [], count = 0, bits = 8) {
let mask = 0;
const values = [];
const widths = [];
for (let i = 0; i < count; i++) {
if (valuesDiffer(current?.[i], base?.[i])) {
mask |= (1 << i);
values.push(current?.[i] || 0);
widths.push(bits);
}
}
writeBitFields(writer, [mask], [count]);
if (widths.length) writeBitFields(writer, values, widths);
}
function readFixedDiffPacked(reader, base = [], count = 0, bits = 8) {
const [mask] = readBitFields(reader, [count]);
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
const changed = [];
for (let i = 0; i < count; i++) if (mask & (1 << i)) changed.push(i);
const values = changed.length ? readBitFields(reader, changed.map(() => bits)) : [];
changed.forEach((index, i) => { out[index] = values[i]; });
return out;
}
function writeIndexList(writer, arr = []) {
const list = Array.isArray(arr) ? arr : [];
writer.u(list.length);
for (const idx of list) writer.u(idx || 0);
}
function readIndexList(reader) {
const count = reader.u();
const out = [];
for (let i = 0; i < count; i++) out.push(reader.u());
return out;
}
function generatedNameCode(value = "") {
const name = String(value || "");
if (!name.startsWith("は")) return -1;
const stop = name.endsWith("っ");
const core = name.slice(1, stop ? -1 : undefined);
if (core.length > 6) return -1;
let bits = 0;
for (let i = 0; i < core.length; i++) {
const ch = core.charAt(i);
if (ch !== "う" && ch !== "ぅ") return -1;
if (ch === "ぅ") bits |= (1 << i);
}
return core.length | (stop ? 8 : 0) | (bits << 4);
}
function nameFromGeneratedCode(code = 0) {
const middleLen = code & 7;
const stop = !!(code & 8);
const bits = code >> 4;
let name = "は";
for (let i = 0; i < middleLen; i++) name += (bits & (1 << i)) ? "ぅ" : "う";
if (stop) name += "っ";
return name;
}
function writeName(writer, value = "") {
const code = generatedNameCode(value);
if (code >= 0) {
writer.u(code + 1);
return;
}
writer.u(0);
writer.str(value || "");
}
function readName(reader) {
const code = reader.u();
if (code > 0) return nameFromGeneratedCode(code - 1);
return reader.str();
}
function valuesDiffer(a, b) { return (Number(a) || 0) !== (Number(b) || 0); }
function writeFixedDiff(writer, base = [], current = [], count = 0, signed = false) {
let mask = 0;
for (let i = 0; i < count; i++) if (valuesDiffer(current?.[i], base?.[i])) mask |= (1 << i);
writer.u(mask);
for (let i = 0; i < count; i++) if (mask & (1 << i)) signed ? writer.s(current[i] || 0) : writer.u(current[i] || 0);
}
function readFixedDiff(reader, base = [], count = 0, signed = false) {
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
const mask = reader.u();
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = signed ? reader.s() : reader.u();
return out;
}
function writeTimers(writer, timers = []) {
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
let mask = 0;
for (let i = 0; i < defaults.length; i++) if (valuesDiffer(timers?.[i], defaults[i])) mask |= (1 << i);
writeBitFields(writer, [mask], [defaults.length]);
for (let i = 0; i < defaults.length; i++) if (mask & (1 << i)) writer.s(timers[i] || 0);
}
function readTimers(reader) {
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
const out = defaults.slice();
const [mask] = readBitFields(reader, [defaults.length]);
for (let i = 0; i < defaults.length; i++) if (mask & (1 << i)) out[i] = reader.s();
return out;
}
function writeModes(writer, modes = []) {
writeBitFields(writer, [modes?.[0] || 0, modes?.[1] || 0, modes?.[2] || 0], [2, 2, 1]);
}
function readModes(reader) {
return readBitFields(reader, [2, 2, 1]);
}
function commonItemAmountNeeded(typeId) {
if (STRUCTURE_TYPE_IDS.has(typeId)) return false;
if (SERVING_FOOD_TYPE_IDS.has(typeId)) return false;
return true;
}
function writeRelationships(writer, rows = []) {
const list = Array.isArray(rows) ? rows : [];
writer.u(list.length);
for (const row of list) {
writer.u(row?.[0] || 0);
writer.s(row?.[1] || 0);
writer.s(row?.[2] || 0);
writer.u(row?.[3] || 0);
}
}
function readRelationships(reader) {
const count = reader.u();
const out = [];
for (let i = 0; i < count; i++) out.push([reader.u(), reader.s(), reader.s(), reader.u()]);
return out;
}
function writeWorld(writer, w = []) {
writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 3, 4, 3]);
writer.u(w[3] || 0); // worldTick10
writer.s(w[5] || 0); // lastBirthAt10 can be negative
writer.u(w[6] || 1); // generation
writer.u(w[7] || 0); // dead count
writer.str(w[8] || ""); // worldSeed
writer.u(w[9] || 0); // birthSerial
}
function readWorld(reader) {
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
return [field, ground, mood, reader.u(), weather, reader.s(), reader.u(), reader.u(), reader.str(), reader.u()];
}
function seedPersonalityArray(seed = "", flags = 0) {
// ずんちどれいは誕生時性格も全軸 -1 なので、差分基準も0固定にする。
if ((Number(flags) || 0) & (1 << 5)) return [0, 0, 0, 0];
return global.TarinaiSeedFactory?.personalityArray?.(seed) || [100, 100, 100, 100];
}
function writeTarinai(writer, row = [], profile = null, coordState = null) {
const pinIdx = row[15] ?? -1;
const nestIdx = row[16] ?? -1;
const stateId = row[17]?.[0] || 0;
const stateTarget = row[17]?.[1] ?? -1;
const birthSeed = row[1] || "";
trackBytes(writer, profile, "tarinai.flags", () => writeBitFields(writer, [row[0] || 0], [10]));
trackBytes(writer, profile, "tarinai.seed", () => writer.str(birthSeed));
trackBytes(writer, profile, "tarinai.core", () => {
const x = toSafeInt(row[2] || 0);
const y = toSafeInt(row[3] || 0);
writer.s(x - (coordState?.x || 0));
writer.s(y - (coordState?.y || 0));
if (coordState) { coordState.x = x; coordState.y = y; }
writer.u(row[4] || 0);
writer.u(row[5] || 1);
writeBitFields(writer, [row[6] || 0, row[7] || 0, row[8] || 0], [8, 8, 8]);
});
trackBytes(writer, profile, "tarinai.needs", () => writeFixedPacked(writer, row[9], 6, 6, 2));
trackBytes(writer, profile, "tarinai.traits", () => {
writeFixedDiffPacked(writer, seedPersonalityArray(birthSeed, row[0] || 0), row[10], 4, 8);
});
trackBytes(writer, profile, "tarinai.family", () => {
writeIndexList(writer, row[11]);
});
trackBytes(writer, profile, "tarinai.relationships", () => writeRelationships(writer, row[12]));
trackBytes(writer, profile, "tarinai.timers", () => writeTimers(writer, row[13]));
trackBytes(writer, profile, "tarinai.modes", () => writeModes(writer, row[14]));
trackBytes(writer, profile, "tarinai.tail", () => {
let tailMask = 0;
if (pinIdx >= 0) tailMask |= 1;
if (nestIdx >= 0) tailMask |= 2;
if (stateId || stateTarget >= 0) tailMask |= 4;
writeBitFields(writer, [tailMask], [3]);
if (tailMask & 1) writer.u(pinIdx);
if (tailMask & 2) writer.u(nestIdx);
if (tailMask & 4) { writeBitFields(writer, [stateId], [3]); writer.s(stateTarget); }
});
}
function readTarinai(reader, coordState = null) {
const flags = readBitFields(reader, [10])[0];
const birthSeed = reader.str();
const x = (coordState?.x || 0) + reader.s();
const y = (coordState?.y || 0) + reader.s();
if (coordState) { coordState.x = x; coordState.y = y; }
const age = reader.u();
const generation = reader.u();
const [hunger, energy, sleepPressure] = readBitFields(reader, [8, 8, 8]);
const needs = readFixedPacked(reader, 6, 6, 2);
const current = readFixedDiffPacked(reader, seedPersonalityArray(birthSeed, flags), 4, 8);
const parents = readIndexList(reader);
const relationships = readRelationships(reader);
const timers = readTimers(reader);
const modes = readModes(reader);
const [tailMask] = readBitFields(reader, [3]);
const pinIdx = (tailMask & 1) ? reader.u() : -1;
const nestIdx = (tailMask & 2) ? reader.u() : -1;
const stateInfo = (tailMask & 4) ? [readBitFields(reader, [3])[0], reader.s()] : [0, -1];
return [flags, birthSeed, x, y, age, generation, hunger, energy, sleepPressure, needs, current, parents, relationships, timers, modes, pinIdx, nestIdx, stateInfo];
}
function writeItemExtra(writer, typeId, extra = []) {
if (STRUCTURE_TYPE_IDS.has(typeId)) {
writer.s(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] ?? -1); writer.s(extra[3] ?? -1); writer.u(extra[4] || 0); writer.s(extra[5] ?? -1);
} else if (typeId === 0) { // grass
writer.s(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] || 0); writer.u(extra[3] || 0);
} else if (typeId === 1) { // zunchi
writer.u(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] || 0);
} else if (typeId === 22) { // signboard
writer.str(extra[0] || "");
} else if (typeId === 6) { // duplicator
writer.s(extra[0] ?? -1);
} else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
writer.str(JSON.stringify(extra || []));
} else if (typeId === 21) { // ball
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
} else if (typeId === 23) { // ant_nest
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
} else if (typeId === 24) { // ant_corpse
writer.s(extra[0] || 0);
} else if (typeId === 25) { // firecracker
writer.s(extra[0] || 0);
} else if (PIN_TYPE_IDS.has(typeId)) {
writer.u(extra[0] || 0); writer.s(extra[1] ?? -1); writer.s(extra[2] || 0); writer.s(extra[3] || 0); writer.s(extra[4] || 0);
} else if (ROTATABLE_ITEM_TYPE_IDS.has(typeId)) {
writer.s(extra[0] || 0); writer.u(extra[1] || 0);
} else if (SERVING_FOOD_TYPE_IDS.has(typeId)) {
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
}
}
function readItemExtra(reader, typeId) {
if (STRUCTURE_TYPE_IDS.has(typeId)) return [reader.s(), reader.s(), reader.s(), reader.s(), reader.u(), reader.s()];
if (typeId === 0) return [reader.s(), reader.s(), reader.s(), reader.u()];
if (typeId === 1) return [reader.u(), reader.s(), reader.s()];
if (typeId === 22) return [reader.str()];
if (typeId === 6) return [reader.s()];
if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
try {
const parsed = JSON.parse(reader.str() || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
if (typeId === 21) return [reader.s(), reader.s()];
if (typeId === 23) return [reader.s(), reader.s()];
if (typeId === 24) return [reader.s()];
if (typeId === 25) return [reader.s()];
if (PIN_TYPE_IDS.has(typeId)) return [reader.u(), reader.s(), reader.s(), reader.s(), reader.s()];
if (ROTATABLE_ITEM_TYPE_IDS.has(typeId)) return [reader.s(), reader.u()];
if (SERVING_FOOD_TYPE_IDS.has(typeId)) return [reader.s(), reader.s()];
return [];
}
function itemCoordKey(row = []) {
const x = Math.max(0, Math.min(65535, toSafeUInt(row[1] || 0)));
const y = Math.max(0, Math.min(65535, toSafeUInt(row[2] || 0)));
return mortonKey(x, y);
}
function mortonKey(x = 0, y = 0) {
let out = 0;
for (let i = 0; i < 16; i++) out += (((x >> i) & 1) * (2 ** (2 * i))) + (((y >> i) & 1) * (2 ** (2 * i + 1)));
return out;
}
function writeItemPayload(writer, row = [], typeId = 0, coordState = null, profile = null) {
const start = writer.bytes.length;
trackBytes(writer, profile, "items.core", () => {
const x = toSafeInt(row[1] || 0);
const y = toSafeInt(row[2] || 0);
writer.s(x - (coordState?.x || 0));
writer.s(y - (coordState?.y || 0));
if (coordState) { coordState.x = x; coordState.y = y; }
if (commonItemAmountNeeded(typeId)) writer.s(row[3] || 0);
});
trackBytes(writer, profile, "items.extra", () => writeItemExtra(writer, typeId, row[4] || []));
addItemTypeProfile(profile, typeId, writer.bytes.length - start);
}
function readItemPayload(reader, typeId = 0, coordState = null) {
const x = (coordState?.x || 0) + reader.s();
const y = (coordState?.y || 0) + reader.s();
if (coordState) { coordState.x = x; coordState.y = y; }
const amount = commonItemAmountNeeded(typeId) ? reader.s() : 0;
const extra = readItemExtra(reader, typeId);
let restoredAmount = amount;
if (STRUCTURE_TYPE_IDS.has(typeId)) restoredAmount = extra[0] || 0;
else if (SERVING_FOOD_TYPE_IDS.has(typeId)) restoredAmount = extra[0] || 0;
return [typeId, x, y, restoredAmount, extra];
}
function itemBlocksFromRows(rows = []) {
const groups = new Map();
for (const row of rows || []) {
const typeId = row?.[0] || 0;
if (!groups.has(typeId)) groups.set(typeId, []);
groups.get(typeId).push(row);
}
const blocks = [];
for (const [typeId, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
list.sort((a, b) => itemCoordKey(a) - itemCoordKey(b) || (a[1] || 0) - (b[1] || 0) || (a[2] || 0) - (b[2] || 0));
blocks.push([typeId, list]);
}
return blocks;
}
function writeItemBlocks(writer, rows = [], profile = null) {
const blocks = itemBlocksFromRows(rows);
writer.u(blocks.length);
for (const [typeId, list] of blocks) {
writer.u(typeId);
writer.u(list.length);
const coordState = { x: 0, y: 0 };
for (const row of list) writeItemPayload(writer, row, typeId, coordState, profile);
}
}
function readItemBlocks(reader) {
const blockCount = reader.u();
const items = [];
for (let b = 0; b < blockCount; b++) {
const typeId = reader.u();
const count = reader.u();
const coordState = { x: 0, y: 0 };
for (let i = 0; i < count; i++) items.push(readItemPayload(reader, typeId, coordState));
}
return items;
}
function fieldValue(index = 0) { return FIELD_IDS[Number(index) | 0] || "garden"; }
function groundValue(index = 0) { return GROUND_IDS[Number(index) | 0] || "soil"; }
function summaryFromPayload(w = [], tarinaiCount = 0, itemCount = 0) {
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
const dayLength = Math.max(1, Number(config.dayLength || 120));
const totalTime = (Number(w[3]) || 0) / 10;
const day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
const time10 = Math.round((totalTime - (day - 1) * dayLength) * 10);
return [day, tarinaiCount, fieldValue(w[0]), time10, itemCount, groundValue(w[1])];
}
function encodeBinarySnapshot(snapshot, options = {}) {
if (!snapshot || snapshot.v !== Snapshot.version || snapshot.a !== "tj1") throw new Error("snapshot version mismatch");
const profile = options?.profile ? makeProfile(snapshot) : null;
const writer = new BinaryWriter();
trackBytes(writer, profile, "header", () => {
writer.b(BINARY_SCHEMA_VERSION);
writer.u(Snapshot.version);
});
trackBytes(writer, profile, "world", () => writeWorld(writer, snapshot.w || []));
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
trackBytes(writer, profile, "tarinai.count", () => writer.u(tarinaiRows.length));
const tarinaiStart = writer.bytes.length;
const tarCoordState = { x: 0, y: 0 };
for (const row of tarinaiRows) writeTarinai(writer, row || [], profile, tarCoordState);
addProfileBytes(profile, "tarinai.total", writer.bytes.length - tarinaiStart);
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
const itemStart = writer.bytes.length;
trackBytes(writer, profile, "items.blocks", () => writeItemBlocks(writer, itemRows, profile));
addProfileBytes(profile, "items.total", writer.bytes.length - itemStart);
const bytes = writer.out();
if (profile) return { bytes, profile };
return bytes;
}
function decodeBinarySnapshot(bytes) {
const reader = new BinaryReader(bytes);
const schema = reader.b();
if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
reader.schemaVersion = schema;
const snapshotVersion = reader.u();
if (snapshotVersion !== Snapshot.version) throw new Error("unsupported binary save version");
const w = readWorld(reader);
const tCount = reader.u();
const t = [];
const tarCoordState = { x: 0, y: 0 };
for (let i = 0; i < tCount; i++) t.push(readTarinai(reader, tarCoordState));
const items = readItemBlocks(reader);
const itemCount = items.length;
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
return { v: Snapshot.version, a: "tj1", c: Date.now(), m: summaryFromPayload(w, t.length, items.length), w, t, i: items };
}
async function packBytesForSave(bytes) {
let codec = RAW_CODEC;
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)}`;
if (compressed.length < bytes.length) { codec = DEFLATE_CODEC; packed = compressed; }
} catch (error) { console.warn("save compression fallback", error); }
return { codec, packed };
}
async function encodeSnapshotWithProfile(snapshot) {
const encoded = encodeBinarySnapshot(snapshot, { profile: true });
const bytes = encoded.bytes || encoded;
const profile = encoded.profile || null;
const { codec, packed } = await packBytesForSave(bytes);
const text = `${EXPORT_PREFIX}${codec}${jp2048Encode(packed)}`;
finalizeProfile(profile, bytes, packed, text, codec);
return { text, profile };
}
async function encodeSnapshot(snapshot) {
return (await encodeSnapshotWithProfile(snapshot)).text;
}
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";
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported Japanese save text");
const codec = raw.charAt(EXPORT_PREFIX.length) || RAW_CODEC;
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = base91Decode(payload);
const packed = jp2048Decode(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;
return decodeBinarySnapshot(bytes);
}
global.TarinaiSaveCodec = {
EXPORT_PREFIX,
RAW_CODEC,
DEFLATE_CODEC,
SAVE_TEXT_ALPHABET,
base91Encode,
base91Decode,
SAVE_TEXT_BASE,
SAVE_TEXT_BITS,
jp2048Encode,
jp2048Decode,
encodeSnapshot,
encodeSnapshotWithProfile,
decodeSnapshot,
encodeBinarySnapshot,
decodeBinarySnapshot,
BinaryWriter,
BinaryReader,
};
})(typeof window !== "undefined" ? window : globalThis);

85
js/save_schema.js Normal file
View file

@ -0,0 +1,85 @@
"use strict";
// Layer: persistence/schema
// Owns stable save identifiers shared by snapshot_system.js and save_codec.js.
// Current-format save identifiers only.
(function (global) {
const SNAPSHOT_VERSION = 7;
const BINARY_SCHEMA_VERSION = 10;
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]);
const MOOD_IDS = Object.freeze(["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]);
const STATE_IDS = Object.freeze(["idle", "sleep", "panic", "sunbath", "fight", "seek_food", "seek_bed", "wander"]);
const POWER_MODE_IDS = Object.freeze(["", "protein", "niteropu"]);
const SIZE_MODE_IDS = Object.freeze(["", "giant_drug", "dwarf_drug"]);
const LIFE_MODE_IDS = Object.freeze(["", "mercury"]);
const PIN_STATE_IDS = Object.freeze(["loose", "lodged", "falling", "stuck"]);
const STAGE_IDS = Object.freeze(["fresh", "aging", "old", "dry"]);
const ITEM_TYPE_IDS = Object.freeze([
"grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi",
"sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h",
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod"
]);
function typeIds(names) {
return Object.freeze(new Set((names || []).map(name => ITEM_TYPE_IDS.indexOf(name)).filter(id => id >= 0)));
}
const STRUCTURE_TYPE_IDS = typeIds(["grass_bed", "plushie"]);
const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]);
const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]);
const FENCE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]);
const MECHANICAL_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator"]);
const LINK_ITEM_TYPE_IDS = typeIds(["rope", "rod"]);
const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator", "rope", "rod"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]);
function enumIndex(list, value, fallback = 0) {
const i = list.indexOf(String(value ?? ""));
return i >= 0 ? i : fallback;
}
function enumValue(list, index, fallback = "") {
const i = Number(index) | 0;
return list[i] ?? fallback ?? list[0] ?? "";
}
function itemTypeId(type, fallback = -1) {
return enumIndex(ITEM_TYPE_IDS, type, fallback);
}
function itemTypeValue(index, fallback = "") {
return enumValue(ITEM_TYPE_IDS, index, fallback);
}
global.TarinaiSaveSchema = Object.freeze({
SNAPSHOT_VERSION,
BINARY_SCHEMA_VERSION,
FIELD_IDS,
GROUND_IDS,
WEATHER_IDS,
MOOD_IDS,
STATE_IDS,
POWER_MODE_IDS,
SIZE_MODE_IDS,
LIFE_MODE_IDS,
PIN_STATE_IDS,
STAGE_IDS,
ITEM_TYPE_IDS,
STRUCTURE_TYPE_IDS,
SERVING_FOOD_TYPE_IDS,
PIN_TYPE_IDS,
FENCE_ITEM_TYPE_IDS,
MECHANICAL_ITEM_TYPE_IDS,
LINK_ITEM_TYPE_IDS,
JSON_EXTRA_ITEM_TYPE_IDS,
ROTATABLE_ITEM_TYPE_IDS,
enumIndex,
enumValue,
itemTypeId,
itemTypeValue,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,47 +1,36 @@
"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 STORAGE_PREFIX = "tarinai_japanese_hash_slot_v1_";
const META_PREFIX = "tarinai_japanese_hash_slot_meta_v1_";
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 metaKey(slot) { return `${META_PREFIX}${slot}`; }
function writeSlot(slot, hashText, meta = null) {
const hash = String(hashText || "").trim();
if (!hash.startsWith("たり")) throw new Error("unsupported Japanese hash slot value");
global.localStorage?.setItem(slotKey(slot), hash);
if (meta) global.localStorage?.setItem(metaKey(slot), JSON.stringify({ ...meta, savedAt: Date.now() }));
return { hash, meta: readMeta(slot) };
}
function readSlot(slot) {
const raw = global.localStorage?.getItem(slotKey(slot));
function readMeta(slot) {
const raw = global.localStorage?.getItem(metaKey(slot));
if (!raw) return null;
try {
const data = JSON.parse(raw);
return data?.v === Snapshot.version ? data : null;
} catch (_) {
return null;
}
try { return JSON.parse(raw); } catch (_) { return null; }
}
function readSlot(slot) {
const hash = global.localStorage?.getItem(slotKey(slot));
if (!hash) return null;
return { hash, m: readMeta(slot) };
}
function requireSlot(slot) {
const snapshot = readSlot(slot);
if (!snapshot) throw new Error("empty slot");
return snapshot;
const entry = readSlot(slot);
if (!entry?.hash) throw new Error("empty slot");
return entry.hash;
}
function deleteSlot(slot) {
global.localStorage?.removeItem(slotKey(slot));
global.localStorage?.removeItem(metaKey(slot));
}
global.TarinaiSaveStorage = {
STORAGE_PREFIX,
SLOT_COUNT,
slotKey,
writeSlot,
readSlot,
requireSlot,
deleteSlot,
};
global.TarinaiSaveStorage = { STORAGE_PREFIX, SLOT_COUNT, slotKey, writeSlot, readSlot, requireSlot, deleteSlot };
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -21,32 +21,95 @@
return Codec.encodeSnapshot(snapshot);
}
async function encodeSnapshotWithProfile(snapshot = createSnapshot(global.world)) {
if (typeof Codec.encodeSnapshotWithProfile === "function") return Codec.encodeSnapshotWithProfile(snapshot);
return { text: await Codec.encodeSnapshot(snapshot), profile: null };
}
async function decodeSnapshot(text) {
return Codec.decodeSnapshot(text);
}
async function saveSlot(slot) {
const snapshot = createSnapshot(global.world);
return Storage.writeSlot(slot, snapshot);
const hash = await encodeSnapshot(snapshot);
return Storage.writeSlot(slot, hash, { c: snapshot.c, m: snapshot.m });
}
async function loadSlot(slot) {
const snapshot = Storage.requireSlot(slot);
const hash = Storage.requireSlot(slot);
const snapshot = await decodeSnapshot(hash);
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 "\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";
function formatSlotSummary(entry) {
if (!entry) return "\u7a7a\u304d";
const meta = entry.m || entry;
const m = meta.m || [];
const date = meta.savedAt ? new Date(meta.savedAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : (meta.c ? new Date(meta.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明");
const day = m[0] || 1;
const population = m[1] || 0;
const fieldType = m[2] || "\u5ead";
const fieldType = m[2] || "";
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`;
const hashLen = entry.hash ? ` / ${entry.hash.length}` : "";
return `${date} / ${fieldType} / ${day}日目 ${timeText} / ${population}${hashLen}`;
}
function bytesText(value = 0) {
const n = Math.max(0, Math.round(Number(value) || 0));
return `${n.toLocaleString("ja-JP")}B`;
}
function percentText(value = 0, total = 0) {
const t = Number(total) || 0;
if (t <= 0) return "-";
return `${((Number(value) || 0) / t * 100).toFixed(1)}%`;
}
function profileRow(label, bytes, total) {
return `<div><span>${htmlEscape(label)}</span><strong>${htmlEscape(bytesText(bytes))}</strong><em>${htmlEscape(percentText(bytes, total))}</em></div>`;
}
function renderHashProfile(profile, dialog = ensureDialog()) {
const box = dialog?.querySelector?.("#saveHashProfile");
if (!box) return;
if (!profile) { box.innerHTML = ""; return; }
const sections = profile.sections || {};
const comp = profile.compression || {};
const raw = comp.rawBytes || sections.totalRaw || 0;
const itemEntries = Object.entries(profile.itemTypes || {})
.sort((a, b) => (b[1]?.bytes || 0) - (a[1]?.bytes || 0))
.slice(0, 10);
const topItems = itemEntries.length
? itemEntries.map(([type, entry]) => `<span>${htmlEscape(type)} ${Number(entry.count || 0)}個 / ${htmlEscape(bytesText(entry.bytes || 0))}</span>`).join("")
: `<span>なし</span>`;
box.innerHTML = `
<div class="save-profile-summary">
<span>ハッシュ <strong>${Number(comp.totalChars || 0).toLocaleString("ja-JP")}</strong></span>
<span>圧縮前 <strong>${htmlEscape(bytesText(raw))}</strong></span>
<span>圧縮後 <strong>${htmlEscape(bytesText(comp.packedBytes || 0))}</strong></span>
<span>圧縮率 <strong>${comp.ratio ? `${(comp.ratio * 100).toFixed(1)}%` : "-"}</strong></span>
<span>文字表 <strong>${comp.japaneseText ? "日本語2048" : "不明"}</strong></span>
</div>
<div class="save-profile-grid">
${profileRow("ヘッダ", sections.header, raw)}
${profileRow("ワールド", sections.world, raw)}
${profileRow("たりない合計", sections["tarinai.total"], raw)}
${profileRow("生成seed", sections["tarinai.seed"], raw)}
${profileRow("基本値", sections["tarinai.core"], raw)}
${profileRow("欲求", sections["tarinai.needs"], raw)}
${profileRow("性格差分", sections["tarinai.traits"], raw)}
${profileRow("家族", sections["tarinai.family"], raw)}
${profileRow("関係", sections["tarinai.relationships"], raw)}
${profileRow("タイマー/効果", (sections["tarinai.timers"] || 0) + (sections["tarinai.modes"] || 0) + (sections["tarinai.tail"] || 0), raw)}
${profileRow("アイテム合計", sections["items.total"], raw)}
${profileRow("アイテム基本", sections["items.core"], raw)}
${profileRow("アイテム追加", sections["items.extra"], raw)}
</div>
<div class="save-profile-items"><b>アイテム上位:</b>${topItems}</div>`;
}
function ensureDialog() {
@ -60,15 +123,15 @@
dialog.innerHTML = `
<div class="field-dialog-panel save-dialog-panel">
<h2 id="saveDialogTitle">\u30bb\u30fc\u30d6 / \u30ed\u30fc\u30c9</h2>
<p class="hint save-warning">\u30bb\u30fc\u30d6\u6a5f\u80fd\u306f\u5b9f\u9a13\u4e2d\u3067\u3042\u308a\u3001\u4fdd\u8a3c\u5bfe\u8c61\u5916\u3067\u3059\u3002</p>
<p class="hint">9\u3064\u306e\u30b9\u30ed\u30c3\u30c8\u306b\u73fe\u5728\u306e\u30b3\u30ed\u30cb\u30fc\u3092\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306f\u5225\u7aef\u672b\u3084\u5225\u30d6\u30e9\u30a6\u30b6\u3078\u306e\u79fb\u52d5\u306b\u4f7f\u3048\u307e\u3059\u3002</p>
<p class="hint save-warning">セーブは実験的機能であり保証対象外です</p>
<div id="saveSlotList" class="save-slot-list"></div>
<div class="save-export-panel">
<div class="save-export-actions">
<button id="saveExportBtn" class="btn" type="button">\u73fe\u5728\u306e\u72b6\u614b\u3092\u66f8\u304d\u51fa\u3057</button>
<button id="saveImportBtn" class="btn primary" type="button">\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f</button>
<button id="saveExportBtn" class="btn" type="button">呪文をセーブ</button>
<button id="saveImportBtn" class="btn primary" type="button">呪文をロード</button>
</div>
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="\u3053\u3053\u306b\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304c\u8868\u793a\u3055\u308c\u307e\u3059\u3002\u8aad\u307f\u8fbc\u3080\u5834\u5408\u306f\u3053\u3053\u306b\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"></textarea>
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="セーブした呪文はコピペして保存してください。"></textarea>
<div id="saveHashProfile" class="save-hash-profile" aria-live="polite"></div>
</div>
<div class="field-dialog-actions">
<button id="saveCloseBtn" class="btn" type="button">\u9589\u3058\u308b</button>
@ -91,9 +154,9 @@
<div class="save-slot-title">\u30b9\u30ed\u30c3\u30c8 ${i}</div>
<div class="save-slot-meta">${htmlEscape(formatSlotSummary(snapshot))}</div>
<div class="save-slot-actions">
<button class="btn mini" data-save-slot="${i}" type="button">\u4fdd\u5b58</button>
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>\u8aad\u8fbc</button>
<button class="btn mini danger" data-delete-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>\u524a\u9664</button>
<button class="btn mini" data-save-slot="${i}" type="button">セーブ</button>
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>ロード</button>
<button class="btn mini danger" data-delete-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>消す</button>
</div>`;
list.appendChild(row);
}
@ -131,58 +194,60 @@
await saveSlot(slot);
renderSlotList();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
global.showToast?.(`スロット${slot}にセーブしました。`);
} catch (error) {
console.warn(error);
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");
global.showToast?.("セーブに失敗しました。空き容量を確認してください。");
}
return;
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
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;
if (!global.confirm?.(`スロット${slot}をロードします。現在の状態は上書きされます。`)) return;
try {
await loadSlot(slot);
closeDialog();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002`);
global.showToast?.(`スロット${slot}をロードしました。`);
} catch (error) {
console.warn(error);
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
global.showToast?.("ロードに失敗しました。");
}
return;
}
if (deleteBtn) {
const slot = Number(deleteBtn.dataset.deleteSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f`)) return;
if (!global.confirm?.(`スロット${slot}を消しますか?`)) return;
deleteSlot(slot);
renderSlotList();
global.audio?.delete?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3057\u305f\u3002`);
global.showToast?.(`スロット${slot}を消しました。`);
return;
}
if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) {
const area = dialog.querySelector("#saveHashText");
if (area) {
area.value = await encodeSnapshot(createSnapshot(global.world));
const result = await encodeSnapshotWithProfile(createSnapshot(global.world));
area.value = result.text || "";
renderHashProfile(result.profile, dialog);
area.focus();
area.select();
}
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");
global.showToast?.("呪文をセーブしました。");
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
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;
if (!text.trim()) { global.showToast?.("ロードする呪文を貼り付けてください。"); return; }
if (!global.confirm?.("呪文をロードします。現在の状態は上書きされます。")) return;
try {
restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" });
closeDialog();
global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
global.showToast?.("呪文からロードしました。");
} catch (error) {
console.warn(error);
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");
global.showToast?.("ロードに失敗しました。呪文を確認してください。");
}
}
});
@ -192,6 +257,7 @@
createSnapshot,
restoreSnapshot,
encodeSnapshot,
encodeSnapshotWithProfile,
decodeSnapshot,
saveSlot,
loadSlot,

View file

@ -120,6 +120,40 @@ function scaleForGrowth(adultScale = 0.28, growth = 1) {
return clamp((Number(adultScale) || 0.28) * childGrowthScaleFactor(growth), 0.10, 0.42);
}
function tarinaiBodySizeRatio(tarinai = null) {
const baseScale = 0.28;
let effectiveScale = Number(tarinai?.scale);
if (tarinai && typeof tarinai.effectiveScale === "function") effectiveScale = Number(tarinai.effectiveScale());
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) effectiveScale = baseScale;
return clamp(effectiveScale / baseScale, 0.28, 3.25);
}
function tarinaiMaxEnergy(tarinai = null) {
return Math.round(clamp(100 * tarinaiBodySizeRatio(tarinai), 28, 240));
}
function clampTarinaiEnergy(tarinai = null) {
if (!tarinai) return 100;
const max = tarinaiMaxEnergy(tarinai);
tarinai.maxEnergy = max;
tarinai.energy = clamp(Number(tarinai.energy) || 0, 0, max);
return max;
}
function tarinaiEnergyRatio(tarinai = null) {
const max = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai?.maxEnergy) || 100));
return clamp((Number(tarinai?.energy) || 0) / max, 0, 1);
}
function tarinaiMetabolicHungerScale(tarinai = null) {
if (!tarinai) return 1;
const attack = Math.max(0.15, typeof tarinai.outgoingDamage === "function" ? tarinai.outgoingDamage(1) : (tarinai.geneticStatRatio ? tarinai.geneticStatRatio("attack") : (Number(tarinai.genetics?.attackMul) || 1)));
const speed = Math.max(0.15, (tarinai.geneticStatRatio ? tarinai.geneticStatRatio("speed") : (Number(tarinai.genetics?.speedMul) || 1)) * (tarinai.itemSpeedMultiplier ? tarinai.itemSpeedMultiplier() : 1));
const size = tarinaiBodySizeRatio(tarinai);
return clamp(attack * speed * size, 0.18, 8.0);
}
const PERSONALITIES = Object.freeze({});
const PERSONALITY_IDS = [];
const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"];
@ -482,6 +516,21 @@ class Effect {
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "wind") {
const drift = 1 - alpha;
ctx.strokeStyle = this.color || "rgba(160,210,245,0.30)";
ctx.lineWidth = Math.max(1.2, this.size * 0.12);
ctx.lineCap = "round";
for (let i = 0; i < 3; i++) {
const y = (i - 1) * this.size * 0.28 + Math.sin(this.seed + i) * 2;
const x0 = -this.size * (0.55 + i * 0.08) + drift * this.size * 0.55;
const x1 = this.size * (0.72 + i * 0.12) + drift * this.size * 0.85;
ctx.globalAlpha = alpha * (0.22 + i * 0.08);
ctx.beginPath();
ctx.moveTo(x0, y);
ctx.quadraticCurveTo(0, y - this.size * 0.22, x1, y);
ctx.stroke();
}
} else if (this.type === "zunchi_miasma") {
const drift = 1 - alpha;
ctx.globalCompositeOperation = "source-over";
@ -607,7 +656,7 @@ class SpatialGrid {
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 (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo" || (type === "rotator" && Math.abs(Number(it.rotatorSpeed || 0)) > 0.001)) 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;
@ -619,7 +668,7 @@ class SpatialGrid {
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, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v")) 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);
}

23
js/simulation.js Normal file
View file

@ -0,0 +1,23 @@
"use strict";
// Layer: simulation
// Single update entrypoint. World owns state; phase modules own the ordered update work.
(function (global) {
function update(worldRef, dt) {
const order = global.TarinaiSystemOrder;
if (order?.update) return order.update(worldRef, dt);
const phases = global.TarinaiWorldUpdatePhases;
if (!phases?.update) throw new Error("TarinaiWorldUpdatePhases is not available");
return phases.update(worldRef, dt);
}
function phases() {
return global.TarinaiWorldUpdatePhases?.phases || null;
}
function systemOrder() {
return global.TarinaiSystemOrder?.names || null;
}
global.TarinaiSimulation = Object.freeze({ update, phases, systemOrder });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,50 @@
"use strict";
// Layer: simulation/system-ambient-spawn
// Owns low-frequency ambient Tarinai/grass/rain-water creation.
(function (global) {
function spawnAmbientTarinai(worldRef, dt) {
if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
const spot = worldRef.edgeSpawnPoint(null, null, 42, true);
const t = worldRef.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, entryTimer: 2.0 });
t.wanderAngle = spot.angle;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
}
}
function spawnAmbientGrass(worldRef, dt) {
if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return;
const groundGrowthMul = Math.max(0, Number(global.TarinaiGround?.grassGrowthMultiplier?.(worldRef.groundType || "soil") ?? (worldRef.groundGrassMultiplier?.() ?? 1)) || 0);
if (groundGrowthMul <= 0) return;
if ((worldRef.canAddGrass?.(1) ?? true) && Math.random() < dt * 0.070 * groundGrowthMul) {
const spot = worldRef.findGrassPlantingSpot(rand(60, worldRef.w - 60), rand(60, worldRef.h - 60), {
allowOriginal: true,
minRadius: 28,
maxRadius: 140,
attempts: 18,
});
if (spot) {
const sprout = new Item("grass", spot.x, spot.y);
global.TarinaiGrass?.setStage?.(sprout, 0, { force: true });
if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn");
else worldRef.items.push(sprout);
}
}
}
function spawnRainWater(worldRef, dt) {
// Rain visuals are diagonal line streaks in render.js. This branch creates
// horizontal oval water items spawned by rain; it intentionally does not
// share the rain overlay renderer.
if (global.TarinaiWeatherSystem?.shouldDropRainWater?.(worldRef, dt)) {
const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef);
worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop);
worldRef.emit?.("rain:itemDropped", { item: drop, type: "water" });
}
}
global.TarinaiAmbientSimulationSystem = Object.freeze({
phases: Object.freeze({ spawnAmbientTarinai, spawnAmbientGrass, spawnRainWater }),
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,70 @@
"use strict";
// Layer: simulation/system-creatures
// Owns Tarinai update cadence, Tarinai movement invalidation, and creature
// collision/interactions scheduled after individual updates.
(function (global) {
const helpers = () => global.TarinaiSimulationRuntime?.helpers || {};
const updatePolicy = () => global.TarinaiCreatureUpdatePolicy || {};
function updateCreatures(worldRef, dt) {
const h = helpers();
const end = global.TarinaiPerf?.begin?.("update.tarinai") || null;
const visibleRect = h.updateVisibleWorldRect?.(worldRef, 160) || { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity };
try {
for (const t of worldRef.tarinai) {
if (!t || t.dead) continue;
const interval = updatePolicy().updateInterval?.(worldRef, t, visibleRect) ?? 0;
if (!Number.isFinite(interval)) continue;
if (interval <= 0) {
const runDt = Math.min(1.4, (t._updateAccum || 0) + dt);
t._updateAccum = 0;
if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt);
else t.update(runDt);
continue;
}
t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt);
t._nextLowFreqUpdateAt = Number.isFinite(t._nextLowFreqUpdateAt) ? t._nextLowFreqUpdateAt : (worldRef.time || 0) + interval * (0.65 + Math.random() * 0.7);
if ((worldRef.time || 0) >= t._nextLowFreqUpdateAt) {
const runDt = Math.max(dt, t._updateAccum || dt);
t._updateAccum = 0;
t._nextLowFreqUpdateAt = (worldRef.time || 0) + interval;
if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt);
else t.update(runDt);
}
}
} finally {
if (end) end();
}
const now = worldRef.time || 0;
const bedConflictInterval = 3.2;
if (now >= (worldRef.nextBedConflictCheckAt || 0)) {
worldRef.nextBedConflictCheckAt = now + bedConflictInterval;
worldRef.workStats && (worldRef.workStats.bedConflictRuns = (worldRef.workStats.bedConflictRuns || 0) + 1);
worldRef.resolveOwnedBedSleepConflicts?.();
} else {
worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1);
}
h.markSpatialDirtyIfMoved?.(worldRef, worldRef.tarinai, "tarinai-moved", 0.25);
worldRef.ensureSpatial?.("post-tarinai-update");
worldRef.limitBallChasers();
worldRef.resolveBallInteractions(dt);
const collisionInterval = 1.8;
if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) {
worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval;
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
worldRef.resolveTarinaiHighSpeedCollisions(dt);
} else {
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
}
}
const phases = Object.freeze({ updateCreatures });
const helpersApi = Object.freeze({
updateInterval(worldRef, tarinai, visibleRect) {
return updatePolicy().updateInterval?.(worldRef, tarinai, visibleRect) ?? Infinity;
},
});
global.TarinaiCreatureSimulationSystem = Object.freeze({ phases, helpers: helpersApi });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,13 @@
"use strict";
// Layer: simulation/system-effects
// Owns frame updates for transient visual/effect entities.
(function (global) {
function updateEffects(worldRef, dt) {
for (const ef of worldRef.effects) ef.update(dt);
}
global.TarinaiEffectsSimulationSystem = Object.freeze({
phases: Object.freeze({ updateEffects }),
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,71 @@
"use strict";
// Layer: simulation/system-environment
// Owns frame time, world clock, frame signals, weather/terrain preparation,
// phase-change notification, and diagnostics publication.
(function (global) {
function normalizeDelta(worldRef, dt) {
if (!worldRef || worldRef.paused) return null;
let frameDt = dt * worldRef.speed;
frameDt = Math.min(frameDt, 0.12 * worldRef.speed);
return frameDt;
}
function updateClock(worldRef, dt) {
const prevTime = worldRef.time;
worldRef.time += dt;
const previousDay = worldRef.day || 1;
worldRef.day = Math.floor(worldRef.time / CONFIG.dayLength) + 1;
const prevHour = worldRef.hourOfDay ? worldRef.hourOfDay(prevTime) : 0;
const nowHour = worldRef.hourOfDay ? worldRef.hourOfDay(worldRef.time) : 0;
const crossedColonyEvalHour = (worldRef.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6);
const pendingColonyMoodEvaluation = crossedColonyEvalHour && worldRef.lastColonyMoodDay !== worldRef.day;
if (worldRef.day !== previousDay) {
for (const t of worldRef.tarinai || []) if (t) t.personalityDaily = { day: worldRef.day, total: 0, byKey: {}, byCause: {} };
}
return { prevTime, previousDay, prevHour, nowHour, crossedColonyEvalHour, pendingColonyMoodEvaluation };
}
function updateFrameSignals(worldRef, dt) {
if (worldRef.pointer) worldRef.pointer.motion = Math.max(0, (worldRef.pointer.motion || 0) - dt * 2.4);
worldRef.shakeTimer = Math.max(0, (worldRef.shakeTimer || 0) - dt);
if ((worldRef.shakeTimer || 0) <= 0) { worldRef.shakeDuration = 0; worldRef.shakeStrength = 0; }
}
function updateEnvironment(worldRef, dt) {
worldRef.updateWeather(dt);
worldRef.foodSpoilagePenalty = Math.max(0, (worldRef.foodSpoilagePenalty || 0) - dt * 0.010);
worldRef.updateTerrainDirtyState?.(dt);
}
function prepareSpatialFrame(worldRef) {
worldRef.spatialRebuildsThisFrame = 0;
worldRef.spatialDirtyMarksThisFrame = 0;
worldRef.ensureSpatial?.("update-start");
worldRef.beginFramePerformanceBudgets?.();
}
function emitPhaseChange(worldRef) {
const phase = worldRef.phaseName();
if (phase !== worldRef.lastPhase) {
worldRef.lastPhase = phase;
worldRef.emit("world:phase", { world: worldRef, phase });
}
}
function finalizeDiagnostics(worldRef) {
worldRef.lastRuntimeDiagnostics = worldRef.runtimeDiagnostics?.() || null;
}
const phases = Object.freeze({
normalizeDelta,
updateClock,
updateFrameSignals,
updateEnvironment,
prepareSpatialFrame,
emitPhaseChange,
finalizeDiagnostics,
});
global.TarinaiEnvironmentSimulationSystem = Object.freeze({ phases });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,63 @@
"use strict";
// Layer: simulation/system-items-ants
// Owns scheduled item updates, random grass growth, ball/ant spatial refresh,
// and item/ant spatial post-processing.
(function (global) {
const helpers = () => global.TarinaiSimulationRuntime?.helpers || {};
function updateRandomGrassGrowth(worldRef, dt) {
if (!worldRef || !global.TarinaiGrass) return false;
if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return false;
const groundGrowthMul = Math.max(0, Number(global.TarinaiGround?.grassGrowthMultiplier?.(worldRef.groundType || "soil") ?? (worldRef.groundGrassMultiplier?.() ?? 1)) || 0);
if (groundGrowthMul <= 0) return false;
worldRef.grassGrowthTimer = (worldRef.grassGrowthTimer || 0) + dt;
const field = worldRef.fieldType || "garden";
const growthSpeedMul = 6.0 * groundGrowthMul;
const interval = (field === "park" ? 1.8 : (field === "cage" ? 3.2 : 2.4)) / Math.max(0.1, growthSpeedMul);
if (worldRef.grassGrowthTimer < interval) return false;
worldRef.grassGrowthTimer = 0;
const grasses = worldRef.itemsOfType ? worldRef.itemsOfType("grass") : (worldRef.items || []).filter(it => it && it.type === "grass");
if (!grasses || !grasses.length) return false;
const tries = Math.min(12, grasses.length);
for (let i = 0; i < tries; i++) {
const it = grasses[Math.floor(Math.random() * grasses.length)];
if (!it || it.dead || (it.amount || 0) <= 0) continue;
if (worldRef.grassBlockedAt?.(it.x, it.y, it)) {
if (global.TarinaiGrass.regress(it, 1) > 0) {
worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-blocked") || worldRef.markTerrainDirty?.("random-grass-blocked");
worldRef.markItemBucketsDirty?.("random-grass-blocked");
return true;
}
continue;
}
if (global.TarinaiGrass.advance(it, 1)) {
worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-grow") || worldRef.markTerrainDirty?.("random-grass-grow");
return true;
}
}
return false;
}
function updateItemsAndAnts(worldRef, dt) {
const h = helpers();
const itemCountBefore = worldRef.items.length;
h.updateItemsScheduled?.(worldRef, dt);
updateRandomGrassGrowth(worldRef, dt);
const itemsMoved = h.markScheduledItemsMoved?.(worldRef, "items-moved", 0.25) || false;
if (worldRef.itemCounts?.ball) {
worldRef.ensureSpatial?.("ball-ball-collisions");
worldRef.resolveBallBallCollisions(dt);
worldRef.markSpatialDirty?.("ball-ball-collisions");
}
worldRef.updateAnts?.(dt);
const antsMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.ants || [], "ants-moved", 0.25) || false;
if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update");
return { itemCountBefore, itemsMoved, antsMoved };
}
const phases = Object.freeze({ updateItemsAndAnts });
const helpersApi = Object.freeze({ updateRandomGrassGrowth });
global.TarinaiItemAntSimulationSystem = Object.freeze({ phases, helpers: helpersApi });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,51 @@
"use strict";
// Layer: simulation/system-maintenance
// Owns periodic compaction, counts refresh, grass limit enforcement,
// out-of-bounds cleanup, and draw-list invalidation.
(function (global) {
function runMaintenance(worldRef, dt, itemCountBefore) {
worldRef.compactTimer += dt;
const compactInterval = 2.5;
let itemsCompacted = false;
if (worldRef.compactTimer >= compactInterval) {
itemsCompacted = worldRef.compactItems();
worldRef.compactEffects();
worldRef.compactTarinai();
worldRef.compactAnts?.();
worldRef.compactTimer = 0;
}
worldRef.countsTimer += dt;
const countsInterval = 2.0;
if (worldRef.countsTimer >= countsInterval || itemsCompacted || worldRef.items.length !== itemCountBefore) {
worldRef.updateItemCounts();
worldRef.updateEffectCounts();
worldRef.countsTimer = 0;
}
if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) {
worldRef.nextGrassLimitCheckAt = (worldRef.time || 0) + 3.0;
const removedGrass = worldRef.enforceGrassLimit?.("periodic-grass-limit") || 0;
if (removedGrass > 0) {
worldRef.compactItems();
worldRef.updateItemCounts();
}
}
if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= worldRef.time) {
worldRef.outOfBoundsCheckAt = worldRef.time;
worldRef.sanitizeOutOfBounds();
}
worldRef.drawSortTimer += dt;
const sortInterval = 0.25;
if (worldRef.drawSortTimer >= sortInterval) {
worldRef.drawListDirty = true;
worldRef.drawSortTimer = 0;
}
}
global.TarinaiMaintenanceSimulationSystem = Object.freeze({
phases: Object.freeze({ runMaintenance }),
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,140 @@
"use strict";
// Layer: simulation/helpers
// Shared, UI-free helpers used by the concrete simulation systems.
(function (global) {
const policy = () => global.TarinaiItemUpdatePolicy || {};
function forScheduledItems(worldRef, visitor) {
if (policy().forScheduledItems) return policy().forScheduledItems(worldRef, visitor);
if (!worldRef || typeof visitor !== "function") return 0;
let count = 0;
for (const it of worldRef.items || []) {
if (!it || it.dead || it.type === "grass") continue;
visitor(it);
count += 1;
}
return count;
}
function itemUpdateInterval(item) {
const interval = policy().itemUpdateInterval?.(item);
return Number.isFinite(interval) ? interval : Infinity;
}
function updateItemsScheduled(worldRef, dt) {
if (global.TarinaiItemRuntime?.updateScheduled) return global.TarinaiItemRuntime.updateScheduled(worldRef, dt);
if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt);
let ran = 0;
forScheduledItems(worldRef, (it) => {
const interval = itemUpdateInterval(it);
if (!Number.isFinite(interval)) return;
if (interval <= 0) {
if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(it, dt, worldRef);
else it.update?.(dt, worldRef);
ran += 1;
return;
}
it._scheduledUpdateDt = Math.min(10, (it._scheduledUpdateDt || 0) + dt);
if (it._scheduledUpdateDt < interval) return;
const scheduledDt = it._scheduledUpdateDt;
it._scheduledUpdateDt = 0;
if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(it, scheduledDt, worldRef);
else it.update?.(scheduledDt, worldRef);
ran += 1;
});
return ran;
}
function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) {
if (!worldRef || !Array.isArray(list) || !list.length) return false;
const thresholdSq = threshold * threshold;
let moved = false;
for (const entity of list) {
if (!entity || entity.dead) continue;
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
continue;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) continue;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
function markScheduledItemsMoved(worldRef, reason = "items-moved", threshold = 0.35) {
if (!worldRef) return false;
const thresholdSq = threshold * threshold;
let moved = false;
const tracked = global.TarinaiItemRuntime?.trackedDynamicItems?.(worldRef) || global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef);
const source = tracked && tracked.length ? tracked : null;
const visit = (entity) => {
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
return;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) return;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
};
if (source) {
for (const entity of source) if (entity && !entity.dead) visit(entity);
} else {
forScheduledItems(worldRef, visit);
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
function updateVisibleWorldRect(worldRef, margin = 120) {
const scale = worldRef.viewScale ? worldRef.viewScale() : 1;
const vw = (worldRef.viewportW || worldRef.w || 1000) / Math.max(0.01, scale);
const vh = (worldRef.viewportH || worldRef.h || 720) / Math.max(0.01, scale);
const cameraX = worldRef.cameraX || 0;
const cameraY = worldRef.cameraY || 0;
return { left: cameraX - margin, top: cameraY - margin, right: cameraX + vw + margin, bottom: cameraY + vh + margin };
}
function pointInRect(x, y, rect) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
const helpers = Object.freeze({
forScheduledItems,
itemUpdateInterval,
updateItemsScheduled,
markSpatialDirtyIfMoved,
markScheduledItemsMoved,
updateVisibleWorldRect,
pointInRect,
});
global.TarinaiSimulationRuntime = Object.freeze({
helpers,
constants: Object.freeze({
get REALTIME_ITEM_TYPES() { return policy().REALTIME_ITEM_TYPES || new Set(); },
get PIN_ITEM_TYPES() { return policy().PIN_ITEM_TYPES || new Set(); },
get SCHEDULED_ITEM_TYPES() { return policy().SCHEDULED_ITEM_TYPES || Object.freeze([]); },
}),
});
})(typeof window !== "undefined" ? window : globalThis);

76
js/simulation_systems.js Normal file
View file

@ -0,0 +1,76 @@
"use strict";
// Layer: simulation/systems-facade
// Owns the concrete phase registry boundary. The actual phase implementations
// are split across simulation_*_system.js files; this facade preserves the
// public TarinaiSimulationSystems contract for system_order.js and tests.
(function (global) {
function phaseFrom(moduleName, phaseName) {
const fn = global[moduleName]?.phases?.[phaseName];
if (typeof fn !== "function") throw new Error(`Tarinai simulation phase missing: ${moduleName}.${phaseName}`);
return fn;
}
function normalizeDelta(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "normalizeDelta")(worldRef, dt); }
function updateClock(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "updateClock")(worldRef, dt); }
function updateFrameSignals(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "updateFrameSignals")(worldRef, dt); }
function updateEnvironment(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "updateEnvironment")(worldRef, dt); }
function prepareSpatialFrame(worldRef) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "prepareSpatialFrame")(worldRef); }
function updateItemsAndAnts(worldRef, dt) { return phaseFrom("TarinaiItemAntSimulationSystem", "updateItemsAndAnts")(worldRef, dt); }
function updateEffects(worldRef, dt) { return phaseFrom("TarinaiEffectsSimulationSystem", "updateEffects")(worldRef, dt); }
function updateCreatures(worldRef, dt) { return phaseFrom("TarinaiCreatureSimulationSystem", "updateCreatures")(worldRef, dt); }
function runMaintenance(worldRef, dt, itemCountBefore) { return phaseFrom("TarinaiMaintenanceSimulationSystem", "runMaintenance")(worldRef, dt, itemCountBefore); }
function emitPhaseChange(worldRef) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "emitPhaseChange")(worldRef); }
function spawnAmbientTarinai(worldRef, dt) { return phaseFrom("TarinaiAmbientSimulationSystem", "spawnAmbientTarinai")(worldRef, dt); }
function spawnAmbientGrass(worldRef, dt) { return phaseFrom("TarinaiAmbientSimulationSystem", "spawnAmbientGrass")(worldRef, dt); }
function spawnRainWater(worldRef, dt) { return phaseFrom("TarinaiAmbientSimulationSystem", "spawnRainWater")(worldRef, dt); }
function finalizeDiagnostics(worldRef) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "finalizeDiagnostics")(worldRef); }
// finalizeDiagnostics publishes worldRef.lastRuntimeDiagnostics.
function update(worldRef, dt) {
const frameDt = normalizeDelta(worldRef, dt);
if (frameDt === null) return;
updateClock(worldRef, frameDt);
updateFrameSignals(worldRef, frameDt);
updateEnvironment(worldRef, frameDt);
prepareSpatialFrame(worldRef);
const mobile = updateItemsAndAnts(worldRef, frameDt);
updateEffects(worldRef, frameDt);
updateCreatures(worldRef, frameDt);
runMaintenance(worldRef, frameDt, mobile.itemCountBefore);
emitPhaseChange(worldRef);
spawnAmbientTarinai(worldRef, frameDt);
spawnAmbientGrass(worldRef, frameDt);
spawnRainWater(worldRef, frameDt);
finalizeDiagnostics(worldRef);
}
const phases = Object.freeze({
normalizeDelta,
updateClock,
updateFrameSignals,
updateEnvironment,
prepareSpatialFrame,
updateItemsAndAnts,
updateEffects,
updateCreatures,
runMaintenance,
emitPhaseChange,
spawnAmbientTarinai,
spawnAmbientGrass,
spawnRainWater,
finalizeDiagnostics,
});
const helpers = Object.freeze({
...(global.TarinaiSimulationRuntime?.helpers || {}),
...(global.TarinaiItemAntSimulationSystem?.helpers || {}),
...(global.TarinaiCreatureSimulationSystem?.helpers || {}),
});
global.TarinaiSimulationSystems = Object.freeze({
update,
phases,
helpers,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,16 +1,59 @@
"use strict";
(function (global) {
const SNAPSHOT_VERSION = 3;
const SaveSchema = global.TarinaiSaveSchema;
if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for snapshot_system.js");
const SNAPSHOT_VERSION = SaveSchema.SNAPSHOT_VERSION;
const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]);
const {
FIELD_IDS,
GROUND_IDS,
WEATHER_IDS,
MOOD_IDS,
STATE_IDS,
POWER_MODE_IDS,
SIZE_MODE_IDS,
LIFE_MODE_IDS,
PIN_STATE_IDS,
STAGE_IDS,
ITEM_TYPE_IDS,
enumIndex,
enumValue,
} = SaveSchema;
function spriteIds() {
const list = typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []);
return (list || []).map(s => s?.id).filter(Boolean);
}
function spriteIndex(id = "smile") {
const ids = spriteIds();
const i = ids.indexOf(String(id || ""));
return i >= 0 ? i : Math.max(0, ids.indexOf("smile"));
}
function spriteValue(index = 0) {
const ids = spriteIds();
return ids[Number(index) | 0] || "smile";
}
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 mortonKeyXY(x = 0, y = 0) {
const ix = Math.max(0, Math.min(65535, Math.round(Number(x) || 0)));
const iy = Math.max(0, Math.min(65535, Math.round(Number(y) || 0)));
let out = 0;
for (let i = 0; i < 16; i++) out += (((ix >> i) & 1) * (2 ** (2 * i))) + (((iy >> i) & 1) * (2 ** (2 * i + 1)));
return out;
}
function isPlainObject(value) {
if (!value || typeof value !== "object") return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function clonePlain(value, depth = 0) {
if (value == null) return value;
const type = typeof value;
@ -35,7 +78,6 @@
}
return out;
}
function copyOwnData(obj, skip = DEFAULT_SKIP) {
const out = {};
if (!obj) return out;
@ -46,7 +88,6 @@
}
return out;
}
function applyOwnData(target, data, skip = DEFAULT_SKIP) {
if (!target || !data) return target;
for (const [key, value] of Object.entries(data)) {
@ -56,172 +97,121 @@
}
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 || "" };
if ((worldRef?.tarinai || []).includes(value)) return { kind: "tarinai", id: value.id || "", familyKey: value.familyKey || "" };
if ((worldRef?.items || []).includes(value)) return { kind: "item", id: value.id || "", type: value.type || "" };
if ((worldRef?.ants || []).includes(value)) return { kind: "ant", id: value.id || "" };
if (Number.isFinite(value.x) && Number.isFinite(value.y)) return { kind: "point", x: value.x, y: value.y, dead: Boolean(value.dead) };
return null;
}
function resolveRef(worldRef, ref) {
if (!ref || typeof ref !== "object") return null;
if (ref.kind === "tarinai") return (worldRef.tarinai || []).find(t => t && !t.dead && ((ref.id && t.id === ref.id) || (ref.familyKey && t.familyKey === ref.familyKey))) || null;
if (ref.kind === "item") return (worldRef.items || []).find(it => it && !it.dead && it.id === ref.id) || null;
if (ref.kind === "ant") return (worldRef.ants || []).find(a => a && !a.dead && a.id === ref.id) || null;
if (ref.kind === "point") return { x: Number(ref.x) || 0, y: Number(ref.y) || 0, dead: Boolean(ref.dead) };
return null;
}
function worldData(worldRef) {
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 needsToArray(needs = {}) {
return NEED_KEYS.map(k => q(needs[k], 1));
}
function arrayToNeeds(arr = []) {
const out = typeof global.createDefaultNeeds === "function" ? global.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 personalityToArray(p = {}) {
return ["aggression", "openness", "sociability", "neuroticism"].map(k => q((Number(p?.[k]) || 0) + 1, 100, 100));
}
function arrayToPersonality(arr = []) {
const keys = ["aggression", "openness", "sociability", "neuroticism"];
const out = {};
keys.forEach((k, i) => { out[k] = u(arr[i], 100, 1) - 1; });
return out;
}
function geneticsToArray(g = {}) {
return ["lifeSpanMul", "speedMul", "sizeMul", "fightMul"].map(k => q(Number(g?.[k]) || 1, 100, 100));
}
function arrayToGenetics(arr = []) {
const keys = ["lifeSpanMul", "speedMul", "sizeMul", "fightMul"];
const out = {};
keys.forEach((k, i) => { out[k] = u(arr[i], 100, 1); });
return out;
}
function flagPack(t = {}) {
let flags = 0;
if (t.favorite) flags |= 1 << 0;
if (t.zunchiDisease) flags |= 1 << 1;
if (t.sleepDisease) flags |= 1 << 2;
if (t.explosionDisease) flags |= 1 << 3;
if (t.fightDisease) flags |= 1 << 4;
if (t.isZunchiSlave) flags |= 1 << 5;
if (t.zunchiSlaveLocked) flags |= 1 << 6;
if (t.birthRitualLeader) flags |= 1 << 7;
if (t.hasPaired) flags |= 1 << 8;
return flags;
}
function flagApply(t, flags = 0) {
t.favorite = !!(flags & (1 << 0));
t.zunchiDisease = !!(flags & (1 << 1));
t.sleepDisease = !!(flags & (1 << 2));
t.explosionDisease = !!(flags & (1 << 3));
t.fightDisease = !!(flags & (1 << 4));
t.isZunchiSlave = !!(flags & (1 << 5));
t.zunchiSlaveLocked = !!(flags & (1 << 6));
t.birthRitualLeader = !!(flags & (1 << 7));
t.hasPaired = !!(flags & (1 << 8));
if (t.isZunchiSlave) t.zunchiSlaveLocked = true;
}
function compactWorld(worldRef) {
const mood = worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed";
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
const dayLength = Math.max(1, Number(config.dayLength || 120));
const worldTick10 = q((Math.max(1, worldRef?.day || 1) - 1) * dayLength + (Number(worldRef?.time) || 0), 10);
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),
enumIndex(FIELD_IDS, worldRef?.fieldType || "garden"),
enumIndex(GROUND_IDS, worldRef?.groundType || "soil"),
enumIndex(MOOD_IDS, worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed"),
worldTick10,
enumIndex(WEATHER_IDS, worldRef?.weather || "sunny"),
q(worldRef?.lastBirthAt, 10, -9990),
q(worldRef?.maxGeneration, 1, 1),
mood,
worldRef?.groundType || "soil",
q(worldRef?.deadCount, 1, 0),
worldRef?.worldSeed || global.TarinaiSeedFactory?.ensureWorldSeed?.(worldRef) || "",
q(worldRef?.birthSerial, 1, 0),
];
}
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 });
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
const dayLength = Math.max(1, Number(config.dayLength || 120));
const totalTime = u(arr[3], 10, 0);
worldRef.fieldType = enumValue(FIELD_IDS, arr[0], "garden");
worldRef.weather = enumValue(WEATHER_IDS, arr[4], "sunny");
worldRef.worldSeed = String(arr[8] || "") || global.TarinaiSeedFactory?.createWorldSeed?.() || `w${Date.now().toString(36)}`;
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
worldRef.day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
worldRef.time = totalTime - (worldRef.day - 1) * dayLength;
worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
worldRef.deadCount = u(arr[7], 1, 0);
worldRef.liveIdNext = 1;
worldRef.liveIdSerial = 1;
worldRef.lastBirthAt = u(arr[5], 10, -999);
worldRef.maxGeneration = Math.max(1, u(arr[6], 1, 1));
const moodId = enumValue(MOOD_IDS, arr[2], "relaxed");
worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || { id: moodId, label: moodId, effects: { personality: {} } };
worldRef.setGroundType?.(enumValue(GROUND_IDS, arr[1], "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 || {})) {
@ -229,89 +219,166 @@
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)]);
const wins = Math.min(15, Math.max(0, Math.round(Number(rel?.fightsWon) || 0)));
const losses = Math.min(15, Math.max(0, Math.round(Number(rel?.fightsLost) || 0)));
if (Math.abs(affinity) < 5 && fear < 5 && wins + losses < 1) continue;
out.push([idx, q(affinity, 2), q(fear, 2), wins | (losses << 4)]);
}
return out;
}
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;
const fight = Number(row[3]) || 0;
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" : "",
affinity: u(row[1], 2),
fear: u(row[2], 2),
fightsWon: fight & 15,
fightsLost: (fight >> 4) & 15,
lastEvent: fight ? "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),
q(t.zunchiDiseaseSeverity, 10), q(t.zunchiStain, 10), q(t.itemEffectTimers?.laxative, 10), q(t.itemEffectTimers?.ammo, 10), q(t.mercuryLifeMultiplier, 100, 100)
];
const modes = [t.powerItemMode || "", t.sizeItemMode || "", t.lifeItemMode || ""];
const modes = [enumIndex(POWER_MODE_IDS, t.powerItemMode || ""), enumIndex(SIZE_MODE_IDS, t.sizeItemMode || ""), enumIndex(LIFE_MODE_IDS, t.lifeItemMode || "")];
const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1;
const nestIdx = itemIndex.get(t.insideNestBoxId) ?? -1;
const state = t.state === "sleep" ? "sleep" : (STATE_IDS.includes(t.state) ? t.state : "idle");
let targetIdx = -1;
if (state === "sleep") {
const targetId = t.sleepSession?.targetId || t.pendingGrassBedWakeId || t.target?.id || "";
targetIdx = itemIndex.get(targetId) ?? itemIndex.get(t.target?.id) ?? -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),
flagPack(t), t.birthSeed || t.familyKey || t.id || `s${idx}`, q(t.x, 1), q(t.y, 1),
q(t.age, 10), q(t.generation, 1, 1), q(t.hunger, 1), q(t.energy, 1), q(t.circadianSleepPressure, 1),
needsToArray(t.needs || t.needRaw || {}), personalityToArray(t.currentPersonality),
parentIdx, compactRelationships(t, tarinaiIndex), timers, modes, pinIdx, nestIdx, [enumIndex(STATE_IDS, state), targetIdx]
];
}
function expandTarinai(row, index, worldRef) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
const needs = arrayToNeeds(row[9]);
const age = u(row[4], 10);
const birthSeed = String(row[1] || `s${index}`);
const profile = global.TarinaiSeedFactory?.birthProfile?.(birthSeed) || {};
const opts = {
familyKey: birthSeed,
birthSeed,
birthSerial: global.TarinaiSeedFactory?.serialFromBirthSeed?.(birthSeed) || 0,
id: `st${index}`,
liveToken: 1,
currentPersonality: arrayToPersonality(row[10]),
x: u(row[2], 1), y: u(row[3], 1), vx: 0, vy: 0,
age, birthTime: (worldRef?.time || 0) - age, generation: Math.max(1, u(row[5], 1, 1)),
hunger: u(row[6], 1), loneliness: Number(needs.social) || 0, energy: u(row[7], 1), circadianSleepPressure: u(row[8], 1),
needs, state: enumValue(STATE_IDS, row[17]?.[0], "idle"),
isZunchiSlave: Boolean((Number(row[0]) || 0) & (1 << 5)),
};
const t = new TarinaiClass(worldRef, opts);
t.familyKey = birthSeed;
t.birthSeed = birthSeed;
t.id = `st${index}`;
t.liveToken = 1;
t.needRaw = { ...needs };
t.needDisplay = { ...needs };
t.previousNeeds = { ...needs };
t.needRawBase = { ...needs };
flagApply(t, Number(row[0]) || 0);
t.__savedParentIdx = Array.isArray(row[11]) ? row[11] : [];
t.__savedRelationships = Array.isArray(row[12]) ? row[12] : [];
const timers = Array.isArray(row[13]) ? row[13] : [];
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.itemEffectTimers = { ...(t.itemEffectTimers || {}), laxative: u(timers[6], 10), ammo: u(timers[7], 10) };
t.mercuryLifeMultiplier = u(timers[8], 100, t.mercuryLifeMultiplier || 1);
const modes = Array.isArray(row[14]) ? row[14] : [];
t.powerItemMode = enumValue(POWER_MODE_IDS, modes[0], "");
t.sizeItemMode = enumValue(SIZE_MODE_IDS, modes[1], "");
t.lifeItemMode = enumValue(LIFE_MODE_IDS, modes[2], "");
t.__savedPinIdx = row[15]; t.__savedNestIdx = row[16]; t.__savedStateInfo = row[17];
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
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 (item?.isStructure) {
const owner = tarinaiIndex.get(item.ownerId) ?? -1;
const carried = item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1;
const consumed = item.singleUseConsumedById ? (tarinaiIndex.get(item.singleUseConsumedById) ?? -1) : -1;
return [q(item.hp, 10), q(item.maxHp, 10), owner, carried, item.singleUsePending ? 1 : 0, consumed];
}
if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0];
if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)];
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),
if (type === "rotator") return [
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
q(item.rotatorSpeed, 1000),
q(item.rotatorThickness || 12, 10),
(Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)]),
item.rotatorPowered === false ? 0 : 1,
q(item.rotatorAngularVelocity || 0, 1000)
];
if (type === "reciprocator") return [
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
q(Number.isFinite(Number(item.reciprocatorAxisAngle)) ? item.reciprocatorAxisAngle : item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
item.reciprocatorPowered === false ? 0 : 1,
q(item.reciprocatorSpeed || 92, 10),
q(item.reciprocatorTravel || 150, 10),
q(item.reciprocatorPhase || 0, 1000),
q(item.reciprocatorVelocity || 0, 10),
q(item.reciprocatorAnchorX || item.x || 0, 1),
q(item.reciprocatorAnchorY || item.y || 0, 1),
q(item.reciprocatorDirection || 1, 1),
q(item.rotatorThickness || 12, 10),
(Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)])
];
}
if (type === "rope" || type === "rod") {
const cloneEndpoint = ep => ep ? { kind: ep.kind || "item", id: ep.id || "", type: ep.type || "", label: ep.label || "", localX: q(ep.localX || 0, 1), localY: q(ep.localY || 0, 1), center: ep.center ? 1 : 0, x: q(ep.x || 0, 1), y: q(ep.y || 0, 1), attachT: Number.isFinite(Number(ep.attachT)) ? q(ep.attachT, 1000) : null } : null;
return [cloneEndpoint(item.linkA), cloneEndpoint(item.linkB), q(item.linkLength || item.r * 2 || 80, 1), q(item.linkMidX || item.x || 0, 1), q(item.linkMidY || item.y || 0, 1), q(item.linkMidVX || 0, 10), q(item.linkMidVY || 0, 10)];
}
if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)];
if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)];
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
if (type === "ant_corpse") return [q(item.decayTimer, 10)];
if (type === "firecracker") return [q(item.fuseTimer, 10)];
if (typeof global.isPinType === "function" && global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) return [q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0];
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
return [];
}
function compactItem(item, index, tarinaiIndex = new Map()) {
const typeId = enumIndex(ITEM_TYPE_IDS, item?.type || "", -1);
if (typeId < 0 || item?.type === "trace") return null;
return [typeId, q(item?.x, 1), q(item?.y, 1), q(item?.amount ?? item?.hp, 10), 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 || "";
if (item.isStructure) {
item.hp = u(extra[0], 10, item.hp || 1);
item.maxHp = u(extra[1], 10, item.maxHp || item.hp || 1);
item.amount = Math.max(0.001, item.hp || item.amount || 1);
item.ownerId = tarinaiList[extra[2]]?.id || "";
item.carriedById = tarinaiList[extra[3]]?.id || "";
item.singleUsePending = !!extra[4];
item.singleUseConsumedById = tarinaiList[extra[5]]?.id || "";
if (type === "plushie") {
item.plushieSpriteId = "plushie_bear";
item.onHead = Boolean(item.carriedById);
}
const def = global.StructureRegistry?.get?.(type);
if (def) {
item.label = def.label;
@ -321,111 +388,108 @@
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;
item.growth = u(extra[0], 100, item.growth || 0); item.health = u(extra[1], 100, item.health || 1); item.fertilityBoost = u(extra[2], 100, 0); item.manualGrass = !!extra[3]; item.placedByPlayer = !!extra[3];
global.TarinaiGrass?.normalize?.(item);
} else if (type === "bed") {
item.comfort = u(extra[0], 1000); item.wear = u(extra[1], 1000);
} else if (type === "zunchi") {
item.stage = enumValue(STAGE_IDS, extra[0], "fresh"); item.freshness = u(extra[1], 100, item.freshness || 1); item.fertility = u(extra[2], 100, item.fertility || 1);
} else if (type === "signboard") {
item.text = String(extra[0] || "");
} else if (type === "rotator") {
const e = Array.isArray(extra) ? extra : [];
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback);
item.rotatorSpeed = u(e[1], 1000, item.rotatorSpeed || 0);
item.rotatorThickness = Math.max(4, Math.min(34, u(e[2], 10, item.rotatorThickness || 12)));
item.rotatorSegments = (Array.isArray(e[3]) ? e[3] : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4);
item.rotatorPowered = e.length >= 5 ? !!e[4] : item.rotatorPowered !== false;
item.rotatorAngularVelocity = u(e[5], 1000, item.rotatorAngularVelocity || 0);
if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]];
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8;
item.r = Math.max(item.r || 64, Math.min(460, extent));
} else if (type === "reciprocator") {
const e = Array.isArray(extra) ? extra : [];
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback);
item.reciprocatorAxisAngle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[1], 1000, item.angle), item.angle) : u(e[1], 1000, item.angle);
item.reciprocatorPowered = e.length >= 3 ? !!e[2] : item.reciprocatorPowered !== false;
item.reciprocatorSpeed = Math.max(0, u(e[3], 10, item.reciprocatorSpeed || 92));
item.reciprocatorTravel = Math.max(24, u(e[4], 10, item.reciprocatorTravel || 150));
item.reciprocatorPhase = clamp(u(e[5], 1000, item.reciprocatorPhase || 0), -1, 1);
item.reciprocatorVelocity = u(e[6], 10, item.reciprocatorVelocity || 0);
item.reciprocatorAnchorX = u(e[7], 1, item.reciprocatorAnchorX || item.x || 0);
item.reciprocatorAnchorY = u(e[8], 1, item.reciprocatorAnchorY || item.y || 0);
item.reciprocatorDirection = u(e[9], 1, item.reciprocatorDirection || 1) || 1;
item.rotatorThickness = Math.max(4, Math.min(34, u(e[10], 10, item.rotatorThickness || 12)));
item.rotatorSegments = (Array.isArray(e[11]) ? e[11] : (Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]])).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4);
if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]];
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8;
item.r = Math.max(item.r || 64, Math.min(460, extent));
} else if (type === "rope" || type === "rod") {
const e = Array.isArray(extra) ? extra : [];
const normalizeEndpoint = ep => ep ? { kind: ep.kind || "item", id: ep.id || "", type: ep.type || "", label: ep.label || "", localX: u(ep.localX, 1, 0), localY: u(ep.localY, 1, 0), center: !!ep.center, x: u(ep.x, 1, item.x || 0), y: u(ep.y, 1, item.y || 0), attachT: ep.attachT == null ? null : clamp(u(ep.attachT, 1000, 0.5), 0, 1) } : null;
item.linkA = normalizeEndpoint(e[0]);
item.linkB = normalizeEndpoint(e[1]);
item.linkLength = Math.max(24, u(e[2], 1, item.linkLength || 80));
item.linkMidX = u(e[3], 1, item.linkMidX || item.x || 0);
item.linkMidY = u(e[4], 1, item.linkMidY || item.y || 0);
item.linkMidVX = u(e[5], 10, item.linkMidVX || 0);
item.linkMidVY = u(e[6], 10, item.linkMidVY || 0);
item.r = Math.max(18, item.linkLength * 0.5);
} else if (type === "duplicator") {
item.storedFoodType = extra[0] || ""; item.storedFoodLabel = extra[1] || "";
item.roles.food = Boolean(item.storedFoodType);
item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.foodLabel ? global.foodLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) 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";
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10);
} 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);
item.antCount = u(extra[0], 1); item.antWorkers = []; item.queenSpawnAt = u(extra[1], 10);
} else if (type === "ant_corpse") {
item.workerSprite = extra[0] || "ant_worker"; item.decayTimer = u(extra[1], 10);
item.workerSprite = "ant_worker"; item.decayTimer = u(extra[0], 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;
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
} else if (typeof global.isPinType === "function" && global.isPinType(type)) {
item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10);
} else if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) {
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback);
if (type === "gate_fence") item.gateOpen = !!extra[1];
} else if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) {
item.foodServingsRemaining = u(extra[0], 10, item.amount || 1); item.foodServingsMax = u(extra[1], 10, item.foodServingsRemaining || item.amount || 1); 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 tarinaiLive = (worldRef.tarinai || []).filter(t => t && !t.dead);
const itemsLive = (worldRef.items || []).filter(Boolean).filter(it => it && !it.dead);
const tarinaiLive = (worldRef.tarinai || [])
.filter(t => t && !t.dead)
.slice()
.sort((a, b) => mortonKeyXY(a.x, a.y) - mortonKeyXY(b.x, b.y) || String(a.id || "").localeCompare(String(b.id || "")));
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);
});
tarinaiLive.forEach((t, i) => { if (t.id) tarinaiIndex.set(t.id, i); if (t.familyKey) familyIndex.set(t.familyKey, i); });
const itemRecords = [];
for (const it of (worldRef.items || [])) {
if (!it || it.dead || it.type === "trace" || enumIndex(ITEM_TYPE_IDS, it.type || "", -1) < 0) continue;
const row = compactItem(it, itemRecords.length, tarinaiIndex);
if (row) itemRecords.push({ item: it, row });
}
itemRecords.sort((a, b) => (a.row[0] || 0) - (b.row[0] || 0) || mortonKeyXY(a.row[1], a.row[2]) - mortonKeyXY(b.row[1], b.row[2]) || String(a.item?.id || "").localeCompare(String(b.item?.id || "")));
const itemIndex = new Map();
itemsLive.forEach((it, i) => { if (it.id) itemIndex.set(it.id, i); });
const now = Date.now();
itemRecords.forEach((rec, i) => { if (rec.item?.id) itemIndex.set(rec.item.id, i); });
const itemRows = itemRecords.map(rec => rec.row);
return {
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"],
a: "tj1",
c: Date.now(),
m: [worldRef.day || 1, tarinaiLive.length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemRows.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,
i: itemRows,
};
}
function expandTarinai(row, index, worldRef) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
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" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid save data");
if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION || snapshot.a !== "tj1") throw new Error("invalid light save data");
if (!worldRef) throw new Error("world is not ready");
const fieldType = snapshot.w?.[0] || "garden";
if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType);
const fieldType = enumValue(FIELD_IDS, snapshot.w?.[0], "garden");
if (typeof global.applyFieldLayout === "function") global.applyFieldLayout(fieldType);
if (typeof worldRef.reset === "function") worldRef.reset(0, fieldType);
worldRef.tarinai = [];
worldRef.items = [];
worldRef.ants = [];
@ -443,57 +507,65 @@
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()) {
for (const t of worldRef.tarinai) {
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;
t.children = [];
}
for (const child of worldRef.tarinai) {
for (const parentIdx of child.__savedParentIdx || []) {
const parent = worldRef.tarinai[parentIdx];
if (parent?.familyKey && child.familyKey && !parent.children.includes(child.familyKey)) parent.children.push(child.familyKey);
}
}
const liveByFamilyKey = new Map((worldRef.tarinai || []).map(t => [t.familyKey, t]).filter(([id]) => id));
for (const t of worldRef.tarinai) {
t.parentNames = (t.parents || []).map(id => liveByFamilyKey.get(id)?.name || "").filter(Boolean);
delete t.__savedParentIdx;
}
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
for (let idx = 0; idx < itemRows.length; idx++) {
const row = itemRows[idx] || [];
const type = row[0] || "";
const type = enumValue(ITEM_TYPE_IDS, 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}`;
const extra = Array.isArray(row[4]) ? row[4] : [];
const owner = global.StructureRegistry?.get?.(type) ? worldRef.tarinai[extra[2]] || null : null;
const item = global.StructureRegistry?.get?.(type)
? global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef)
: new Item(type, u(row[1], 1), u(row[2], 1));
item.id = `li${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);
item.amount = u(row[3], 10, item.amount || 1);
applyItemExtra(item, extra, worldRef.tarinai);
worldRef.items.push(item);
}
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;
const stateInfo = Array.isArray(t.__savedStateInfo) ? t.__savedStateInfo : [];
const state = enumValue(STATE_IDS, stateInfo[0], "idle");
if (state === "sleep") {
const sleepTarget = worldRef.items[stateInfo[1]] || null;
t.target = sleepTarget;
t.sleeping = true;
t.state = "sleep";
t.sleepSession = { startedAt: worldRef.time || 0, minDuration: 6, targetEnergy: 86, maxDuration: 36, targetId: sleepTarget?.id || null, consumesGrassBedOnWake: sleepTarget?.type === "grass_bed" };
if (sleepTarget?.type === "grass_bed") {
t.pendingGrassBedWakeId = sleepTarget.id;
sleepTarget.singleUsePending = true;
sleepTarget.singleUseConsumedById = t.id;
}
} else {
t.state = "idle";
t.sleeping = false;
t.target = null;
t.sleepSession = null;
}
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships;
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships; delete t.__savedStateInfo;
}
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);
}
worldRef.liveIdNext = Math.max(1, worldRef.tarinai.length + 1);
worldRef.liveIdSerial = Math.max(1, worldRef.tarinai.length + 1);
worldRef.birthSerial = Math.max(Number(worldRef.birthSerial) || 0, ...worldRef.tarinai.map(t => Number(t.birthSerial) || 0));
worldRef.family = {};
worldRef.familyVersion = (worldRef.familyVersion || 0) + 1;
for (const t of worldRef.tarinai) worldRef.recordFamily?.(t);
@ -504,16 +576,14 @@
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();
worldRef.updateItemCounts?.();
worldRef.enforceGrassLimit?.("load-grass-limit");
worldRef.compactItems?.();
worldRef.updateItemCounts?.();
worldRef.updateEffectCounts?.();
worldRef.rebuildSpatial?.(true);
worldRef.markTerrainDirty?.("load");
worldRef.clampCamera?.();
worldRef.emit?.("snapshot:restored", { snapshotVersion: SNAPSHOT_VERSION });
return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION };
}

View file

@ -75,7 +75,8 @@
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);
item.hp = 0;
item.removedReason = String(opts.reason || "delete-tool");
markWorldAfterStructureChange(worldRef, opts.reason || "delete-tool");
return true;
}

View file

@ -30,8 +30,7 @@
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.plushieSpriteId = "plushie_bear";
}
this.dead = false;
this.world = world || null;
@ -50,16 +49,30 @@
if (this.type === "plushie") {
const owner = this.owner(worldRef);
if (!owner) {
this.damage(1, worldRef, null);
this.dead = true;
this.hp = 0;
this.amount = 0;
this.carriedById = "";
this.onHead = false;
worldRef && (worldRef.drawListDirty = true);
worldRef?.markItemBucketsDirty?.("plushie-owner-gone");
worldRef?.markSpatialDirty?.("plushie-owner-gone");
return;
}
this.ownerId = owner.id || this.ownerId;
this.carriedById = owner.id || this.ownerId || this.carriedById;
this.onHead = true;
this.x = owner.x;
this.y = owner.y - Math.max(15, (owner.radius || 24) * 0.70);
this.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8);
}
if (this.type === "grass_bed") {
if (!Number.isFinite(this.hp) || this.hp <= 0) {
this.hp = Math.max(1, Number(this.maxHp || 100) || 100);
this.amount = this.hp;
}
const owner = this.owner(worldRef);
if (!owner) {
if (!owner && !this.singleUsePending) {
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);
@ -69,7 +82,7 @@
this.unownedDecayTimer = 0;
}
}
this.amount = Math.max(0.001, this.hp);
this.amount = Math.max(0.001, Number(this.hp || this.amount || 1) || 1);
}
use(user, worldRef = this.world, options = {}) {
@ -122,14 +135,15 @@
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 spriteId = this.plushieSpriteId || "smile";
const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, "smile") : (global.images?.get?.(spriteId) || global.images?.get?.("smile"));
const spriteId = "plushie_bear";
this.plushieSpriteId = spriteId;
const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, spriteId) : (global.images?.get?.(spriteId) || null);
ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05);
if (img) {
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);
const w = this.r * 3.35;
const h = w * (metrics?.ratio || 1.05);
ctx.drawImage(img, -w * 0.50, -h * 0.62, w, h);
} else {
ctx.fillStyle = "#f7dfc0";
ctx.strokeStyle = "rgba(86,62,48,0.70)";
@ -200,6 +214,11 @@
structure.ownerId = user.id;
structure.ownerName = user.name || "";
structure.attachment = Math.max(structure.attachment || 0, 24);
structure.unownedDecayTimer = 0;
structure.dead = false;
structure.maxHp = Math.max(1, Number(structure.maxHp || 100) || 100);
structure.hp = Math.max(1, Number(structure.hp || 0) || structure.maxHp);
structure.amount = Math.max(0.001, structure.hp);
worldRef?.markItemBucketsDirty?.("grass-bed-owner-adopted");
worldRef?.markTerrainDirty?.("grass-bed-owner-adopted");
worldRef?.emit?.("structure:ownerAdopted", { structure, owner: user });
@ -210,6 +229,11 @@
if (options?.purpose === "sleep" || options?.sleep === true) {
// かんたんベッドは「寝た瞬間」ではなく「起きた時」に消える。
// 睡眠中は寝場所として残すことで、見た目とホバー情報が自然になる。
structure.dead = false;
structure.maxHp = Math.max(1, Number(structure.maxHp || 100) || 100);
structure.hp = Math.max(1, Number(structure.hp || 0) || structure.maxHp);
structure.amount = Math.max(0.001, structure.hp);
structure.unownedDecayTimer = 0;
structure.singleUsePending = true;
structure.singleUseConsumedById = user?.id || "";
structure.singleUseConsumedAt = worldRef?.time || 0;
@ -236,13 +260,19 @@
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 new TarinaiStructure(this, owner, x, y, world); },
onUse(user, structure) {
onUse(user, structure, worldRef) {
if (user?.id === structure.ownerId) structure.attachment = Math.min(100, (structure.attachment || 0) + 1);
structure.usedCount = (structure.usedCount || 0) + 1;
if (user?.id === structure.ownerId) {
structure.carriedById = user.id;
structure.onHead = true;
structure.x = user.x;
structure.y = user.y - Math.max(28, (user.radius || 24) * 1.28);
user.needShock = user.needShock || {};
user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - 24);
user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - 4);
worldRef?.markSpatialDirty?.("plushie-use-head");
worldRef && (worldRef.drawListDirty = true);
}
return true;
},

41
js/system_order.js Normal file
View file

@ -0,0 +1,41 @@
"use strict";
// Layer: simulation/system-order
// Names the update order in one place. Prefer concrete simulation systems,
// with the phase facade kept as a stable named entry point.
(function (global) {
function phase(name) {
return global.TarinaiSimulationSystems?.phases?.[name] || global.TarinaiWorldUpdatePhases?.phases?.[name] || null;
}
const SYSTEM_ORDER = Object.freeze([
{ id: "clock", run(worldRef, ctx) { phase("updateClock")?.(worldRef, ctx.dt); } },
{ id: "frame_signals", run(worldRef, ctx) { phase("updateFrameSignals")?.(worldRef, ctx.dt); } },
{ id: "environment", run(worldRef, ctx) { phase("updateEnvironment")?.(worldRef, ctx.dt); } },
{ id: "spatial_prepare", run(worldRef) { phase("prepareSpatialFrame")?.(worldRef); } },
{ id: "items_and_ants", run(worldRef, ctx) { ctx.mobile = phase("updateItemsAndAnts")?.(worldRef, ctx.dt) || null; } },
{ id: "effects", run(worldRef, ctx) { phase("updateEffects")?.(worldRef, ctx.dt); } },
{ id: "creatures", run(worldRef, ctx) { phase("updateCreatures")?.(worldRef, ctx.dt); } },
{ id: "maintenance", run(worldRef, ctx) { phase("runMaintenance")?.(worldRef, ctx.dt, ctx.mobile?.itemCountBefore ?? (worldRef.items || []).length); } },
{ id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } },
{ id: "ambient_tarinai", run(worldRef, ctx) { phase("spawnAmbientTarinai")?.(worldRef, ctx.dt); } },
{ id: "ambient_grass", run(worldRef, ctx) { phase("spawnAmbientGrass")?.(worldRef, ctx.dt); } },
{ id: "rain_water", run(worldRef, ctx) { phase("spawnRainWater")?.(worldRef, ctx.dt); } },
{ id: "diagnostics", run(worldRef) { phase("finalizeDiagnostics")?.(worldRef); } },
]);
function update(worldRef, dt) {
const normalize = phase("normalizeDelta");
if (!normalize) return global.TarinaiWorldUpdatePhases?.update?.(worldRef, dt);
const frameDt = normalize(worldRef, dt);
if (frameDt === null) return;
const ctx = { dt: frameDt, mobile: null };
for (const system of SYSTEM_ORDER) system.run(worldRef, ctx);
}
global.TarinaiSystemOrder = Object.freeze({
SYSTEM_ORDER,
names: Object.freeze(SYSTEM_ORDER.map(system => system.id)),
update,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -15,21 +15,26 @@ function tarinaiRoundRectPath(ctx, x, y, w, h, r) {
}
class Tarinai { constructor(world, opts = {}) {
if (world && !opts.birthSeed && !opts.familyKey && !opts.id && globalThis.TarinaiSeedFactory?.prepareAddOptions) {
opts = globalThis.TarinaiSeedFactory.prepareAddOptions(world, opts) || opts;
}
this.world = world;
this.familyKey = opts.familyKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
this.birthSeed = opts.birthSeed || opts.familyKey || opts.id || (globalThis.TarinaiSeedFactory?.childSeed?.(world?.worldSeed || "", Number(world?.birthSerial || 0) + 1, []) || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`));
this.birthSerial = Number.isFinite(opts.birthSerial) ? Number(opts.birthSerial) : (globalThis.TarinaiSeedFactory?.serialFromBirthSeed?.(this.birthSeed) || 0);
const birthProfile = globalThis.TarinaiSeedFactory?.birthProfile?.(this.birthSeed, opts) || {};
this.familyKey = opts.familyKey || this.birthSeed;
this.id = opts.id || "";
this.liveToken = opts.liveToken || 0;
this.name = opts.name || makeName(world);
this.name = opts.name || birthProfile.name || makeName(world);
this.personality = "";
this.type = opts.type || baseSpriteId();
this.type = opts.type || birthProfile.type || baseSpriteId();
const typeMeta = SPRITES.find(s => s.id === this.type);
if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId();
this.birthPersonality = normalizePersonality(opts.birthPersonality);
if (!opts.birthPersonality) this.birthPersonality = randomBirthPersonality(this.familyKey);
if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = birthProfile.type || baseSpriteId();
this.birthPersonality = normalizePersonality(opts.birthPersonality || birthProfile.birthPersonality);
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);
this.genetics = normalizeGenetics(opts.genetics || birthProfile.genetics, this.birthSeed);
this.trait = makeTrait(this.type);
this.trait.speed *= this.genetics.speedMul || 1;
this.birthTime = opts.birthTime ?? this.world.time;
@ -46,12 +51,12 @@ class Tarinai { constructor(world, opts = {}) {
this.vy = opts.vy ?? rand(-12, 12);
this.impulseVx = opts.impulseVx ?? 0;
this.impulseVy = opts.impulseVy ?? 0;
this.adultScale = Number.isFinite(opts.adultScale) ? opts.adultScale : (Number.isFinite(opts.baseAdultScale) ? opts.baseAdultScale : (Number.isFinite(opts.scale) && growthAtConstruct >= 1 ? opts.scale : adultScaleFromGenetics(this.familyKey, this.genetics)));
this.adultScale = Number.isFinite(opts.adultScale) ? opts.adultScale : (Number.isFinite(opts.baseAdultScale) ? opts.baseAdultScale : (Number.isFinite(opts.scale) && growthAtConstruct >= 1 ? opts.scale : (Number.isFinite(birthProfile.adultScale) ? birthProfile.adultScale : adultScaleFromGenetics(this.birthSeed, this.genetics))));
this.scale = Number.isFinite(opts.scale) ? opts.scale : (generationOpt > 1 ? scaleForGrowth(this.adultScale, growthAtConstruct) : this.adultScale);
this.radius = 56 * this.scale;
this.age = opts.age ?? rand(0, 20);
this.lifeSpan = opts.lifeSpan ?? rand(1400, 2200) * (this.genetics.lifeSpanMul || 1);
if (this.lifeSpan < 900) this.lifeSpan = rand(1200, 1800) * (this.genetics.lifeSpanMul || 1);
this.lifeSpan = opts.lifeSpan ?? (Number.isFinite(birthProfile.lifeSpan) ? birthProfile.lifeSpan : rand(1400, 2200) * (this.genetics.lifeSpanMul || 1));
if (this.lifeSpan < 900) this.lifeSpan = Number.isFinite(birthProfile.lifeSpan) ? birthProfile.lifeSpan : rand(1200, 1800) * (this.genetics.lifeSpanMul || 1);
this.generation = generationOpt;
this.hasPaired = opts.hasPaired ?? false;
this.hunger = opts.hunger ?? rand(16, 45);
@ -113,7 +118,7 @@ class Tarinai { constructor(world, opts = {}) {
this.wasSleepingLastTick = false;
this.blink = rand(0, 10);
this.lastSocial = 0;
this.goodMode = opts.goodMode ?? stableChoice(this.familyKey, "good-mode", displayNormalSprites()) ?? "smile";
this.goodMode = opts.goodMode ?? birthProfile.goodMode ?? stableChoice(this.birthSeed || this.familyKey, "good-mode", displayNormalSprites()) ?? "smile";
this.facing = opts.facing ?? (stableUnit(this.familyKey, "facing") < 0.5 ? -1 : 1);
this.visualFacing = opts.visualFacing ?? this.facing;
this.facingLockUntil = opts.facingLockUntil ?? 0;

View file

@ -152,11 +152,11 @@ function createRestActionSpec() {
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);
t.energy = clamp((t.energy || 0) + 1.6, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100));
return true;
},
tick(t) {
t.energy = clamp((t.energy || 0) + 0.8, 0, 100);
t.energy = clamp((t.energy || 0) + 0.8, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100));
return true;
},
};
@ -421,8 +421,8 @@ function createBirthRitualActionSpec() {
id: "birth_ritual",
need: "social",
subNeed: "mate",
label: "繁殖の前ぶれをしている",
phrase: "繁殖の前ぶれをしている",
label: "へこへこしている",
phrase: "へこへこしている",
weight: 0,
state: "birth_ritual",
timerBacked: true,

View file

@ -0,0 +1,21 @@
"use strict";
// Boundary for ordinary need/action planning. The current implementation still
// delegates to the extracted planner body, and callers no longer depend on the
// Tarinai prototype owning that body.
(function (global) {
function updateOne(tarinai, dt = 0) {
if (!tarinai || tarinai.dead) return false;
return Boolean(global.TarinaiNeedPlannerCoreSystem?.updateOne?.(tarinai, dt));
}
function updateWithThis(dt = 0) {
return updateOne(this, dt);
}
global.TarinaiActionPlannerSystem = Object.freeze({
updateOne,
updateWithThis,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -38,14 +38,14 @@ function actionTextForReason(action = null, tarinai = null) {
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 === "birth_ritual") return target?.name ? `${name}とへこへこしている` : "へこへこしている";
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]+$/, "");
return String(action?.label || action?.phrase || behavior?.label || "ぼーっとしている").replace(/[\u3002\uff01\uff1f]+$/, "");
}
function forcedCausePhrase(source = "", fallback = "") {

View file

@ -101,11 +101,19 @@ function continueBuildPlan(t, world, dt) {
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 spot = plan.type === "plushie"
? { x: t.x, y: t.y - Math.max(28, (t.radius || 24) * 1.28) }
: 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");
}
if (plan.type === "plushie") {
structure.carriedById = t.id;
structure.onHead = true;
structure.x = t.x;
structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28);
}
world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure);
world.drawListDirty = true;
structure.use?.(t, world);

View file

@ -47,6 +47,19 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) {
return true;
}
if ((role === "drink" || role === "medicine") && isDuplicatorFoodSource(target) && foodType === "water") {
const text = "複製機の水滴を飲んでいる";
t.setActionState?.("seek_water", { target, reason: text, sleeping: false });
t.thought = text;
t.applyWaterEffect?.(0.55, "water");
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));

View file

@ -0,0 +1,86 @@
"use strict";
// Non-eating contact effects for water, sleep furniture, toys, stains, and traces.
(function (global) {
function updateCandidate(tarinai, ctx, it, dt = 0) {
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
const { foodActionActive = false } = ctx;
if (!it || it.dead) return false;
const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it);
const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0);
if (d > contactReach) return false;
if (it.type === "water" || it.type === "water_bowl") {
tarinai.thought = it.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b";
tarinai.target = it;
tarinai.applyWaterEffect(dt, it.type);
if ((tarinai.world?.time || 0) > (tarinai.nextDrinkSound || -999)) {
audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 });
tarinai.nextDrinkSound = (tarinai.world?.time || 0) + 0.72;
}
if (it.type === "water") it.amount -= dt * 2.4;
if (tarinai.type === "teary" || tarinai.type === "cry") tarinai.affection += dt * 0.18;
}
if (it.type === "stone") {
if (tarinai.state === "idle" && tarinai.stress > 48) tarinai.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b";
applyNeedRelief(tarinai, { safety: -dt * 4, fulfill: -dt * 2 });
tarinai.energy += dt * 0.18;
}
if (tarinai.isSleepFurniture(it)) {
if (tarinai.energy < 74 + 6 * tarinai.personalityProfile().sleep) {
const isEasyBed = it.type === "grass_bed";
const occ = tarinai.world.bedOccupancy(it);
const comfort = tarinai.world.bedComfort(it);
it.use?.(tarinai, tarinai.world, isEasyBed ? { purpose: "sleep" } : undefined);
tarinai.startSleeping?.(it, it.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 (tarinai.world.time > tarinai.nextSleepBubbleAt) {
tarinai.nextSleepBubbleAt = tarinai.world.time + 4.5;
const crowded = occ > 5;
tarinai.world.spawnBubble(tarinai.x, tarinai.y - tarinai.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
}
tarinai.energy += dt * (1.9 + comfort * 1.4);
if (occ > 5) applyNeedShock(tarinai, { safety: dt * 3 });
it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1);
}
}
if (it.type === "ball") {
const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1);
if (tarinai.state === "play_ball" || tarinai.target === it || tarinai.shouldApplyPersonalityBehavior("openness", 1)) {
tarinai.goodMode = "smile";
applyNeedRelief(tarinai, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) });
tarinai.loneliness = clamp(tarinai.loneliness - dt * (0.4 + touch), 0, 110);
if (tarinai.state === "play_ball") tarinai.foodReactTimer = Math.max(tarinai.foodReactTimer, 0.10);
}
}
if (it.type === "splat") {
const touch = clamp(1 - d / (tarinai.radius + it.r + 16), 0, 1);
if (touch > 0) {
applyNeedShock(tarinai, { safety: touch * dt * 5, health: touch * dt * 3 });
if (touch > 0.06) tarinai.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses");
}
}
if (it.type === "trace") {
tarinai.loneliness -= dt * 0.25;
applyNeedShock(tarinai, { social: -dt * 1, safety: dt * 0.8 });
}
return true;
}
function updateOne(tarinai, ctx, dt = 0) {
if (!tarinai || tarinai.dead || !ctx) return false;
for (const it of ctx.foodCandidates || []) updateCandidate(tarinai, ctx, it, dt);
return true;
}
global.TarinaiContactItemSystem = Object.freeze({
updateCandidate,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,40 @@
"use strict";
// Layer: entity-runtime/tarinai/update-system
// Owns pointer proximity care and cursor-heart effects.
(function (global) {
function updateBody(dt) {
const pointer = this.world?.pointer;
if (!pointer?.inside || !this.isAwakeCursorFriendly()) return;
const d = distXY(this.x, this.y, pointer.x, pointer.y);
const touch = this.radius * 1.35;
if (d > touch) return;
const p = clamp(1 - d / touch, 0, 1);
const relief = dt * (1.35 + 2.65 * p);
applyNeedRelief(this, { safety: -relief * 1.2, fulfill: -relief * 1.6 });
this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted.");
this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5);
this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75);
this.goodMode = "smile";
if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) {
this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10);
for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) {
this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), {
vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)"
}));
}
}
}
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead) return false;
updateBody.call(tarinai, dt);
return true;
}
global.TarinaiCursorCareSystem = Object.freeze({
updateBody,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -62,7 +62,7 @@
if (this.zunchiDisease) {
this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25);
if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * (5 + 4 * this.zunchiDiseaseSeverity) });
this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100);
this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010));
this.emitZunchiDiseaseEffect(dt);
if (this.zunchiDiseaseSeverity <= 0.04) {
@ -298,7 +298,7 @@
applyWaterEffect(dt, source = "water") {
const power = Math.max(0, dt || 0);
if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -power * 12, safety: -power * 6 });
this.energy = clamp(this.energy + power * 0.5, 0, 100);
this.energy = clamp(this.energy + power * 0.5, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
this.hunger = clamp(this.hunger - power * 0.2, 0, 115);
this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100);
if (this.zunchiDisease) {

View file

@ -0,0 +1,194 @@
"use strict";
// Food, medicine, grass, ant-corpse, and zunchi eating branch for Tarinai item interaction.
(function (global) {
function updateCandidate(tarinai, ctx, it, dt = 0) {
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
const { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx;
const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it);
const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0);
if (d > contactReach) return false;
const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type;
const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi";
if (((foodType === "sweet" && canSweetBite) || (foodType === "food" && (canBite || foodActionActive)) || duplicatorConsumable) && tarinai.foodItemHasServingLeft(it)) {
const eating = tarinai.consumeFoodServing(it);
if (eating > 0) {
const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type);
tarinai.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`);
tarinai.vx *= Math.pow(0.25, dt * 60);
tarinai.vy *= Math.pow(0.25, dt * 60);
const hungerRelief = tarinai.foodHungerReliefFor(foodType || it.type, eating, foodType === "sweet" ? 0.75 : 0.95);
tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(hungerRelief) : (tarinai.hunger -= hungerRelief);
tarinai.recoverHealth(eating * (foodType === "sweet" ? 0.28 : 0.46));
tarinai.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012;
if (foodType === "sweet") {
applyNeedRelief(tarinai, { food: -eating * 0.4, fulfill: -eating * 0.2 });
tarinai.fearTimer = Math.max(0, tarinai.fearTimer - eating * 0.035);
if (tarinai.explosionDisease) tarinai.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
if (tarinai.fightDisease) tarinai.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f");
}
tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0);
tarinai.eatCooldown = rand(0.96, 1.12);
tarinai.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)");
tarinai.foodReactTimer = Math.max(tarinai.foodReactTimer, 0.16);
if (Math.random() < dt * 2.2) {
const mouth = tarinai.mouthPosition(it);
tarinai.world.spawnEatEffect(mouth.x, mouth.y, foodType === "sweet" ? "#78b957" : "#f1dfb7");
}
tarinai.lastMealColor = foodType === "sweet" ? "#78b957" : "#f1dfb7";
const fromDuplicator = isDuplicatorFoodSource(it);
tarinai.makePoop(normalPoopUnitsFor(foodType || it.type, eating));
if (fromDuplicator) tarinai.mealCooldownUntil = Math.max(tarinai.mealCooldownUntil || 0, tarinai.world.time + 1.2);
if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.42; }
if (tarinai.world.time - tarinai.lastLog > 9 && Math.random() < 0.014) {
tarinai.world.log(`${tarinai.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [tarinai] });
tarinai.lastLog = tarinai.world.time;
}
return true;
}
}
if (canSweetBite && (foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && tarinai.foodItemHasServingLeft(it)) {
const eating = tarinai.consumeFoodServing(it);
if (eating <= 0) return false;
const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type);
tarinai.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`);
tarinai.vx *= Math.pow(0.25, dt * 60);
tarinai.vy *= Math.pow(0.25, dt * 60);
const hungerRelief = tarinai.foodHungerReliefFor(foodType || it.type, eating, 0.82);
tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(hungerRelief) : (tarinai.hunger -= hungerRelief);
tarinai.recoverHealth(eating * 0.24);
tarinai.affection += eating * 0.04;
const mealColors = {
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",
};
const mealBubbles = {
love_mochi: ["\u2661", 2.4, "rgba(190,82,132,0.78)"],
fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"],
sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"],
};
if (tarinai.applyParamItemEffect) tarinai.applyParamItemEffect(foodType || it.type, it, {
source: "eating",
nutrition: eating,
silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug",
});
if (mealBubbles[foodType]) tarinai.bubble(...mealBubbles[foodType]);
tarinai.lastMealColor = mealColors[foodType] || "#41aa66";
tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0);
tarinai.eatCooldown = rand(0.96, 1.12);
tarinai.foodReactTimer = Math.max(tarinai.foodReactTimer, 0.16);
if (Math.random() < dt * 2.2) {
const mouth = tarinai.mouthPosition(it);
tarinai.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (tarinai.lastMealColor || "#41aa66"))));
}
const fromDuplicator = isDuplicatorFoodSource(it);
tarinai.makePoop(normalPoopUnitsFor(foodType || it.type, eating));
if (fromDuplicator) tarinai.mealCooldownUntil = Math.max(tarinai.mealCooldownUntil || 0, tarinai.world.time + 1.2);
if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.42; }
if (tarinai.world.time - tarinai.lastLog > 9 && Math.random() < 0.02) {
tarinai.world.log(`${tarinai.name}\u306f${toolLabel(foodType || it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [tarinai] });
tarinai.lastLog = tarinai.world.time;
}
return true;
}
if (canBite && it.type === "grass") {
const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1));
const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale);
tarinai.beginEating(it, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b");
tarinai.grassEatTimer = Math.max(tarinai.grassEatTimer, 1.0);
tarinai.vx *= Math.pow(0.20, dt * 60);
tarinai.vy *= Math.pow(0.20, dt * 60);
it.amount -= bite;
it.eatenAmount = (it.eatenAmount || 0) + bite;
it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2);
tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(bite * 0.46) : (tarinai.hunger -= bite * 0.46);
tarinai.recoverHealth(bite * 0.18);
applyNeedRelief(tarinai, { food: -dt * 2, fulfill: -dt * 1 });
tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0);
tarinai.eatCooldown = rand(0.96, 1.14);
tarinai.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)");
if (Math.random() < dt * 2.0) {
const mouth = tarinai.mouthPosition(it);
tarinai.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39");
}
if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.55; }
tarinai.makePoop(normalPoopUnitsFor("grass", bite));
if (tarinai.type === "jito") tarinai.affection += dt * 0.25;
return true;
}
if (canBite && it.type === "ant_corpse") {
const bite = Math.min(it.amount, rand(3.2, 5.4));
if (bite > 0) {
tarinai.beginEating(it, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b");
tarinai.vx *= Math.pow(0.18, dt * 60);
tarinai.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite;
tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(bite * 0.62) : (tarinai.hunger -= bite * 0.62);
tarinai.recoverHealth(bite * 0.12);
applyNeedShock(tarinai, { safety: bite * 0.12 });
tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0);
tarinai.eatCooldown = rand(0.96, 1.16);
tarinai.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)");
const mouth = tarinai.mouthPosition(it);
tarinai.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36");
tarinai.lastMealColor = "#4a3f36";
tarinai.makePoop(normalPoopUnitsFor("ant_corpse", bite));
if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.62; }
return true;
}
}
if (it.type === "zunchi") {
const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1);
if (!tarinai.isZunchiSlave) {
applyNeedShock(tarinai, { health: touch * dt * 7, safety: touch * dt * 4 });
if (touch > 0.08) tarinai.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop.");
}
tarinai.zunchiStain = clamp((tarinai.zunchiStain || 0) + touch * dt * (tarinai.isZunchiSlave ? 5.2 : 9.5), 0, 100);
const emergencyHunger = (tarinai.hunger || 0) >= 104 || foodNeed >= 94;
const zunchiTooFresh = zunchiEatingBlocked(tarinai, it, tarinai.world) || (!emergencyHunger && !tarinai.isZunchiSlave && it.stage === "fresh" && (it.age || 0) < 150 && foodNeed < 94 && (tarinai.hunger || 0) < 104);
if (canZunchiBite && !zunchiTooFresh) {
const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1));
const bite = Math.min(it.amount, (tarinai.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale);
tarinai.beginEating(it, tarinai.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");
tarinai.vx *= Math.pow(0.18, dt * 60);
tarinai.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite;
tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(bite * (tarinai.isZunchiSlave ? 0.66 : 0.18)) : (tarinai.hunger -= bite * (tarinai.isZunchiSlave ? 0.66 : 0.18));
tarinai.recoverHealth(bite * (tarinai.isZunchiSlave ? 0.10 : 0.02));
applyNeedShock(tarinai, { health: bite * (tarinai.isZunchiSlave ? 0.2 : 1.2), safety: bite * (tarinai.isZunchiSlave ? 0.1 : 0.6) });
if (!tarinai.isZunchiSlave) tarinai.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop.");
tarinai.zunchiStain = clamp((tarinai.zunchiStain || 0) + bite * (tarinai.isZunchiSlave ? 0.75 : 1.75), 0, 100);
tarinai.lastMealColor = "#6b8d3f";
tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0);
tarinai.eatCooldown = rand(1.0, 1.22);
tarinai.bubble("\u3082\u3050", 2.2, "rgba(74,91,50,0.78)");
const mouth = tarinai.mouthPosition(it);
tarinai.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f");
if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.7; }
return true;
}
}
return false;
}
function updateOne(tarinai, ctx, dt = 0) {
if (!tarinai || tarinai.dead || !ctx) return false;
for (const it of ctx.foodCandidates || []) {
if (updateCandidate(tarinai, ctx, it, dt)) return true;
}
return false;
}
global.TarinaiFoodInteractionSystem = Object.freeze({
updateCandidate,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,22 @@
"use strict";
// Boundary for forced-action planning. The concrete forced-queue implementation
// remains in tarinai_forced_behavior.js / tarinai_needs_items.js; this module
// gives the need planner a stable dependency point.
(function (global) {
function hasPending(tarinai, actionId = "") {
if (!tarinai) return false;
if (typeof tarinai.hasForcedBehavior === "function") return tarinai.hasForcedBehavior(actionId);
return false;
}
function prepare(tarinai, dt = 0) {
return Boolean(tarinai && !tarinai.dead && hasPending(tarinai));
}
global.TarinaiForcedActionPlannerSystem = Object.freeze({
hasPending,
prepare,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -440,8 +440,8 @@ function synchronizeActionTextFromState(tarinai) {
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 });
const label = partner?.name ? `${partner.name}\u3068へこへこしている` : "へこへこしている";
setBehaviorText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "へこへこしている", reason: label, target: partner });
return;
}
if (state === "eat" || (tarinai.eatTimer || 0) > 0.04 || (tarinai.grassEatTimer || 0) > 0.04) {

View file

@ -3,9 +3,20 @@
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_identity_social.js");
function relationIdSetFor(tarinai) {
const ids = new Set(Object.keys(tarinai?.relationships || {}));
for (const other of tarinai?.world?.tarinai || []) {
if (!other || other === tarinai || !other.id) continue;
if (other.relationships?.[tarinai.id]) ids.add(other.id);
}
return ids;
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
get lack() {
return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100);
const healthLack = 100 * (1 - (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp((this.energy || 0) / Math.max(1, this.maxEnergy || 100), 0, 1)));
return clamp((this.hunger + this.loneliness + this.stress + healthLack) / 4, 0, 100);
},
get mood() {
@ -152,14 +163,26 @@
rel.fightsLost = rel.fightsLost || 0;
if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; }
this.relationCache = null;
const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14;
if (affinityDelta > 0 && rel.affinity >= threshold && other.relationTo) {
const reverse = other.relationTo(this.id);
const mirrored = Math.max(reverse.affinity || 0, threshold + 0.25, Math.min(rel.affinity, rel.affinity * 0.78));
reverse.affinity = clamp(mirrored, -24, 40);
reverse.fear = clamp(Math.min(reverse.fear || 0, rel.fear || 0), 0, 40);
if (event && !reverse.lastEvent) { reverse.lastEvent = event; reverse.lastTime = this.world?.time || other.world?.time || 0; }
other.relationCache = null;
}
},
strongestRelation(kind = "friend", liveOnly = true) {
let bestId = null;
let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD;
for (const [id, rel] of Object.entries(this.relationships || {})) {
for (const id of relationIdSetFor(this)) {
const rel = this.relationships?.[id] || relationDefaults();
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0);
const other = this.world?.liveTarinaiById?.(id) || null;
const reverse = kind === "fear" ? null : (other?.relationships?.[this.id] || null);
const score = kind === "fear" ? (rel.fear || 0) : Math.max(rel.affinity || 0, reverse?.affinity || 0);
if (score > bestScore) { bestId = id; bestScore = score; }
}
return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null;
@ -176,9 +199,12 @@
const cacheKey = `${minScore}:${liveOnly ? 1 : 0}`;
if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount;
let count = 0;
for (const [id, rel] of Object.entries(this.relationships || {})) {
if ((rel.affinity || 0) <= minScore) continue;
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
for (const id of relationIdSetFor(this)) {
const rel = this.relationships?.[id] || relationDefaults();
const other = this.world?.liveTarinaiById?.(id) || null;
const reverse = other?.relationships?.[this.id] || null;
if (Math.max(rel.affinity || 0, reverse?.affinity || 0) <= minScore) continue;
if (liveOnly && (!other || id === this.id)) continue;
count += 1;
}
this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 };
@ -198,10 +224,12 @@
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;
for (const id of relationIdSetFor(this)) {
const rel = this.relationships?.[id] || relationDefaults();
const t = this.world.liveTarinaiById?.(id) || null;
const reverse = t?.relationships?.[this.id] || null;
const score = Math.max(rel.affinity || 0, reverse?.affinity || 0);
if (score <= bestScore) continue;
if (!t) continue;
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
best = t;
@ -294,7 +322,9 @@
recoverHealth(amount) {
if (this.dead) return;
this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100);
const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100);
this.maxEnergy = maxEnergy;
this.energy = clamp((this.energy || 0) + Math.max(0, amount || 0), 0, maxEnergy);
},
familyJoined() {
@ -339,7 +369,7 @@
this.targetKey = key;
this.targetSince = this.world.time;
this.targetBestDist = d;
} else if (d < (this.targetBestDist || Infinity) - 8) {
} else if (d < (this.targetBestDist || Infinity) - 6) {
this.targetBestDist = d;
this.targetSince = this.world.time;
}
@ -351,7 +381,7 @@
this.wanderAngle += rand(-1.4, 1.4);
return;
}
if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) {
if (this.world.time - (this.targetSince || this.world.time) > 10.5 && d > this.radius + 24) {
this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 12;
this.target = null;
@ -394,11 +424,12 @@
},
lowHealthSprite() {
// This is physical weakness only. Stress is handled by the stress sprite branch
// below so high-stress individuals do not incorrectly render as low-health.
const critical = this.hunger > 112 || this.energy < 7;
if (critical) return this.stretchTimer > 0 ? "stretch" : "weak";
const weak = this.hunger > 102 || this.energy < 18;
// 低体力は空腹・眠気だけでなく、健康欲求の悪化や負傷も反映する。
const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0;
const hurt = (this.hurtTimer || 0) > 0.10;
const critical = this.hunger > 112 || this.energy < 7 || healthNeed >= 90 || (hurt && this.energy < 20);
if (critical) return "stretch";
const weak = this.hunger > 102 || this.energy < 18 || healthNeed >= 66 || hurt;
if (weak) return "weak";
return null;
},

View file

@ -85,11 +85,29 @@
},
refreshEffectiveSize() {
const oldMax = Number.isFinite(this.maxEnergy) ? Math.max(1, this.maxEnergy) : 100;
const oldEnergy = Number.isFinite(this.energy) ? this.energy : oldMax;
const mult = this.itemSizeMultiplier ? this.itemSizeMultiplier() : 1;
this.radius = 56 * (this.scale || 0.28) * mult;
const nextMax = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : 100;
this.maxEnergy = nextMax;
const ratio = clamp(oldEnergy / oldMax, 0, 1.35);
this.energy = clamp(ratio * nextMax, 0, nextMax);
return this.radius;
},
effectiveMaxEnergy() {
return typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100);
},
energyRatio() {
return typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp((this.energy || 0) / Math.max(1, this.maxEnergy || 100), 0, 1);
},
metabolicHungerMultiplier() {
return typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1;
},
setPowerItemMode(mode = "") {
this.ensureItemEffectState();
if (mode !== "protein" && mode !== "niteropu") return false;

View file

@ -0,0 +1,69 @@
"use strict";
// Builds and finalizes Tarinai item-interaction scan context.
// This keeps scan radius, candidate collection, and post-interaction clamping in one place.
(function (global) {
function create(tarinai, dt = 0) {
if (!tarinai || !tarinai.world) return null;
const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
const healthNeed = Number(tarinai.needRaw?.health ?? tarinai.needs?.health ?? 0) || 0;
const currentNeed = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai.behavior?.need;
const foodActionActive = currentNeed === "food" || ["seek_food", "eat", "seek_water"].includes(tarinai.state);
const healthActionActive = currentNeed === "health" || tarinai.state === "seek_food";
const mealReady = (tarinai.mealCooldownUntil || 0) <= (tarinai.world?.time || 0) || tarinai.hunger > 92 || foodNeed > 84;
const wantsFood = foodActionActive || foodNeed >= needThreshold("food", "start") || tarinai.hunger > 70;
const wantsMedicine = healthActionActive || healthNeed >= needThreshold("health", "start");
const canBite = !tarinai.isZunchiSlave && tarinai.eatCooldown <= 0 && mealReady && wantsFood && tarinai.hunger > 18;
const canSweetBite = !tarinai.isZunchiSlave && tarinai.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && tarinai.hunger > 7;
const canZunchiBite = tarinai.eatCooldown <= 0 && (tarinai.isZunchiSlave ? tarinai.hunger > 16 : (tarinai.hunger >= 102 || foodNeed >= 92)) && (tarinai.isZunchiSlave || !hasBetterFoodThanZunchiNearby(tarinai, tarinai.world, 260));
const scanLimit = (tarinai.hunger >= 96 || foodNeed >= 84) ? 32 : 18;
const foodScanRadius = tarinai.radius + ((tarinai.hunger >= 96 || foodNeed >= 84) ? 104 : 56);
const nearbyFood = tarinai.world.nearbyFood?.(tarinai.x, tarinai.y, foodScanRadius, true) || [];
const nearbyItems = nearbyFood.length ? nearbyFood : tarinai.world.nearbyItems(tarinai.x, tarinai.y, foodScanRadius);
const foodCandidates = [];
if (tarinai.target && !tarinai.target.dead && (tarinai.target.type === "duplicator" || roleMatches(tarinai.target, "food") || roleMatches(tarinai.target, "medicine"))) foodCandidates.push(tarinai.target);
for (const it of nearbyItems) {
if (!it || it.dead || foodCandidates.includes(it)) continue;
if (!(it.type === "duplicator" || roleMatches(it, "food") || roleMatches(it, "medicine") || it.type === "water" || it.type === "water_bowl")) continue;
foodCandidates.push(it);
if (foodCandidates.length >= scanLimit) break;
}
if (foodCandidates.length > 1 && foodCandidates.length <= scanLimit) {
foodCandidates.sort((a, b) => foodPriorityScore(tarinai, b, b?.type === "duplicator" ? foodInteractionDistance(tarinai, b) : dist(tarinai, b)) - foodPriorityScore(tarinai, a, a?.type === "duplicator" ? foodInteractionDistance(tarinai, a) : dist(tarinai, a)));
}
return {
dt,
foodNeed,
healthNeed,
currentNeed,
foodActionActive,
healthActionActive,
mealReady,
wantsFood,
wantsMedicine,
canBite,
canSweetBite,
canZunchiBite,
scanLimit,
foodScanRadius,
nearbyFood,
nearbyItems,
foodCandidates,
};
}
function finalize(tarinai) {
if (!tarinai) return false;
tarinai.hunger = clamp(tarinai.hunger, 0, 115);
tarinai.energy = clamp(tarinai.energy, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100));
tarinai.stress = applyGroundStressModifier(tarinai, calculateStressFromNeeds(tarinai.needRaw || tarinai.needs || createDefaultNeeds()));
tarinai.loneliness = clamp(tarinai.loneliness, 0, 110);
return true;
}
global.TarinaiItemInteractionContext = Object.freeze({
create,
finalize,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,39 @@
"use strict";
// Tarinai item interaction system.
// Orchestrates food/medicine consumption and non-eating item contact effects.
// The candidate loop intentionally preserves the established order: for each nearby
// candidate, try eating first; if nothing was consumed, apply contact effects
// for that same candidate before advancing to the next one.
(function (global) {
function interactWithItems(dt = 0) {
const ctx = global.TarinaiItemInteractionContext?.create?.(this, dt);
if (!ctx) return false;
for (const item of ctx.foodCandidates || []) {
const consumed = Boolean(global.TarinaiFoodInteractionSystem?.updateCandidate?.(this, ctx, item, dt));
if (consumed) {
global.TarinaiItemInteractionContext?.finalize?.(this);
return true;
}
global.TarinaiContactItemSystem?.updateCandidate?.(this, ctx, item, dt);
}
global.TarinaiItemInteractionContext?.finalize?.(this);
return true;
}
function updateOne(tarinai, dt = 0) {
if (!tarinai || tarinai.dead) return false;
return interactWithItems.call(tarinai, dt);
}
function updateWithThis(dt = 0) {
return updateOne(this, dt);
}
global.TarinaiItemInteractionSystem = Object.freeze({
interactWithItems,
updateOne,
updateWithThis,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -36,7 +36,7 @@ function roleMatches(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 === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "splat"].includes(item.type);
if (role === "grassMaterial") return item.type === "grass";
return false;
}
@ -103,7 +103,7 @@ function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
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 (["sweet", "food", "grass", "water", "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;

View file

@ -0,0 +1,52 @@
"use strict";
// Layer: entity-runtime/tarinai/update-system
// Owns local family/friend/grass/nest comfort environment effects.
(function (global) {
function updateBody(dt) {
const parent = this.parentToFollow ? this.parentToFollow() : null;
if (parent && dist(this, parent) < 96) {
applyNeedRelief(this, { social: -dt * 4, safety: -dt * 2 });
this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110);
this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family.");
}
const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null;
const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0;
if (friend) {
applyNeedRelief(this, { social: -dt * 3, fulfill: -dt * 1 });
this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends.");
} else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) {
this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends.");
}
let grassComfort = 0;
for (const it of this.world.nearbyItems(this.x, this.y, 105)) {
if (it.type !== "grass" || it.dead) continue;
const d = distXY(this.x, this.y, it.x, it.y);
const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2);
grassComfort += clamp(1 - d / 105, 0, 1) * lush;
}
let nestComfort = 0;
for (const it of this.world.nearbyItems(this.x, this.y, 120)) {
if (!it || it.dead || it.type !== "nest_box") continue;
nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1);
}
if (nestComfort > 0) applyNeedRelief(this, { sleep: -dt * Math.min(4, nestComfort * 3), safety: -dt * Math.min(5, nestComfort * 4) });
if (grassComfort > 0) {
applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) });
if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06));
if (this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012);
}
}
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead) return false;
updateBody.call(tarinai, dt);
return true;
}
global.TarinaiLocalEnvironmentSystem = Object.freeze({
updateBody,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,22 @@
"use strict";
// Public Tarinai need-planning entrypoint. This owns the resolveNeeds boundary
// used by Tarinai.prototype.resolveNeeds and by future planner decomposition.
(function (global) {
function updateOne(tarinai, dt = 0) {
if (!tarinai || tarinai.dead) return false;
global.TarinaiForcedActionPlannerSystem?.prepare?.(tarinai, dt);
return Boolean(global.TarinaiActionPlannerSystem?.updateOne?.(tarinai, dt)
?? global.TarinaiNeedPlannerCoreSystem?.updateOne?.(tarinai, dt));
}
function updateWithThis(dt = 0) {
return updateOne(this, dt);
}
global.TarinaiNeedPlannerSystem = Object.freeze({
updateOne,
updateWithThis,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -62,10 +62,14 @@ function updateNeeds(tarinai, world, dt = 0) {
const raw = createDefaultNeeds();
raw.food = (tarinai.hunger || 0) * 0.88;
const sleepPhase = updateCircadianSleepPressure(tarinai, world, dt);
const energySleepNeed = Math.max(0, 100 - (tarinai.energy || 0)) * 0.22;
const energyMax = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100));
tarinai.maxEnergy = energyMax;
const energyPct = clamp((Number(tarinai.energy) || 0) / energyMax, 0, 1);
const energyLack = (1 - energyPct) * 100;
const energySleepNeed = energyLack * 0.22;
raw.sleep = energySleepNeed + sleepPhase.pressure;
if (tarinai.sleepDisease) raw.sleep += 34;
raw.health = Math.max(0, (100 - (tarinai.energy || 100)) * 0.18 + (tarinai.zunchiStain || 0) * 0.32);
raw.health = Math.max(0, energyLack * 0.18 + (tarinai.zunchiStain || 0) * 0.32);
if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) raw.health += 42;
const lodgedBehavior = tarinai.currentLodgedPinBehavior?.() || null;
if ((tarinai.hurtTimer || 0) > 0.04 || ((tarinai.stuckPushpinId || "") && (lodgedBehavior?.panicOnAttach || (lodgedBehavior?.damagePerTick || 0) > 0 || (lodgedBehavior?.damageOnAttach || 0) > 0))) raw.health += 36;
@ -547,9 +551,173 @@ function protectSleepSession(tarinai, world, needs) {
return false;
}
function resolveNeedsCore(dt) {
decayActionCooldowns(this, dt);
const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds();
const needs = updateNeedsCached(this, this.world, dt);
synchronizeActionTextFromState(this);
if (processForcedBehaviorQueue(this, this.world, needs)) return;
if (protectSleepSession(this, this.world, needs)) return;
if (continueBuildPlan(this, this.world, dt)) return;
const lockedTargetInvalid = this.target && this.target.dead;
const lockedBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior;
if ((this.behaviorLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) {
if (continueCurrentBehavior(this, this.world, needs, dt)) return;
this.thought = lockedBehavior.reason || lockedBehavior.label || this.thought;
return;
}
if (this.birthRitualTimer > 0.04) {
const partner = this.world.liveTarinaiById?.(this.birthPartnerId) || null;
this.setActionState?.("birth_ritual", { target: partner, reason: "へこへこしている" });
synchronizeActionTextFromState(this);
return;
}
if (this.intimidateTimer > 0.04) {
const target = this.world.liveTarinaiById?.(this.intimidateTargetId) || null;
this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" });
this.bubble("!", 1.2);
return;
}
if (this.fightTimer > 0.04) {
this.fightTargetIds = (this.fightTargetIds || []).filter(id => Boolean(this.world.liveTarinaiById?.(id)));
this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId;
let best = null, bestD = Infinity;
for (const id of this.fightTargetIds) {
const t = this.world.liveTarinaiById?.(id);
if (!t) continue;
const d = dist(this, t);
if (d < bestD) { best = t; bestD = d; }
}
const maxFightContinueDistance = Math.max(150, (this.radius || 20) + (best?.radius || 20) + 116);
if (!best || bestD > maxFightContinueDistance) {
const rival = best || (this.fightTargetId ? this.world.liveTarinaiById?.(this.fightTargetId) : null);
this.fightTimer = 0;
this.fightTargetIds = [];
this.fightTargetId = null;
this.counterAttackFromId = null;
this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8));
if (rival) this.world?.markFightPairCooldown?.(this, rival, CONFIG.fightCooldown + 6);
if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(this, e => e && e.id === "fight_rival");
this.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true });
return;
}
const reasonText = best ? `${best.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002` : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002";
this.setActionState?.("fight", { target: best, reason: reasonText });
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" });
this.bubble("!", 1.6);
return;
}
if (this.defeatedTimer > 0.04) {
const opponent = this.world.liveTarinaiById?.(this.defeatedById);
applyNeedShock(this, { safety: 54, health: 16 }, opponent || null);
startNeedDrivenEmergencyReaction(this, opponent || null, "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b\u3002");
if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null;
return;
} else if (this.defeatedById && this.fightTimer <= 0.04) {
this.defeatedById = null;
this.fightWinnerId = null;
}
if (this.stuckPushpinId) {
const pin = this.currentLodgedPin?.() || null;
const pinBehavior = this.currentLodgedPinBehavior?.() || null;
if (pin && pinBehavior?.panicOnAttach) {
this.needShock = this.needShock || {};
this.needShock.safety = Math.max(this.needShock.safety || 0, 42);
this.needShock.health = Math.max(this.needShock.health || 0, 46);
this.lastNeedShockBreaker = pin;
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24);
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8);
const now = this.world?.time || 0;
if (now >= (this.nextPinPanicBubbleAt || 0)) {
this.bubble("!!", 0.9, "rgba(168,72,72,0.82)");
this.nextPinPanicBubbleAt = now + 1.6;
}
this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
}
if (!pin || pin.pinState !== "lodged") this.stuckPushpinId = null;
}
const breaker = this.lastNeedShockBreaker || null;
const safetyDelta = (this.needs?.safety || 0) - (beforeNeeds.safety || 0);
const healthDelta = (this.needs?.health || 0) - (beforeNeeds.health || 0);
const fulfillDelta = (this.needs?.fulfill || 0) - (beforeNeeds.fulfill || 0);
const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50;
if (breaker && shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) {
if (startNeedDrivenEmergencyReaction(this, breaker, "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002")) return;
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null;
this.lastNeedShockBreaker = null;
return;
}
if (continueCurrentBehavior(this, this.world, needs, dt)) return;
if ((this.behaviorSwitchCooldownUntil || 0) > (this.world?.time || 0) && !this.hasForcedBehavior?.()) {
this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 0.25);
if (!(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior) && !["panic", "fight", "birth_ritual", "intimidate", "sleep"].includes(this.state)) this.setActionState?.("idle", { target: null, reason: this.thought || "\u5c11\u3057\u69d8\u5b50\u3092\u898b\u3066\u3044\u308b", sleeping: false, clearTarget: true });
return;
}
const foodNeedForLeisure = Number(needs?.food || 0) || 0;
const safetyNeedForLeisure = Number(needs?.safety || 0) || 0;
const sleepNeedForLeisure = Number(needs?.sleep || 0) || 0;
const sunbathActionForLeisure = actionById("sunbath");
if (sunbathActionForLeisure
&& this.canStartSunbath?.({ leisure: true })
&& foodNeedForLeisure < 58
&& safetyNeedForLeisure < 50
&& sleepNeedForLeisure < 68
&& Math.random() < Math.max(0.010, dt * 0.055)) {
const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathActionForLeisure) };
startNeedAction(this, this.world, choice, sunbathActionForLeisure, "余裕があるので、日光浴している。", { leisure: true, priority: actionPriority(sunbathActionForLeisure), lockSeconds: 6.0 });
return;
}
const picked = choosePriorityNeedAction(needs, this, this.world);
if (!picked?.action) {
const sunbathAction = actionById("sunbath");
if (sunbathAction && this.canStartSunbath?.({ leisure: true }) && Math.random() < Math.max(0.006, dt * 0.08)) {
const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathAction) };
startNeedAction(this, this.world, choice, sunbathAction, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathAction), lockSeconds: 6.0 });
return;
}
const choice = chooseTopNeedRandom(needs, this, this.world);
setBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reason: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 });
clearBehavior(this);
this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 3.0);
this.goIdle?.("\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b");
return;
}
const action = picked.action;
const choice = picked.choice;
const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action, this, this.world);
startNeedAction(this, this.world, choice, action, reasonText, { priority: actionPriority(action) });
return;
}
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js");
if (!global.TarinaiNeedPlannerCoreSystem) {
global.TarinaiNeedPlannerCoreSystem = Object.freeze({
updateOne(tarinai, dt = 0) {
if (!tarinai || tarinai.dead) return false;
resolveNeedsCore.call(tarinai, dt);
return true;
},
updateWithThis(dt = 0) {
return this && !this.dead ? (resolveNeedsCore.call(this, dt), true) : false;
},
resolveNeedsCore,
});
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
forceBehavior(actionId, options = {}) {
return queueForcedTarinaiBehavior(this, actionId, options);
@ -836,278 +1004,19 @@ function protectSleepSession(tarinai, world, needs) {
},
updateSunbath(dt) {
if ((this.sunbathTimer || 0) <= 0) return;
this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt);
this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt);
this.vx = 0;
this.vy = 0;
if ((this.sunbathFrameTimer || 0) <= 0) {
const prev = this._activeSunbathSpriteId || (this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant);
const next = this.randomSunbathSpriteId ? this.randomSunbathSpriteId(prev, { readyOnly: true }) : `sunbath_${1 + Math.floor(Math.random() * 3)}`;
if (next && next !== prev && (!this.isSunbathSpriteReady || this.isSunbathSpriteReady(next))) {
this._activeSunbathSpriteId = next;
this._lastRenderableSunbathSpriteId = next;
this.sunbathSpriteVariant = next;
this.visibleSpriteId = next;
this.spriteLockUntil = 0;
} else {
this.sunbathSpriteVariant = prev;
this.visibleSpriteId = prev;
}
this.sunbathFrameTimer = 0.5;
}
this.energy = clamp((this.energy || 0) + dt * 0.03, 0, 100);
if (!this.isSunbathTime() || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0;
if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") {
this.finishSunbath?.();
this._activeSunbathSpriteId = "";
this.visibleSpriteId = "";
this.spriteLockUntil = 0;
this.sunbathAnchorX = null;
this.sunbathAnchorY = null;
this.goIdle("\u65e5\u5149\u6d74\u3092\u7d42\u3048\u305f");
}
return globalThis.TarinaiSunbathSystem?.updateOne(this, dt) || false;
},
applyCursorContactCare(dt) {
const pointer = this.world?.pointer;
if (!pointer?.inside || !this.isAwakeCursorFriendly()) return;
const d = distXY(this.x, this.y, pointer.x, pointer.y);
const touch = this.radius * 1.35;
if (d > touch) return;
const p = clamp(1 - d / touch, 0, 1);
const relief = dt * (1.35 + 2.65 * p);
applyNeedRelief(this, { safety: -relief * 1.2, fulfill: -relief * 1.6 });
this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted.");
this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5);
this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75);
this.goodMode = "smile";
if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) {
this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10);
for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) {
this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), {
vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)"
}));
}
}
return globalThis.TarinaiCursorCareSystem?.updateOne(this, dt) || false;
},
update(dt) {
if (this.dead) return;
this.prevX = this.x;
this.prevY = this.y;
const minute = dt / 60;
this.age += dt;
this.reproductionTimer -= dt;
this.blink += dt;
this.eatTimer = Math.max(0, this.eatTimer - dt);
if (this.state === "eat" && this.eatTimer <= 0.02) {
this.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true });
}
this.eatCooldown = Math.max(0, this.eatCooldown - dt);
this.foodReactTimer = Math.max(0, this.foodReactTimer - dt);
this.surpriseTimer = Math.max(0, this.surpriseTimer - dt);
this.fearTimer = Math.max(0, this.fearTimer - dt);
this.intimidateTimer = Math.max(0, this.intimidateTimer - dt);
this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt);
this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt);
this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt);
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt);
this.updateZunchiDisease(dt);
this.updateSpecialDiseases(dt);
if (this.updateItemEffects) this.updateItemEffects(dt);
if (this.dead) return;
this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt);
this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt);
if (this.fightMochiTimer > 0.04) {
applyNeedShock(this, { safety: dt * 4 });
if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10);
}
if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null;
if (this.birthRitualTimer > 0) {
const beforeBirthTimer = this.birthRitualTimer;
this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt);
if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) {
const partner = this.world.liveTarinaiById?.(this.birthPartnerId);
if (partner) this.world.finishBirthRitual(this, partner);
this.birthRitualLeader = false;
}
}
this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt);
this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt);
this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2);
this.pinchThoughtTimer = Math.max(0, (this.pinchThoughtTimer || 0) - dt);
this.sunbathCooldown = Math.max(0, (this.sunbathCooldown || 0) - dt);
if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt);
return;
}
this.applyCursorContactCare(dt);
const colonyMoodId = this.world?.colonyMood?.id || "relaxed";
if (colonyMoodId === "weaklings" && !this.isZunchiSlave) applyNeedRelief(this, { safety: -dt * 2, fulfill: -dt * 1 });
if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(this, { fulfill: -dt * 3 });
if (colonyMoodId === "ant_overrun" && this.state !== "panic") {
const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead);
if (antSeen) {
if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30a2\u30ea\u3060", fear: 1.1, wake: true, cause: "colony_ant_overrun" });
else { this.setActionState?.("panic", { target: null, reason: "\u30a2\u30ea\u3060", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); }
}
}
const wasFighting = this.fightTimer > 0.04;
this.fightTimer = Math.max(0, this.fightTimer - dt);
this.fightCooldown = Math.max(0, this.fightCooldown - dt);
this.defeatedTimer = Math.max(0, this.defeatedTimer - dt);
if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) {
const winner = this.world.liveTarinaiById?.(this.defeatedById) || null;
this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2));
this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear);
this.hurtTimer = Math.max(this.hurtTimer, 1.8);
this.fallTimer = Math.max(this.fallTimer, 1.15);
this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer);
this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir;
this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45);
if (winner) this.world.resolveFightOutcome(winner, this);
this.fightTargetIds = [];
this.fightTargetId = null;
}
this.stretchTimer = Math.max(0, this.stretchTimer - dt);
this.grassEatTimer = Math.max(0, this.grassEatTimer - dt);
this.hurtTimer = Math.max(0, this.hurtTimer - dt);
this.fallTimer = Math.max(0, this.fallTimer - dt);
if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) {
this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)");
}
if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) {
this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)");
}
if (this.hurtTimer > 0 && Math.random() < dt * 5.2) {
const side = this.facingDir();
this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34));
}
this.tempTimer += dt;
if (this.tempTimer >= 0.55) {
this.tempComfort = this.world.temperatureAt(this.x, this.y);
this.tempTimer = 0;
}
const tempComfort = this.tempComfort ?? 0.62;
const tooCold = Math.max(0, 0.48 - tempComfort);
this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8);
this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social);
this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1);
// stress is derived from needs; no independent stress decay.
if (tooCold > 0) applyNeedShock(this, { health: tooCold * dt * 4, safety: tooCold * dt * 2 });
const phase = this.world.lightLevel();
if (phase < 0.25) this.energy += dt * 0.07;
if (phase > 0.75) applyNeedShock(this, { health: dt * 1.2 });
if (this.world.weather === "cloudy") {
applyNeedRelief(this, { safety: -dt * 1 });
this.vx *= Math.pow(0.996, dt * 60);
this.vy *= Math.pow(0.996, dt * 60);
} else if (this.world.weather === "light_rain") {
this.energy += dt * 0.018;
applyNeedRelief(this, { health: -dt * 1, safety: -dt * 1 });
} else if (this.world.weather === "sunny") {
this.vx *= Math.pow(1.001, dt * 60);
this.vy *= Math.pow(1.001, dt * 60);
}
if (this.generation > 1 && this.syncGrowthScale) this.syncGrowthScale();
this.hunger = clamp(this.hunger, 0, 115);
this.loneliness = clamp(this.loneliness, 0, 110);
this.energy = clamp(this.energy, 0, 100);
this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds()));
const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping;
this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt);
if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt);
return;
}
this.behaviorLockTimer = Math.max(0, (this.behaviorLockTimer || 0) - dt);
this.aiTimer += dt;
const urgentFoodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0;
const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02 || this.hunger >= 96 || urgentFoodNeed >= 84;
const aiJitter = stableUnit(this.familyKey || this.id, "ai-interval") * 0.35;
const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter);
if (this.aiTimer >= aiInterval && this.world.spendScheduledWork?.("ai", urgentAi) !== false) {
const elapsedAiDt = Math.max(0.016, Math.min(this.aiTimer, urgentAi ? 0.75 : 1.50));
const scanDt = Math.min(elapsedAiDt, 0.75);
this.aiTimer = 0;
// Needs intentionally advance by a fixed coarse step per AI update.
// Exact elapsed-time integration is unnecessary for this observation game
// and can over-amplify safety/fear transitions when updates are sparse.
const needStepDt = urgentAi ? 0.50 : 1.00;
this.resolveNeeds(needStepDt);
const sleepingNow = this.state === "sleep";
if (!sleepingNow) {
this.reactToFreshZunchi(scanDt);
this.updateFacing();
this.interactWithItems(scanDt);
this.interactWithOthers(scanDt);
} else {
this.updateFacing();
}
} else {
this.updateFacing();
}
this.envTimer += dt;
const envInterval = 3.0 + stableUnit(this.familyKey || this.id, "env-phase") * 1.4;
if (this.envTimer >= envInterval && this.world.spendScheduledWork?.("env", urgentAi || this.world.selected === this) !== false) {
const envDt = Math.min(this.envTimer, 1.2);
this.envTimer = 0;
this.applyEnvironment(envDt);
}
this.updateSunbath(dt);
this.maintainTargetProgress(dt);
this.move(dt);
if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt);
this.updateNestBoxPresence(dt);
if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this);
const sleepingAtFrameEnd = this.state === "sleep" || this.sleeping;
if (wasSleepingAtFrameStart && !sleepingAtFrameEnd && !this.dead) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10);
this.checkLife(dt);
return globalThis.TarinaiUpdatePipeline?.updateOne?.(this, dt) || false;
},
applyEnvironment(dt) {
const parent = this.parentToFollow ? this.parentToFollow() : null;
if (parent && dist(this, parent) < 96) {
applyNeedRelief(this, { social: -dt * 4, safety: -dt * 2 });
this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110);
this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family.");
}
const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null;
const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0;
if (friend) {
applyNeedRelief(this, { social: -dt * 3, fulfill: -dt * 1 });
this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends.");
} else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) {
this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends.");
}
let grassComfort = 0;
for (const it of this.world.nearbyItems(this.x, this.y, 105)) {
if (it.type !== "grass" || it.dead) continue;
const d = distXY(this.x, this.y, it.x, it.y);
const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2);
grassComfort += clamp(1 - d / 105, 0, 1) * lush;
}
let nestComfort = 0;
for (const it of this.world.nearbyItems(this.x, this.y, 120)) {
if (!it || it.dead || it.type !== "nest_box") continue;
nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1);
}
if (nestComfort > 0) applyNeedRelief(this, { sleep: -dt * Math.min(4, nestComfort * 3), safety: -dt * Math.min(5, nestComfort * 4) });
if (grassComfort > 0) {
applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) });
if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06));
if (this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012);
}
return globalThis.TarinaiLocalEnvironmentSystem?.updateOne(this, dt) || false;
},
bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)", options = {}) {
@ -1234,140 +1143,7 @@ function protectSleepSession(tarinai, world, needs) {
},
resolveNeeds(dt) {
decayActionCooldowns(this, dt);
const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds();
const needs = updateNeedsCached(this, this.world, dt);
synchronizeActionTextFromState(this);
if (processForcedBehaviorQueue(this, this.world, needs)) return;
if (protectSleepSession(this, this.world, needs)) return;
if (continueBuildPlan(this, this.world, dt)) return;
const lockedTargetInvalid = this.target && this.target.dead;
const lockedBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior;
if ((this.behaviorLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) {
if (continueCurrentBehavior(this, this.world, needs, dt)) return;
this.thought = lockedBehavior.reason || lockedBehavior.label || this.thought;
return;
}
if (this.birthRitualTimer > 0.04) {
const partner = this.world.liveTarinaiById?.(this.birthPartnerId) || null;
this.setActionState?.("birth_ritual", { target: partner, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" });
synchronizeActionTextFromState(this);
return;
}
if (this.intimidateTimer > 0.04) {
const target = this.world.liveTarinaiById?.(this.intimidateTargetId) || null;
this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" });
this.bubble("!", 1.2);
return;
}
if (this.fightTimer > 0.04) {
this.fightTargetIds = (this.fightTargetIds || []).filter(id => Boolean(this.world.liveTarinaiById?.(id)));
this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId;
let best = null, bestD = Infinity;
for (const id of this.fightTargetIds) {
const t = this.world.liveTarinaiById?.(id);
if (!t) continue;
const d = dist(this, t);
if (d < bestD) { best = t; bestD = d; }
}
const reasonText = best ? `${best.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002` : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002";
this.setActionState?.("fight", { target: best, reason: reasonText });
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" });
this.bubble("!", 1.6);
return;
}
if (this.defeatedTimer > 0.04) {
const opponent = this.world.liveTarinaiById?.(this.defeatedById);
applyNeedShock(this, { safety: 54, health: 16 }, opponent || null);
startNeedDrivenEmergencyReaction(this, opponent || null, "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b\u3002");
if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null;
return;
} else if (this.defeatedById && this.fightTimer <= 0.04) {
this.defeatedById = null;
this.fightWinnerId = null;
}
if (this.stuckPushpinId) {
const pin = this.currentLodgedPin?.() || null;
const pinBehavior = this.currentLodgedPinBehavior?.() || null;
if (pin && pinBehavior?.panicOnAttach) {
this.needShock = this.needShock || {};
this.needShock.safety = Math.max(this.needShock.safety || 0, 42);
this.needShock.health = Math.max(this.needShock.health || 0, 46);
this.lastNeedShockBreaker = pin;
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24);
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8);
const now = this.world?.time || 0;
if (now >= (this.nextPinPanicBubbleAt || 0)) {
this.bubble("!!", 0.9, "rgba(168,72,72,0.82)");
this.nextPinPanicBubbleAt = now + 1.6;
}
this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
}
if (!pin || pin.pinState !== "lodged") this.stuckPushpinId = null;
}
const breaker = this.lastNeedShockBreaker || null;
const safetyDelta = (this.needs?.safety || 0) - (beforeNeeds.safety || 0);
const healthDelta = (this.needs?.health || 0) - (beforeNeeds.health || 0);
const fulfillDelta = (this.needs?.fulfill || 0) - (beforeNeeds.fulfill || 0);
const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50;
if (breaker && shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) {
if (startNeedDrivenEmergencyReaction(this, breaker, "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002")) return;
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null;
this.lastNeedShockBreaker = null;
return;
}
if (continueCurrentBehavior(this, this.world, needs, dt)) return;
if ((this.behaviorSwitchCooldownUntil || 0) > (this.world?.time || 0) && !this.hasForcedBehavior?.()) {
this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 0.25);
if (!(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior) && !["panic", "fight", "birth_ritual", "intimidate", "sleep"].includes(this.state)) this.setActionState?.("idle", { target: null, reason: this.thought || "\u5c11\u3057\u69d8\u5b50\u3092\u898b\u3066\u3044\u308b", sleeping: false, clearTarget: true });
return;
}
const foodNeedForLeisure = Number(needs?.food || 0) || 0;
const safetyNeedForLeisure = Number(needs?.safety || 0) || 0;
const sleepNeedForLeisure = Number(needs?.sleep || 0) || 0;
const sunbathActionForLeisure = actionById("sunbath");
if (sunbathActionForLeisure
&& this.canStartSunbath?.({ leisure: true })
&& foodNeedForLeisure < 58
&& safetyNeedForLeisure < 50
&& sleepNeedForLeisure < 68
&& Math.random() < Math.max(0.010, dt * 0.055)) {
const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathActionForLeisure) };
startNeedAction(this, this.world, choice, sunbathActionForLeisure, "余裕があるので、日光浴している。", { leisure: true, priority: actionPriority(sunbathActionForLeisure), lockSeconds: 6.0 });
return;
}
const picked = choosePriorityNeedAction(needs, this, this.world);
if (!picked?.action) {
const sunbathAction = actionById("sunbath");
if (sunbathAction && this.canStartSunbath?.({ leisure: true }) && Math.random() < Math.max(0.006, dt * 0.08)) {
const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathAction) };
startNeedAction(this, this.world, choice, sunbathAction, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathAction), lockSeconds: 6.0 });
return;
}
const choice = chooseTopNeedRandom(needs, this, this.world);
setBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reason: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 });
clearBehavior(this);
this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 3.0);
this.goIdle?.("\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b");
return;
}
const action = picked.action;
const choice = picked.choice;
const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action, this, this.world);
startNeedAction(this, this.world, choice, action, reasonText, { priority: actionPriority(action) });
return;
return global.TarinaiNeedPlannerSystem?.updateOne?.(this, dt) ?? global.TarinaiNeedPlannerCoreSystem?.updateOne?.(this, dt);
},
makePoop(force = 1) {
@ -1396,267 +1172,7 @@ function protectSleepSession(tarinai, world, needs) {
},
interactWithItems(dt) {
const foodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0;
const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0;
const currentNeed = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need;
const foodActionActive = currentNeed === "food" || ["seek_food", "eat", "seek_water"].includes(this.state);
const healthActionActive = currentNeed === "health" || this.state === "seek_food";
const mealReady = (this.mealCooldownUntil || 0) <= (this.world?.time || 0) || this.hunger > 92 || foodNeed > 84;
const wantsFood = foodActionActive || foodNeed >= needThreshold("food", "start") || this.hunger > 70;
const wantsMedicine = healthActionActive || healthNeed >= needThreshold("health", "start");
const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && wantsFood && this.hunger > 18;
const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && this.hunger > 7;
const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger >= 102 || foodNeed >= 92)) && (this.isZunchiSlave || !hasBetterFoodThanZunchiNearby(this, this.world, 260));
const scanLimit = (this.hunger >= 96 || foodNeed >= 84) ? 32 : 18;
const foodScanRadius = this.radius + ((this.hunger >= 96 || foodNeed >= 84) ? 104 : 56);
const nearbyFood = this.world.nearbyFood?.(this.x, this.y, foodScanRadius, true) || [];
const nearbyItems = nearbyFood.length ? nearbyFood : this.world.nearbyItems(this.x, this.y, foodScanRadius);
const foodCandidates = [];
if (this.target && !this.target.dead && (this.target.type === "duplicator" || roleMatches(this.target, "food") || roleMatches(this.target, "medicine"))) foodCandidates.push(this.target);
for (const it of nearbyItems) {
if (!it || it.dead || foodCandidates.includes(it)) continue;
if (!(it.type === "duplicator" || roleMatches(it, "food") || roleMatches(it, "medicine") || it.type === "water" || it.type === "water_bowl")) continue;
foodCandidates.push(it);
if (foodCandidates.length >= scanLimit) break;
}
if (foodCandidates.length > 1 && foodCandidates.length <= scanLimit) {
foodCandidates.sort((a, b) => foodPriorityScore(this, b, b?.type === "duplicator" ? foodInteractionDistance(this, b) : dist(this, b)) - foodPriorityScore(this, a, a?.type === "duplicator" ? foodInteractionDistance(this, a) : dist(this, a)));
}
for (const it of foodCandidates) {
const d = it?.type === "duplicator" ? foodInteractionDistance(this, it) : dist(this, it);
const contactReach = foodInteractionReach(this, it, "food") + (it === this.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0);
if (d > contactReach) continue;
const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type;
const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi";
if (((foodType === "sweet" && canSweetBite) || (foodType === "food" && (canBite || foodActionActive)) || duplicatorConsumable) && this.foodItemHasServingLeft(it)) {
const eating = this.consumeFoodServing(it);
if (eating > 0) {
const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type);
this.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`);
this.vx *= Math.pow(0.25, dt * 60);
this.vy *= Math.pow(0.25, dt * 60);
const hungerRelief = this.foodHungerReliefFor(foodType || it.type, eating, foodType === "sweet" ? 0.75 : 0.95);
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief);
this.recoverHealth(eating * (foodType === "sweet" ? 0.28 : 0.46));
this.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012;
if (foodType === "sweet") {
applyNeedRelief(this, { food: -eating * 0.4, fulfill: -eating * 0.2 });
this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035);
if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f");
}
this.eatTimer = Math.max(this.eatTimer, 1.0);
this.eatCooldown = rand(0.96, 1.12);
this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)");
this.foodReactTimer = Math.max(this.foodReactTimer, 0.16);
if (Math.random() < dt * 2.2) {
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "sweet" ? "#78b957" : "#f1dfb7");
}
this.lastMealColor = foodType === "sweet" ? "#78b957" : "#f1dfb7";
const fromDuplicator = isDuplicatorFoodSource(it);
this.makePoop(normalPoopUnitsFor(foodType || it.type, eating));
if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2);
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; }
if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) {
this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] });
this.lastLog = this.world.time;
}
break;
}
}
if (canSweetBite && (foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && this.foodItemHasServingLeft(it)) {
const eating = this.consumeFoodServing(it);
if (eating <= 0) continue;
const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type);
this.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`);
this.vx *= Math.pow(0.25, dt * 60);
this.vy *= Math.pow(0.25, dt * 60);
const hungerRelief = this.foodHungerReliefFor(foodType || it.type, eating, 0.82);
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief);
this.recoverHealth(eating * 0.24);
this.affection += eating * 0.04;
const mealColors = {
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",
};
const mealBubbles = {
love_mochi: ["\u2661", 2.4, "rgba(190,82,132,0.78)"],
fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"],
sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"],
};
if (this.applyParamItemEffect) this.applyParamItemEffect(foodType || it.type, it, {
source: "eating",
nutrition: eating,
silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug",
});
if (mealBubbles[foodType]) this.bubble(...mealBubbles[foodType]);
this.lastMealColor = mealColors[foodType] || "#41aa66";
this.eatTimer = Math.max(this.eatTimer, 1.0);
this.eatCooldown = rand(0.96, 1.12);
this.foodReactTimer = Math.max(this.foodReactTimer, 0.16);
if (Math.random() < dt * 2.2) {
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (this.lastMealColor || "#41aa66"))));
}
const fromDuplicator = isDuplicatorFoodSource(it);
this.makePoop(normalPoopUnitsFor(foodType || it.type, eating));
if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2);
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; }
if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) {
this.world.log(`${this.name}\u306f${toolLabel(foodType || it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] });
this.lastLog = this.world.time;
}
break;
}
if (canBite && it.type === "grass") {
const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1));
const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale);
this.beginEating(it, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b");
this.grassEatTimer = Math.max(this.grassEatTimer, 1.0);
this.vx *= Math.pow(0.20, dt * 60);
this.vy *= Math.pow(0.20, dt * 60);
it.amount -= bite;
it.eatenAmount = (it.eatenAmount || 0) + bite;
it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2);
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46);
this.recoverHealth(bite * 0.18);
applyNeedRelief(this, { food: -dt * 2, fulfill: -dt * 1 });
this.eatTimer = Math.max(this.eatTimer, 1.0);
this.eatCooldown = rand(0.96, 1.14);
this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)");
if (Math.random() < dt * 2.0) {
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39");
}
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; }
this.makePoop(normalPoopUnitsFor("grass", bite));
if (this.type === "jito") this.affection += dt * 0.25;
break;
}
if (canBite && it.type === "ant_corpse") {
const bite = Math.min(it.amount, rand(3.2, 5.4));
if (bite > 0) {
this.beginEating(it, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b");
this.vx *= Math.pow(0.18, dt * 60);
this.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite;
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62);
this.recoverHealth(bite * 0.12);
applyNeedShock(this, { safety: bite * 0.12 });
this.eatTimer = Math.max(this.eatTimer, 1.0);
this.eatCooldown = rand(0.96, 1.16);
this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)");
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36");
this.lastMealColor = "#4a3f36";
this.makePoop(normalPoopUnitsFor("ant_corpse", bite));
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.62; }
break;
}
}
if (it.type === "zunchi") {
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1);
if (!this.isZunchiSlave) {
applyNeedShock(this, { health: touch * dt * 7, safety: touch * dt * 4 });
if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop.");
}
this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100);
const emergencyHunger = (this.hunger || 0) >= 104 || foodNeed >= 94;
const zunchiTooFresh = zunchiEatingBlocked(this, it, this.world) || (!emergencyHunger && !this.isZunchiSlave && it.stage === "fresh" && (it.age || 0) < 150 && foodNeed < 94 && (this.hunger || 0) < 104);
if (canZunchiBite && !zunchiTooFresh) {
const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1));
const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale);
this.beginEating(it, this.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");
this.vx *= Math.pow(0.18, dt * 60);
this.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite;
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * (this.isZunchiSlave ? 0.66 : 0.18)) : (this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18));
this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02));
applyNeedShock(this, { health: bite * (this.isZunchiSlave ? 0.2 : 1.2), safety: bite * (this.isZunchiSlave ? 0.1 : 0.6) });
if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop.");
this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100);
this.lastMealColor = "#6b8d3f";
this.eatTimer = Math.max(this.eatTimer, 1.0);
this.eatCooldown = rand(1.0, 1.22);
this.bubble("\u3082\u3050", 2.2, "rgba(74,91,50,0.78)");
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f");
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; }
break;
}
}
if (it.type === "water" || it.type === "water_bowl") {
this.thought = it.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b";
this.target = it;
this.applyWaterEffect(dt, it.type);
if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) {
audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 });
this.nextDrinkSound = (this.world?.time || 0) + 0.72;
}
if (it.type === "water") it.amount -= dt * 2.4;
if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18;
}
if (it.type === "stone") {
if (this.state === "idle" && this.stress > 48) this.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b";
applyNeedRelief(this, { safety: -dt * 4, fulfill: -dt * 2 });
this.energy += dt * 0.18;
}
if (this.isSleepFurniture(it)) {
if (this.energy < 74 + 6 * this.personalityProfile().sleep) {
const isEasyBed = it.type === "grass_bed";
const occ = this.world.bedOccupancy(it);
const comfort = this.world.bedComfort(it);
it.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined);
this.startSleeping?.(it, it.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 (this.world.time > this.nextSleepBubbleAt) {
this.nextSleepBubbleAt = this.world.time + 4.5;
const crowded = occ > 5;
this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
}
this.energy += dt * (1.9 + comfort * 1.4);
if (occ > 5) applyNeedShock(this, { safety: dt * 3 });
it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1);
}
}
if (it.type === "ball") {
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1);
if (this.state === "play_ball" || this.target === it || this.shouldApplyPersonalityBehavior("openness", 1)) {
this.goodMode = "smile";
applyNeedRelief(this, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) });
this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110);
if (this.state === "play_ball") this.foodReactTimer = Math.max(this.foodReactTimer, 0.10);
}
}
if (it.type === "splat") {
const touch = clamp(1 - d / (this.radius + it.r + 16), 0, 1);
if (touch > 0) {
applyNeedShock(this, { safety: touch * dt * 5, health: touch * dt * 3 });
if (touch > 0.06) this.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses");
}
}
if (it.type === "trace") {
this.loneliness -= dt * 0.25;
applyNeedShock(this, { social: -dt * 1, safety: dt * 0.8 });
}
}
this.hunger = clamp(this.hunger, 0, 115);
this.energy = clamp(this.energy, 0, 100);
this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds()));
this.loneliness = clamp(this.loneliness, 0, 110);
return window.TarinaiItemInteractionSystem?.updateOne?.(this, dt);
}
}));
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -70,22 +70,18 @@
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;
let drawX = Number.isFinite(this.x) ? this.x : (Number.isFinite(this.sunbathAnchorX) ? this.sunbathAnchorX : (Number.isFinite(lastX) ? lastX : pad));
let drawY = Number.isFinite(this.y) ? this.y : (Number.isFinite(this.sunbathAnchorY) ? this.sunbathAnchorY : (Number.isFinite(lastY) ? lastY : pad));
drawX = clamp(drawX, pad, Math.max(pad, worldW - pad));
drawY = clamp(drawY, pad, Math.max(pad, worldH - pad));
this.x = drawX;
this.y = drawY;
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;
@ -548,7 +544,7 @@
drawMeter(baseY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1));
}
if (hpActive) {
const pct = clamp(this.energy / 100, 0, 1);
const pct = typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp(this.energy / Math.max(1, this.maxEnergy || 100), 0, 1);
drawMeter(baseY - (stressActive ? 11 : 0), pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1));
}
}

35
js/tarinai_runtime.js Normal file
View file

@ -0,0 +1,35 @@
"use strict";
// Layer: entity-runtime/tarinai
// Owns the public Tarinai update boundary. Tarinai.prototype.update routes
// directly through the explicit creature update pipeline.
(function (global) {
function installPrototypeBoundary() {
const proto = global.Tarinai?.prototype;
if (!proto || proto._tarinaiCreatureRuntimeInstalled) return false;
proto.update = function update(dt) {
return global.TarinaiCreatureRuntime.updateOne(this, dt);
};
Object.defineProperty(proto, "_tarinaiCreatureRuntimeInstalled", {
value: true,
configurable: true,
writable: true,
});
return true;
}
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead || !global.TarinaiUpdatePipeline?.updateOne) return false;
return global.TarinaiUpdatePipeline.updateOne(tarinai, dt) !== false;
}
function updateBatch(tarinaiList, dt) {
if (!Array.isArray(tarinaiList)) return 0;
let ran = 0;
for (const t of tarinaiList) if (updateOne(t, dt)) ran += 1;
return ran;
}
global.TarinaiCreatureRuntime = Object.freeze({ installPrototypeBoundary, updateOne, updateBatch });
installPrototypeBoundary();
})(typeof window !== "undefined" ? window : globalThis);

154
js/tarinai_seed_factory.js Normal file
View file

@ -0,0 +1,154 @@
"use strict";
(function (global) {
function hashString(value = "") {
const str = String(value || "");
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(36);
}
function createWorldSeed() {
const cryptoObj = global.crypto;
if (cryptoObj?.getRandomValues) {
const buf = new Uint32Array(2);
cryptoObj.getRandomValues(buf);
return `w${buf[0].toString(36)}${buf[1].toString(36)}`;
}
return `w${Date.now().toString(36)}${Math.floor(Math.random() * 0xffffffff).toString(36)}`;
}
function ensureWorldSeed(worldRef = null) {
if (!worldRef) return createWorldSeed();
if (!worldRef.worldSeed) worldRef.worldSeed = createWorldSeed();
if (!Number.isFinite(worldRef.birthSerial)) worldRef.birthSerial = 0;
return worldRef.worldSeed;
}
function childSeed(worldSeed = "", serial = 0, parentSeeds = []) {
const safeSerial = Math.max(0, Math.floor(Number(serial) || 0));
const parents = (parentSeeds || []).filter(Boolean).join("+");
// 区切りを入れておくと、将来 birthSeed 自体を保存しない形式へ移る時に
// birthSerial を安全に取り出せる。
return `b${safeSerial.toString(36)}_${hashString(`${worldSeed}|${safeSerial}|${parents}`)}`;
}
function serialFromBirthSeed(seed = "") {
const match = /^b([0-9a-z]+)_/i.exec(String(seed || ""));
if (!match) return 0;
const n = parseInt(match[1], 36);
return Number.isFinite(n) && n > 0 ? n : 0;
}
function prepareAddOptions(worldRef, opts = {}) {
const next = { ...(opts || {}) };
ensureWorldSeed(worldRef);
if (!next.birthSeed) {
worldRef.birthSerial = Math.max(0, Number(worldRef.birthSerial) || 0) + 1;
next.birthSerial = worldRef.birthSerial;
const parentSeeds = Array.isArray(next.seedParents)
? next.seedParents.map(p => typeof p === "string" ? p : (p?.birthSeed || p?.familyKey || p?.id || ""))
: [];
next.birthSeed = childSeed(worldRef.worldSeed, next.birthSerial, parentSeeds);
} else {
if (!Number.isFinite(next.birthSerial)) {
const parsedSerial = serialFromBirthSeed(next.birthSeed);
if (parsedSerial > 0) next.birthSerial = parsedSerial;
}
if (Number.isFinite(next.birthSerial)) {
worldRef.birthSerial = Math.max(Number(worldRef.birthSerial) || 0, Number(next.birthSerial) || 0);
}
}
if (!next.familyKey) next.familyKey = next.birthSeed;
return next;
}
function seedUnit(seed = "", key = "") {
if (typeof stableUnit === "function") return stableUnit(seed, key);
return parseInt(hashString(`${seed}:${key}`).slice(0, 6), 36) / (36 ** 6);
}
function seedPick(seed = "", key = "", list = []) {
if (!list?.length) return null;
const i = Math.min(list.length - 1, Math.floor(seedUnit(seed, key) * list.length));
return list[i];
}
function seededName(seed = "") {
const len = 2 + Math.floor(seedUnit(seed, "name.len") * 6); // 2..7
const hasStop = len >= 2 && seedUnit(seed, "name.stop") < 0.30;
const middleLen = Math.max(0, len - 1 - (hasStop ? 1 : 0));
let name = "は";
for (let i = 0; i < middleLen; i++) name += seedUnit(seed, `name.u.${i}`) < 0.64 ? "う" : "ぅ";
if (hasStop) name += "っ";
return name;
}
function seededType(seed = "") {
const list = typeof spawnableSprites === "function" ? spawnableSprites().map(s => s.id).filter(Boolean) : ["smile"];
return seedPick(seed, "sprite.base", list) || "smile";
}
function seededGoodMode(seed = "") {
const list = typeof displayNormalSprites === "function" ? displayNormalSprites() : ["smile", "jito", "normal_smirk"];
return seedPick(seed, "sprite.good", list) || "smile";
}
function seededPersonality(seed = "") {
if (typeof randomBirthPersonality === "function") return randomBirthPersonality(seed);
return { aggression: 0, openness: 0, sociability: 0, neuroticism: 0 };
}
function seededGenetics(seed = "") {
if (typeof normalizeGenetics === "function") return normalizeGenetics(null, seed);
return { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 };
}
function seededAdultScale(seed = "", genetics = null) {
if (typeof adultScaleFromGenetics === "function") return adultScaleFromGenetics(seed, genetics);
return 0.28;
}
function seededLifeSpan(seed = "", genetics = null) {
const g = genetics || seededGenetics(seed);
const base = 1400 + seedUnit(seed, "life.span") * 800;
let span = base * (g.lifeSpanMul || 1);
if (span < 900) span = (1200 + seedUnit(seed, "life.span.low") * 600) * (g.lifeSpanMul || 1);
return span;
}
function birthProfile(seed = "", opts = {}) {
const zunchiSlave = Boolean(opts.isZunchiSlave || opts.zunchiSlaveLocked);
const genetics = seededGenetics(seed);
const personality = zunchiSlave && typeof zunchiSlavePersonalityValue === "function" ? zunchiSlavePersonalityValue() : seededPersonality(seed);
return {
birthSeed: seed,
name: zunchiSlave ? "ずんちどれい" : seededName(seed),
type: zunchiSlave ? "zunchi_slave" : seededType(seed),
goodMode: zunchiSlave ? "zunchi_slave" : seededGoodMode(seed),
birthPersonality: personality,
genetics,
adultScale: seededAdultScale(seed, genetics),
lifeSpan: seededLifeSpan(seed, genetics),
};
}
function personalityArray(seed = "") {
const p = birthProfile(seed).birthPersonality || {};
return ["aggression", "openness", "sociability", "neuroticism"].map(k => Math.round(((Number(p[k]) || 0) + 1) * 100));
}
global.TarinaiSeedFactory = Object.freeze({
createWorldSeed,
ensureWorldSeed,
childSeed,
serialFromBirthSeed,
prepareAddOptions,
birthProfile,
personalityArray,
hashString,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -71,12 +71,24 @@ 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 });
const maxFightContinueDistance = Math.max(150, (t.radius || 20) + (rival?.radius || 20) + 116);
if (!isLiveTarinaiEntity(rival) || dist(t, rival) > maxFightContinueDistance) {
t.fightTimer = 0;
t.fightTargetIds = [];
t.fightTargetId = null;
t.counterAttackFromId = null;
t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8));
if (isLiveTarinaiEntity(rival)) world.markFightPairCooldown?.(t, rival, CONFIG.fightCooldown + 6);
if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival");
t.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true });
return true;
}
t.setActionState?.("fight", { target: rival || t.target, reason: rival?.name ? `${rival.name}と喧嘩している` : "喧嘩している", 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));
const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, isCurrentBehaviorForced(t) ? 260 : 220));
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");

View file

@ -7,18 +7,22 @@
interactWithOthers(dt) {
let closeCount = 0;
const scanLimit = 8;
const selfRadius = Math.max(12, Number(this.radius || 22) || 22);
const scanRadius = Math.max(96, selfRadius * 2.4 + 88);
let scanned = 0;
for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) {
for (const o of this.world.nearbyTarinai(this.x, this.y, scanRadius, true)) {
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;
const otherRadius = Math.max(12, Number(o.radius || 22) || 22);
const bodyContactRange = Math.max(74, selfRadius + otherRadius + 18);
if (d < 0.01 || d > bodyContactRange) continue;
closeCount += 1;
const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
if (fightMochiContact && d <= Math.max(42, this.radius + o.radius + 16)) {
if (fightMochiContact && (this.world.fightPairCooldownRemaining?.(this, o) || 0) <= 0.04 && this.fightCooldown <= 0.04 && o.fightCooldown <= 0.04 && d <= Math.max(42, this.radius + o.radius + 16)) {
this.conflictTargetId = o.id;
o.conflictTargetId = this.id;
this.conflictUrge = Math.max(this.conflictUrge || 0, 92);
@ -26,8 +30,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: 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" });
queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 180, ttl: 3.2, retryDelay: 0.55, searchRange: 140, replaceSameSource: true, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 180, ttl: 3.2, retryDelay: 0.55, searchRange: 140, replaceSameSource: true, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
}
}
@ -69,7 +73,7 @@
this.vy += (dy / d) * dt * 4.8;
}
if (d < 60) {
if (d < Math.max(60, selfRadius + otherRadius + 8)) {
const rel = this.relationTo(o.id);
const pdef = this.personalityProfile();
this.loneliness -= dt * 1.65 * this.trait.social;
@ -192,19 +196,13 @@
if (this.state === "sunbath") {
this.target = null;
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.x = clamp(Number.isFinite(this.x) ? this.x : (Number.isFinite(this.sunbathAnchorX) ? this.sunbathAnchorX : pad), pad, safeW - pad);
this.y = clamp(Number.isFinite(this.y) ? this.y : (Number.isFinite(this.sunbathAnchorY) ? this.sunbathAnchorY : pad), pad, safeH - pad);
this.sunbathAnchorX = this.x;
this.sunbathAnchorY = this.y;
this._lastSunbathDrawX = this.x;
this._lastSunbathDrawY = this.y;
this._lastValidX = this.x;
@ -215,9 +213,10 @@
const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72;
const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0;
const crowdedSleep = bedCrowd > 5;
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100);
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) });
this.hunger = clamp(this.hunger + dt * 0.018, 0, 115);
const sleepHungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1);
this.hunger = clamp(this.hunger + dt * 0.018 * sleepHungerScale, 0, 115);
if (this.target && !this.target.dead && this.target.type === "nest_box") {
this.updateNestBoxPresence(dt);
this.vx = 0;
@ -375,10 +374,12 @@
this.vx += ax * dt;
this.vy += ay * dt;
this.vx *= Math.pow(0.84, dt * 8);
this.vy *= Math.pow(0.84, dt * 8);
const iceGround = (this.world?.groundType || "soil") === "ice";
const moveFriction = iceGround ? 0.965 : 0.84;
this.vx *= Math.pow(moveFriction, dt * 8);
this.vy *= Math.pow(moveFriction, dt * 8);
const maxV = (this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 76))) * Math.max(1, itemSpeedMul || 1) * (this.geneticStatRatio ? this.geneticStatRatio("speed") : 1);
const maxV = (this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 76))) * Math.max(1, itemSpeedMul || 1) * (this.geneticStatRatio ? this.geneticStatRatio("speed") : 1) * (iceGround ? 1.35 : 1);
const v = Math.hypot(this.vx, this.vy);
if (v > maxV) {
this.vx = this.vx / v * maxV;
@ -427,6 +428,17 @@
this.dead = true;
this.deathReason = finalReason;
this.world.deadCount += 1;
for (const item of this.world.items || []) {
if (!item || item.dead || !item.isStructure || item.type !== "plushie" || item.ownerId !== this.id) continue;
item.dead = true;
item.hp = 0;
item.amount = 0;
item.carriedById = "";
item.onHead = false;
}
this.world.drawListDirty = true;
this.world.markItemBucketsDirty?.("tarinai-death-plushie-cleanup");
this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup");
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) {

View file

@ -0,0 +1,49 @@
"use strict";
// Layer: entity-runtime/tarinai/update-system
// Owns active sunbath timer, sprite frame cycling, and recovery finish logic.
(function (global) {
function updateBody(dt) {
if ((this.sunbathTimer || 0) <= 0) return;
this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt);
this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt);
if ((this.sunbathFrameTimer || 0) <= 0) {
const prev = this._activeSunbathSpriteId || (this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant);
const next = this.randomSunbathSpriteId ? this.randomSunbathSpriteId(prev, { readyOnly: true }) : `sunbath_${1 + Math.floor(Math.random() * 3)}`;
if (next && next !== prev && (!this.isSunbathSpriteReady || this.isSunbathSpriteReady(next))) {
this._activeSunbathSpriteId = next;
this._lastRenderableSunbathSpriteId = next;
this.sunbathSpriteVariant = next;
this.visibleSpriteId = next;
this.spriteLockUntil = 0;
} else {
this.sunbathSpriteVariant = prev;
this.visibleSpriteId = prev;
}
this.sunbathFrameTimer = 0.5;
}
this.energy = clamp((this.energy || 0) + dt * 0.03, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
if (!this.isSunbathTime() || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0;
if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") {
this.finishSunbath?.();
this._activeSunbathSpriteId = "";
this.visibleSpriteId = "";
this.spriteLockUntil = 0;
this.sunbathAnchorX = null;
this.sunbathAnchorY = null;
this.goIdle("\u65e5\u5149\u6d74\u3092\u7d42\u3048\u305f");
}
}
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead) return false;
updateBody.call(tarinai, dt);
return true;
}
global.TarinaiSunbathSystem = Object.freeze({
updateBody,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,226 @@
"use strict";
// Layer: entity-runtime/tarinai/legacy
// Compatibility owner for the former monolithic Tarinai.prototype.update body.
// The normal runtime path uses TarinaiUpdatePipeline concrete steps; this module
// exists only as a stable fallback while update steps continue to be refined.
(function (global) {
function legacyUpdate(dt) {
if (this.dead) return;
this.prevX = this.x;
this.prevY = this.y;
const minute = dt / 60;
this.age += dt;
this.reproductionTimer -= dt;
this.blink += dt;
this.eatTimer = Math.max(0, this.eatTimer - dt);
if (this.state === "eat" && this.eatTimer <= 0.02) {
this.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true });
}
this.eatCooldown = Math.max(0, this.eatCooldown - dt);
this.foodReactTimer = Math.max(0, this.foodReactTimer - dt);
this.surpriseTimer = Math.max(0, this.surpriseTimer - dt);
this.fearTimer = Math.max(0, this.fearTimer - dt);
this.intimidateTimer = Math.max(0, this.intimidateTimer - dt);
this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt);
this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt);
this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt);
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt);
this.updateZunchiDisease(dt);
this.updateSpecialDiseases(dt);
if (this.updateItemEffects) this.updateItemEffects(dt);
if (this.dead) return;
this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt);
this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt);
if (this.fightMochiTimer > 0.04) {
applyNeedShock(this, { safety: dt * 4 });
if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10);
}
if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null;
if (this.birthRitualTimer > 0) {
const beforeBirthTimer = this.birthRitualTimer;
this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt);
if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) {
const partner = this.world.liveTarinaiById?.(this.birthPartnerId);
if (partner) this.world.finishBirthRitual(this, partner);
this.birthRitualLeader = false;
}
}
this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt);
this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt);
this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2);
this.pinchThoughtTimer = Math.max(0, (this.pinchThoughtTimer || 0) - dt);
this.sunbathCooldown = Math.max(0, (this.sunbathCooldown || 0) - dt);
if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt);
return;
}
this.applyCursorContactCare(dt);
const colonyMoodId = this.world?.colonyMood?.id || "relaxed";
if (colonyMoodId === "weaklings" && !this.isZunchiSlave) applyNeedRelief(this, { safety: -dt * 2, fulfill: -dt * 1 });
if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(this, { fulfill: -dt * 3 });
if (colonyMoodId === "ant_overrun" && this.state !== "panic") {
const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead);
if (antSeen) {
if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30a2\u30ea\u3060", fear: 1.1, wake: true, cause: "colony_ant_overrun" });
else { this.setActionState?.("panic", { target: null, reason: "\u30a2\u30ea\u3060", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); }
}
}
const wasFighting = this.fightTimer > 0.04;
this.fightTimer = Math.max(0, this.fightTimer - dt);
this.fightCooldown = Math.max(0, this.fightCooldown - dt);
this.groundStumbleCooldown = Math.max(0, (this.groundStumbleCooldown || 0) - dt);
this.defeatedTimer = Math.max(0, this.defeatedTimer - dt);
if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) {
const winner = this.world.liveTarinaiById?.(this.defeatedById) || null;
this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2));
this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear);
this.hurtTimer = Math.max(this.hurtTimer, 1.8);
this.fallTimer = Math.max(this.fallTimer, 1.15);
this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer);
this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir;
this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45);
if (winner) this.world.resolveFightOutcome(winner, this);
this.fightTargetIds = [];
this.fightTargetId = null;
}
this.stretchTimer = Math.max(0, this.stretchTimer - dt);
this.grassEatTimer = Math.max(0, this.grassEatTimer - dt);
this.hurtTimer = Math.max(0, this.hurtTimer - dt);
this.fallTimer = Math.max(0, this.fallTimer - dt);
if ((this.world?.groundType || "soil") === "foot_massage"
&& (this.groundStumbleCooldown || 0) <= 0.04
&& (this.fallTimer || 0) <= 0.04
&& !this.dead
&& !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(this.state || ""))
&& Math.random() < dt * 0.055) {
const dir = Math.random() < 0.5 ? -1 : 1;
this.groundStumbleCooldown = rand(8.5, 16.0);
this.fallTimer = Math.max(this.fallTimer || 0, rand(0.86, 1.18));
this.fallMax = Math.max(this.fallMax || 1.05, this.fallTimer);
this.fallDir = dir;
this.vx += dir * rand(26, 52);
this.vy += rand(-18, 18);
this.world?.spawnFallEffect?.(this.x, this.y + this.radius * 0.45);
this.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false });
if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)");
}
if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) {
this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)");
}
if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) {
this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)");
}
if (this.hurtTimer > 0 && Math.random() < dt * 5.2) {
const side = this.facingDir();
this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34));
}
this.tempTimer += dt;
if (this.tempTimer >= 0.55) {
this.tempComfort = this.world.temperatureAt(this.x, this.y);
this.tempTimer = 0;
}
const tempComfort = this.tempComfort ?? 0.62;
const tooCold = Math.max(0, 0.48 - tempComfort);
const hungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1);
this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * hungerScale * (1 + tooCold * 0.8);
this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social);
this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1);
// stress is derived from needs; no independent stress decay.
if (tooCold > 0) applyNeedShock(this, { health: tooCold * dt * 4, safety: tooCold * dt * 2 });
const phase = this.world.lightLevel();
if (phase < 0.25) this.energy += dt * 0.07;
if (phase > 0.75) applyNeedShock(this, { health: dt * 1.2 });
if (this.world.weather === "cloudy") {
applyNeedRelief(this, { safety: -dt * 1 });
this.vx *= Math.pow(0.996, dt * 60);
this.vy *= Math.pow(0.996, dt * 60);
} else if (this.world.weather === "light_rain") {
this.energy += dt * 0.018;
applyNeedRelief(this, { health: -dt * 1, safety: -dt * 1 });
} else if (this.world.weather === "sunny") {
this.vx *= Math.pow(1.001, dt * 60);
this.vy *= Math.pow(1.001, dt * 60);
}
if (this.generation > 1 && this.syncGrowthScale) this.syncGrowthScale();
this.hunger = clamp(this.hunger, 0, 115);
this.loneliness = clamp(this.loneliness, 0, 110);
this.energy = clamp(this.energy, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds()));
const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping;
this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt);
if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt);
return;
}
this.behaviorLockTimer = Math.max(0, (this.behaviorLockTimer || 0) - dt);
this.aiTimer += dt;
const urgentFoodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0;
const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02 || this.hunger >= 96 || urgentFoodNeed >= 84;
const aiJitter = stableUnit(this.familyKey || this.id, "ai-interval") * 0.35;
const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter);
if (this.aiTimer >= aiInterval && this.world.spendScheduledWork?.("ai", urgentAi) !== false) {
const elapsedAiDt = Math.max(0.016, Math.min(this.aiTimer, urgentAi ? 0.75 : 1.50));
const scanDt = Math.min(elapsedAiDt, 0.75);
this.aiTimer = 0;
// Needs intentionally advance by a fixed coarse step per AI update.
// Exact elapsed-time integration is unnecessary for this observation game
// and can over-amplify safety/fear transitions when updates are sparse.
const needStepDt = urgentAi ? 0.50 : 1.00;
this.resolveNeeds(needStepDt);
const sleepingNow = this.state === "sleep";
if (!sleepingNow) {
this.reactToFreshZunchi(scanDt);
this.updateFacing();
this.interactWithItems(scanDt);
this.interactWithOthers(scanDt);
} else {
this.updateFacing();
}
} else {
this.updateFacing();
}
this.envTimer += dt;
const envInterval = 3.0 + stableUnit(this.familyKey || this.id, "env-phase") * 1.4;
if (this.envTimer >= envInterval && this.world.spendScheduledWork?.("env", urgentAi || this.world.selected === this) !== false) {
const envDt = Math.min(this.envTimer, 1.2);
this.envTimer = 0;
this.applyEnvironment(envDt);
}
this.updateSunbath(dt);
this.maintainTargetProgress(dt);
this.move(dt);
if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt);
this.updateNestBoxPresence(dt);
if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this);
const sleepingAtFrameEnd = this.state === "sleep" || this.sleeping;
if (wasSleepingAtFrameStart && !sleepingAtFrameEnd && !this.dead) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10);
this.checkLife(dt);
}
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead) return false;
legacyUpdate.call(tarinai, dt);
return true;
}
function updateWithThis(dt) {
return updateOne(this, dt);
}
global.TarinaiLegacyUpdateSystem = Object.freeze({
legacyUpdate,
updateOne,
updateWithThis,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,41 @@
"use strict";
// Layer: entity-runtime/tarinai/pipeline
// Owns the ordered Tarinai update pipeline. There is no monolithic fallback path:
// creature behavior must be handled by the explicit update steps below.
(function (global) {
const CONCRETE_STEP_NAMES = Object.freeze([
"TarinaiFrameUpdateStep",
"TarinaiAiUpdateStep",
"TarinaiEnvironmentUpdateStep",
"TarinaiMovementUpdateStep",
"TarinaiHealthUpdateStep",
]);
function concreteSteps() {
return CONCRETE_STEP_NAMES.map(name => global[name]).filter(step => step && typeof step.update === "function");
}
function canRunConcretePipeline() {
return concreteSteps().length === CONCRETE_STEP_NAMES.length;
}
function updateOne(tarinai, dt, options = {}) {
if (!tarinai || tarinai.dead || !canRunConcretePipeline()) return false;
const context = {
...(options.context || {}),
wasSleepingAtFrameStart: false,
};
for (const step of concreteSteps()) {
const result = step.update(tarinai, dt, context) || {};
if (result.done) return result.ok !== false;
}
return true;
}
global.TarinaiUpdatePipeline = Object.freeze({
stepNames: CONCRETE_STEP_NAMES,
canRunConcretePipeline,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,29 @@
"use strict";
// Layer: entity-runtime/tarinai/policy
// Owns Tarinai update cadence decisions. The simulation creature system asks
// this policy when an individual should run at realtime vs low-frequency cadence.
(function (global) {
const helpers = () => global.TarinaiSimulationRuntime?.helpers || {};
function updateInterval(worldRef, tarinai, visibleRect) {
if (!tarinai || tarinai.dead) return Infinity;
if (worldRef.selected === tarinai) return 0;
if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || (tarinai.entryTimer || 0) > 0.01) return 0;
if (helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, visibleRect)) return 0;
const count = (worldRef.tarinai || []).length;
const tier = global.TarinaiPerf?.renderQualityTier?.() || "high";
if (tier === "high" && count <= 36) return 0;
const farRect = helpers().updateVisibleWorldRect?.(worldRef, 520) || visibleRect;
return helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70;
}
function shouldRunRealtime(worldRef, tarinai, visibleRect) {
return updateInterval(worldRef, tarinai, visibleRect) <= 0;
}
global.TarinaiCreatureUpdatePolicy = Object.freeze({
updateInterval,
shouldRunRealtime,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,41 @@
"use strict";
// Layer: entity-runtime/tarinai/update-step
// Owns low-frequency AI cadence, need resolution, item/social interaction scans,
// and facing refresh. Extracted from Tarinai.prototype.update.
(function (global) {
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
t.behaviorLockTimer = Math.max(0, (t.behaviorLockTimer || 0) - dt);
t.aiTimer += dt;
const urgentFoodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0;
const urgentAi = t.fightTimer > 0.04 || t.intimidateTimer > 0.04 || t.intimidatedTimer > 0.04 || t.birthRitualTimer > 0.04 || t.defeatedTimer > 0.04 || t.eatTimer > 0.02 || t.hurtTimer > 0.02 || t.hunger >= 96 || urgentFoodNeed >= 84;
const aiJitter = stableUnit(t.familyKey || t.id, "ai-interval") * 0.35;
context.urgentAi = urgentAi;
const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter);
if (t.aiTimer >= aiInterval && t.world.spendScheduledWork?.("ai", urgentAi) !== false) {
const elapsedAiDt = Math.max(0.016, Math.min(t.aiTimer, urgentAi ? 0.75 : 1.50));
const scanDt = Math.min(elapsedAiDt, 0.75);
t.aiTimer = 0;
// Needs intentionally advance by a fixed coarse step per AI update.
// Exact elapsed-time integration is unnecessary for this observation game
// and can over-amplify safety/fear transitions when updates are sparse.
const needStepDt = urgentAi ? 0.50 : 1.00;
t.resolveNeeds(needStepDt);
const sleepingNow = t.state === "sleep";
if (!sleepingNow) {
t.reactToFreshZunchi(scanDt);
t.updateFacing();
TarinaiItemInteractionSystem.updateOne(t, scanDt);
t.interactWithOthers(scanDt);
} else {
t.updateFacing();
}
} else {
t.updateFacing();
}
return { done: false };
}
global.TarinaiAiUpdateStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,21 @@
"use strict";
// Layer: entity-runtime/tarinai/update-step
// Owns low-frequency local environment effects around a Tarinai.
(function (global) {
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
t.envTimer += dt;
const urgentAi = Boolean(context.urgentAi);
const envInterval = 3.0 + stableUnit(t.familyKey || t.id, "env-phase") * 1.4;
if (t.envTimer >= envInterval && t.world.spendScheduledWork?.("env", urgentAi || t.world.selected === t) !== false) {
const envDt = Math.min(t.envTimer, 1.2);
t.envTimer = 0;
if (global.TarinaiLocalEnvironmentSystem?.updateOne) global.TarinaiLocalEnvironmentSystem.updateOne(t, envDt);
else t.applyEnvironment?.(envDt);
}
return { done: false };
}
global.TarinaiEnvironmentUpdateStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,169 @@
"use strict";
// Layer: entity-runtime/tarinai/update-step
// Owns per-frame timers, passive metabolism, disease/effect ticks, colony mood
// pressure, and early freeze exits. This is extracted from the former
// Tarinai.prototype.update body without changing behavior.
(function (global) {
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
t.prevX = t.x;
t.prevY = t.y;
const minute = dt / 60;
t.age += dt;
t.reproductionTimer -= dt;
t.blink += dt;
t.eatTimer = Math.max(0, t.eatTimer - dt);
if (t.state === "eat" && t.eatTimer <= 0.02) {
t.setActionState?.("idle", { target: null, reason: "食べ終えた", sleeping: false, clearTarget: true });
}
t.eatCooldown = Math.max(0, t.eatCooldown - dt);
t.foodReactTimer = Math.max(0, t.foodReactTimer - dt);
t.surpriseTimer = Math.max(0, t.surpriseTimer - dt);
t.fearTimer = Math.max(0, t.fearTimer - dt);
t.intimidateTimer = Math.max(0, t.intimidateTimer - dt);
t.intimidatedTimer = Math.max(0, t.intimidatedTimer - dt);
t.blastSpinTimer = Math.max(0, (t.blastSpinTimer || 0) - dt);
t.postBirthPeaceTimer = Math.max(0, (t.postBirthPeaceTimer || 0) - dt);
t.hpBarTimer = Math.max(0, (t.hpBarTimer || 0) - dt);
t.stressBarTimer = Math.max(0, (t.stressBarTimer || 0) - dt);
t.updateZunchiDisease(dt);
t.updateSpecialDiseases(dt);
if (t.updateItemEffects) t.updateItemEffects(dt);
if (t.dead) return { done: true };
t.loveMochiTimer = Math.max(0, (t.loveMochiTimer || 0) - dt);
t.fightMochiTimer = Math.max(0, (t.fightMochiTimer || 0) - dt);
if (t.fightMochiTimer > 0.04) {
applyNeedShock(t, { safety: dt * 4 });
if (t.state !== "fight") t.fearTimer = Math.max(t.fearTimer || 0, 0.10);
}
if (t.intimidateTimer <= 0.04) t.intimidateTargetId = null;
if (t.birthRitualTimer > 0) {
const beforeBirthTimer = t.birthRitualTimer;
t.birthRitualTimer = Math.max(0, t.birthRitualTimer - dt);
if (beforeBirthTimer > 0 && t.birthRitualTimer <= 0.04 && t.birthRitualLeader) {
const partner = t.world.liveTarinaiById?.(t.birthPartnerId);
if (partner) t.world.finishBirthRitual(t, partner);
t.birthRitualLeader = false;
}
}
t.pokeFlashTimer = Math.max(0, t.pokeFlashTimer - dt);
t.focusPulseTimer = Math.max(0, (t.focusPulseTimer || 0) - dt);
t.cursorPetting = Math.max(0, t.cursorPetting - dt * 1.2);
t.pinchThoughtTimer = Math.max(0, (t.pinchThoughtTimer || 0) - dt);
t.sunbathCooldown = Math.max(0, (t.sunbathCooldown || 0) - dt);
if (t.freezeSleepDiseaseMotion(dt)) {
t.checkLife(dt);
return { done: true };
}
if (global.TarinaiCursorCareSystem?.updateOne) global.TarinaiCursorCareSystem.updateOne(t, dt);
else t.applyCursorContactCare?.(dt);
const colonyMoodId = t.world?.colonyMood?.id || "relaxed";
if (colonyMoodId === "weaklings" && !t.isZunchiSlave) applyNeedRelief(t, { safety: -dt * 2, fulfill: -dt * 1 });
if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(t, { fulfill: -dt * 3 });
if (colonyMoodId === "ant_overrun" && t.state !== "panic") {
const antSeen = (t.world?.nearbyAnts?.(t.x, t.y, 110, true) || []).some(a => a && !a.dead);
if (antSeen) {
if (t.enterPanic) t.enterPanic({ target: null, reason: "アリだ", fear: 1.1, wake: true, cause: "colony_ant_overrun" });
else { t.setActionState?.("panic", { target: null, reason: "アリだ", wake: true }); t.fearTimer = Math.max(t.fearTimer || 0, 1.1 * t.personalityProfile().fear); }
}
}
const wasFighting = t.fightTimer > 0.04;
t.fightTimer = Math.max(0, t.fightTimer - dt);
t.fightCooldown = Math.max(0, t.fightCooldown - dt);
t.groundStumbleCooldown = Math.max(0, (t.groundStumbleCooldown || 0) - dt);
t.defeatedTimer = Math.max(0, t.defeatedTimer - dt);
if (wasFighting && t.fightTimer <= 0.04 && t.defeatedById) {
const winner = t.world.liveTarinaiById?.(t.defeatedById) || null;
t.defeatedTimer = Math.max(t.defeatedTimer, rand(3.4, 5.2));
t.fearTimer = Math.max(t.fearTimer, 1.0 * t.personalityProfile().fear);
t.hurtTimer = Math.max(t.hurtTimer, 1.8);
t.fallTimer = Math.max(t.fallTimer, 1.15);
t.fallMax = Math.max(t.fallMax || 1.15, t.fallTimer);
t.fallDir = winner ? (t.x < winner.x ? -1 : 1) : t.fallDir;
t.world.spawnFallEffect(t.x, t.y + t.radius * 0.45);
if (winner) t.world.resolveFightOutcome(winner, t);
t.fightTargetIds = [];
t.fightTargetId = null;
}
t.stretchTimer = Math.max(0, t.stretchTimer - dt);
t.grassEatTimer = Math.max(0, t.grassEatTimer - dt);
t.hurtTimer = Math.max(0, t.hurtTimer - dt);
t.fallTimer = Math.max(0, t.fallTimer - dt);
if ((t.world?.groundType || "soil") === "foot_massage"
&& (t.groundStumbleCooldown || 0) <= 0.04
&& (t.fallTimer || 0) <= 0.04
&& !t.dead
&& !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(t.state || ""))
&& Math.random() < dt * 0.055) {
const dir = Math.random() < 0.5 ? -1 : 1;
t.groundStumbleCooldown = rand(8.5, 16.0);
t.fallTimer = Math.max(t.fallTimer || 0, rand(0.86, 1.18));
t.fallMax = Math.max(t.fallMax || 1.05, t.fallTimer);
t.fallDir = dir;
t.vx += dir * rand(26, 52);
t.vy += rand(-18, 18);
t.world?.spawnFallEffect?.(t.x, t.y + t.radius * 0.45);
t.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false });
if ((t.world?.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)");
}
if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && Math.random() < dt * 0.050) {
t.bubble("はっ...はっ...", 3.4, "rgba(58,48,63,0.80)");
}
if (t.state === "idle" && t.stress < 78 && Math.random() < dt * 0.018) {
t.bubble(pick(["はう", "はうぅ", "はうっ", "はぅ"]), 4.2, "rgba(62,84,45,0.76)");
}
if (t.hurtTimer > 0 && Math.random() < dt * 5.2) {
const side = t.facingDir();
t.world.spawnBleedEffect(t.x - side * t.radius * rand(0.15, 0.42), t.y - t.radius * rand(0.08, 0.34));
}
t.tempTimer += dt;
if (t.tempTimer >= 0.55) {
t.tempComfort = t.world.temperatureAt(t.x, t.y);
t.tempTimer = 0;
}
const tempComfort = t.tempComfort ?? 0.62;
const tooCold = Math.max(0, 0.48 - tempComfort);
const hungerScale = t.metabolicHungerMultiplier ? t.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(t) : 1);
t.hunger += CONFIG.hungerPerMinute * minute * t.trait.hunger * hungerScale * (1 + tooCold * 0.8);
t.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - t.trait.social);
t.energy -= CONFIG.energyPerMinute * minute * t.trait.sleep * (t.state === "sleep" ? 0.025 : 1);
// stress is derived from needs; no independent stress decay.
if (tooCold > 0) applyNeedShock(t, { health: tooCold * dt * 4, safety: tooCold * dt * 2 });
const phase = t.world.lightLevel();
if (phase < 0.25) t.energy += dt * 0.07;
if (phase > 0.75) applyNeedShock(t, { health: dt * 1.2 });
if (t.world.weather === "cloudy") {
applyNeedRelief(t, { safety: -dt * 1 });
t.vx *= Math.pow(0.996, dt * 60);
t.vy *= Math.pow(0.996, dt * 60);
} else if (t.world.weather === "light_rain") {
t.energy += dt * 0.018;
applyNeedRelief(t, { health: -dt * 1, safety: -dt * 1 });
} else if (t.world.weather === "sunny") {
t.vx *= Math.pow(1.001, dt * 60);
t.vy *= Math.pow(1.001, dt * 60);
}
if (t.generation > 1 && t.syncGrowthScale) t.syncGrowthScale();
t.hunger = clamp(t.hunger, 0, 115);
t.loneliness = clamp(t.loneliness, 0, 110);
t.energy = clamp(t.energy, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100));
t.stress = applyGroundStressModifier(t, calculateStressFromNeeds(t.needRaw || t.needs || createDefaultNeeds()));
context.wasSleepingAtFrameStart = t.state === "sleep" || t.sleeping;
t.awakeLockTimer = Math.max(0, (t.awakeLockTimer || 0) - dt);
if (t.freezeSleepDiseaseMotion(dt)) {
t.checkLife(dt);
return { done: true };
}
return { done: false };
}
global.TarinaiFrameUpdateStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,15 @@
"use strict";
// Layer: entity-runtime/tarinai/update-step
// Owns end-of-frame wake lock and life/death checks.
(function (global) {
function update(t, dt, context = {}) {
if (!t) return { done: true };
const sleepingAtFrameEnd = t.state === "sleep" || t.sleeping;
if (context.wasSleepingAtFrameStart && !sleepingAtFrameEnd && !t.dead) t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 10);
t.checkLife(dt);
return { done: true };
}
global.TarinaiHealthUpdateStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,14 @@
"use strict";
// Layer: entity-runtime/tarinai/update-step
// Fallback step for environments where the split update pipeline cannot run.
(function (global) {
function update(t, dt, context = {}) {
const legacyUpdate = context.legacyUpdate;
if (typeof legacyUpdate !== "function") return { done: true, ok: false };
legacyUpdate.call(t, dt);
return { done: true, ok: true };
}
global.TarinaiLegacyUpdateStep = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,58 @@
"use strict";
// Layer: entity-runtime/tarinai/update-step
// Owns sunbath/runtime movement, collisions, and nest-box occupancy refresh.
(function (global) {
const SHOCK_DAMAGE_DELTA_THRESHOLD = 205;
const SHOCK_DAMAGE_COOLDOWN = 0.46;
function combinedVelocity(t) {
return {
vx: (Number.isFinite(t?.vx) ? t.vx : 0) + (Number.isFinite(t?.impulseVx) ? t.impulseVx : 0),
vy: (Number.isFinite(t?.vy) ? t.vy : 0) + (Number.isFinite(t?.impulseVy) ? t.impulseVy : 0),
};
}
function applyVelocityShockDamage(t, before, dt) {
if (!t || t.dead) return false;
const now = t.world?.time || 0;
const after = combinedVelocity(t);
const baseline = before || (Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY)
? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY }
: after);
const delta = Math.hypot(after.vx - baseline.vx, after.vy - baseline.vy);
t._lastPhysicalVelocityX = after.vx;
t._lastPhysicalVelocityY = after.vy;
if (delta < SHOCK_DAMAGE_DELTA_THRESHOLD) return false;
if (now - (t.lastVelocityShockDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
if (now - (t.lastPhysicalCollisionDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
const amount = Math.min(42, Math.max(1.0, (delta - SHOCK_DAMAGE_DELTA_THRESHOLD) / 14));
t.lastVelocityShockDamageAt = now;
t.world?.applyImpactDamage?.(t, amount, "急激な速度変化", { cause: "衝突" }) || t.damage?.(amount, "衝突");
t.hurtTimer = Math.max(t.hurtTimer || 0, amount > 15 ? 1.9 : 0.9);
t.fallTimer = Math.max(t.fallTimer || 0, amount > 12 ? 0.72 : 0.28);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer || 0);
t.fearTimer = Math.max(t.fearTimer || 0, 0.45);
if (t.world?.spawnFallEffect) t.world.spawnFallEffect(t.x, t.y + (t.radius || 22) * 0.55, Math.min(1.8, Math.max(0.5, amount / 18)));
return true;
}
function update(t, dt) {
if (!t || t.dead) return { done: true };
const current = combinedVelocity(t);
const before = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY)
? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY }
: current;
if (global.TarinaiSunbathSystem?.updateOne) global.TarinaiSunbathSystem.updateOne(t, dt);
else t.updateSunbath?.(dt);
t.maintainTargetProgress(dt);
t.move(dt);
if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(dt);
applyVelocityShockDamage(t, before, dt);
t.updateNestBoxPresence(dt);
if (t.world.resolveFenceCollision) t.world.resolveFenceCollision(t);
return { done: false };
}
global.TarinaiMovementUpdateStep = Object.freeze({ update, applyVelocityShockDamage });
})(typeof window !== "undefined" ? window : globalThis);

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