stable
This commit is contained in:
parent
fbdac9ebf1
commit
86226ccc94
97 changed files with 5790 additions and 2307 deletions
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "16.07.32",
|
||||
"version": "16.07.50",
|
||||
"css": [
|
||||
"css/base.css",
|
||||
"css/layout.css",
|
||||
|
|
@ -28,7 +28,25 @@
|
|||
"js/structures.js",
|
||||
"js/items.js",
|
||||
"js/item_type_initializers.js",
|
||||
"js/item_lifecycle_support.js",
|
||||
"js/item_lifecycle_legacy_system.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_step_legacy.js",
|
||||
"js/item_lifecycle_pipeline.js",
|
||||
"js/item_runtime.js",
|
||||
"js/structure_lifecycle.js",
|
||||
"js/item_render_runtime.js",
|
||||
"js/ants.js",
|
||||
|
|
@ -49,7 +67,26 @@
|
|||
"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_update_legacy_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_step_legacy.js",
|
||||
"js/tarinai_update_pipeline.js",
|
||||
"js/tarinai_runtime.js",
|
||||
"js/tarinai_render.js",
|
||||
"js/world.js",
|
||||
"js/world_view.js",
|
||||
|
|
@ -61,16 +98,29 @@
|
|||
"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/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/save_codec.js",
|
||||
|
|
@ -106,5 +156,5 @@
|
|||
"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": "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. Boundary cleanup v1: save schema constants are centralized in js/save_schema.js and World.update now routes through js/simulation.js before the phase runner. Boundary cleanup v2: basic UI tool and selection changes route through js/command_dispatcher.js. Boundary cleanup v3: UI pointer, focus, favorite, pause, speed, and tool-size changes route through command_dispatcher; simulation order is named in js/system_order.js. Boundary cleanup v4: concrete world update phase work is extracted into js/simulation_systems.js; world_update_phases.js is now a compatibility facade. Boundary cleanup v5: simulation_systems.js is reduced to a facade; runtime helpers, environment, item/ant, effects, creature, maintenance, and ambient spawn systems are split into dedicated files. Boundary cleanup v6: item and Tarinai update entrypoints now route through js/item_runtime.js and js/tarinai_runtime.js before the legacy lifecycle implementations. Boundary cleanup v7: item update cadence is centralized in js/item_update_policy.js; Tarinai update cadence is centralized in js/tarinai_update_policy.js; scheduler and simulation helpers now consume those policies. Boundary cleanup v8: Tarinai update now routes through js/tarinai_update_pipeline.js with frame, AI, environment, movement, health, and legacy fallback steps. Boundary cleanup v9: Item lifecycle now routes through js/item_lifecycle_pipeline.js with frame, decay, dynamic, growth, and legacy fallback steps. Boundary cleanup v10: dynamic item behavior dispatch is split into js/item_dynamic_system.js plus tool, ball, duplicator, pin, and zunchi submodules; item_lifecycle_step_dynamic.js now calls that boundary. Boundary cleanup v11: fan/magnet/rotator, ball physics, duplicator loading, pin attachment, and zunchi motion/lifecycle implementations now live in the dynamic subsystem files; Item.prototype keeps thin compatibility facades. Boundary cleanup v12: item lifecycle support helpers and legacy fallback moved out of item_lifecycle_runtime.js; decay and growth implementation moved into dedicated item_lifecycle_*_system modules while lifecycle steps remain thin pipeline adapters. Boundary cleanup v13: Tarinai item interaction is extracted into js/tarinai_item_interaction_system.js; AI update step now calls that system directly while Tarinai.prototype keeps a thin compatibility facade. Boundary cleanup v14: remaining Tarinai interaction/planning cleanup is consolidated; item interaction scan/context, food consumption, and contact effects are split into dedicated modules, and resolveNeeds now routes through explicit need/action/forced planner boundaries while keeping legacy behavior as a fallback system. Boundary cleanup v15: Tarinai item interaction orchestration preserves the legacy per-candidate order by trying food consumption and then contact effects for each candidate before advancing."
|
||||
}
|
||||
|
|
|
|||
110
css/base.css
110
css/base.css
|
|
@ -580,3 +580,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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
.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"; }
|
||||
|
|
@ -24,7 +25,8 @@
|
|||
|
||||
.tool[data-tool="poke"].tool-emoji-icon::before,
|
||||
.tool[data-tool="water_hose"].tool-emoji-icon::before { font-size: 25px; }
|
||||
.tool[data-tool="delete"] { --tool-icon-zoom: 1.42; }
|
||||
.tool[data-tool="delete"],
|
||||
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; }
|
||||
.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; }
|
||||
.tool[data-tool="observe"],
|
||||
.tool[data-tool="new"] { --tool-icon-zoom: 1.20; }
|
||||
|
|
|
|||
240
index.html
240
index.html
|
|
@ -7,11 +7,11 @@
|
|||
<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=16.07.32" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=16.07.32" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=16.07.32" />
|
||||
<link rel="stylesheet" href="css/components.css?v=16.07.32" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=16.07.32" />
|
||||
<link rel="stylesheet" href="css/base.css?v=16.07.50" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=16.07.50" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=16.07.50" />
|
||||
<link rel="stylesheet" href="css/components.css?v=16.07.50" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=16.07.50" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
||||
|
|
@ -95,7 +95,7 @@
|
|||
<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" data-ground-toggle role="button" tabindex="0"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="地面をきりかえる"><strong id="statGroundType">土</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="地面をきりかえる"><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="objects">物</button></div>
|
||||
<div id="colonyChartLegend" class="colony-chart-legend"></div>
|
||||
|
|
@ -189,94 +189,144 @@
|
|||
</div>
|
||||
|
||||
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
|
||||
<script src="js/version.js?v=16.07.32" defer></script>
|
||||
<script src="js/event_bus.js?v=16.07.32" defer></script>
|
||||
<script src="js/registry_base.js?v=16.07.32" defer></script>
|
||||
<script src="js/disease_registry.js?v=16.07.32" defer></script>
|
||||
<script src="js/sound_pack.js?v=16.07.32" defer></script>
|
||||
<script src="js/item_registry.js?v=16.07.32" defer></script>
|
||||
<script src="js/data.js?v=16.07.32" defer></script>
|
||||
<script src="js/ground_types.js?v=16.07.32" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=16.07.32" defer></script>
|
||||
<script src="js/math.js?v=16.07.32" defer></script>
|
||||
<script src="js/physics_helpers.js?v=16.07.32" defer></script>
|
||||
<script src="js/assets.js?v=16.07.32" defer></script>
|
||||
<script src="js/audio.js?v=16.07.32" defer></script>
|
||||
<script src="js/perf_profiler.js?v=16.07.32" defer></script>
|
||||
<script src="js/render.js?v=16.07.32" defer></script>
|
||||
<script src="js/sim_core.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=16.07.32" defer></script>
|
||||
<script src="js/structures.js?v=16.07.32" defer></script>
|
||||
<script src="js/items.js?v=16.07.32" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=16.07.32" defer></script>
|
||||
<script src="js/item_lifecycle_runtime.js?v=16.07.32" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=16.07.32" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=16.07.32" defer></script>
|
||||
<script src="js/ants.js?v=16.07.32" defer></script>
|
||||
<script src="js/health.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=16.07.32" defer></script>
|
||||
<script src="js/tarinai_render.js?v=16.07.32" defer></script>
|
||||
<script src="js/world.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_view.js?v=16.07.32" defer></script>
|
||||
<script src="js/family_graph.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_family_social.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_environment.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_ants_system.js?v=16.07.32" defer></script>
|
||||
<script src="js/weather_system.js?v=16.07.32" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_update_phases.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_update.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_placement_log.js?v=16.07.32" defer></script>
|
||||
<script src="js/world_event_effects.js?v=16.07.32" defer></script>
|
||||
<script src="js/text_catalog.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_log.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_helpers.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_selected.js?v=16.07.32" defer></script>
|
||||
<script src="js/snapshot_system.js?v=16.07.32" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=16.07.32" defer></script>
|
||||
<script src="js/save_codec.js?v=16.07.32" defer></script>
|
||||
<script src="js/save_storage.js?v=16.07.32" defer></script>
|
||||
<script src="js/save_system.js?v=16.07.32" defer></script>
|
||||
<script src="js/patch_helpers.js?v=16.07.32" defer></script>
|
||||
<script src="js/freeze_store.js?v=16.07.32" defer></script>
|
||||
<script src="js/freeze_snapshot_adapter.js?v=16.07.32" defer></script>
|
||||
<script src="js/freeze_panel.js?v=16.07.32" defer></script>
|
||||
<script src="js/freeze_system.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_ground.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_tools.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_bind.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_charts.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_family_data.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_family_async.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=16.07.32" defer></script>
|
||||
<script src="js/ui_family_render.js?v=16.07.32" defer></script>
|
||||
<script src="js/main.js?v=16.07.32" defer></script>
|
||||
<script src="js/debug_tools.js?v=16.07.32" defer></script>
|
||||
<script src="js/version.js?v=16.07.50" defer></script>
|
||||
<script src="js/event_bus.js?v=16.07.50" defer></script>
|
||||
<script src="js/registry_base.js?v=16.07.50" defer></script>
|
||||
<script src="js/disease_registry.js?v=16.07.50" defer></script>
|
||||
<script src="js/sound_pack.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_registry.js?v=16.07.50" defer></script>
|
||||
<script src="js/data.js?v=16.07.50" defer></script>
|
||||
<script src="js/ground_types.js?v=16.07.50" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=16.07.50" defer></script>
|
||||
<script src="js/math.js?v=16.07.50" defer></script>
|
||||
<script src="js/physics_helpers.js?v=16.07.50" defer></script>
|
||||
<script src="js/assets.js?v=16.07.50" defer></script>
|
||||
<script src="js/audio.js?v=16.07.50" defer></script>
|
||||
<script src="js/perf_profiler.js?v=16.07.50" defer></script>
|
||||
<script src="js/render.js?v=16.07.50" defer></script>
|
||||
<script src="js/sim_core.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=16.07.50" defer></script>
|
||||
<script src="js/structures.js?v=16.07.50" defer></script>
|
||||
<script src="js/items.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_legacy_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_runtime.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_update_policy.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_step_legacy.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_runtime.js?v=16.07.50" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=16.07.50" defer></script>
|
||||
<script src="js/ants.js?v=16.07.50" defer></script>
|
||||
<script src="js/health.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_forced_action_planner_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_action_planner_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_legacy_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_step_legacy.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_render.js?v=16.07.50" defer></script>
|
||||
<script src="js/world.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_view.js?v=16.07.50" defer></script>
|
||||
<script src="js/family_graph.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_family_social.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_environment.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_ants_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/weather_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=16.07.50" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation_systems.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_update_phases.js?v=16.07.50" defer></script>
|
||||
<script src="js/system_order.js?v=16.07.50" defer></script>
|
||||
<script src="js/simulation.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_update.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_placement_log.js?v=16.07.50" defer></script>
|
||||
<script src="js/world_event_effects.js?v=16.07.50" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=16.07.50" defer></script>
|
||||
<script src="js/text_catalog.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_log.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_helpers.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_selected.js?v=16.07.50" defer></script>
|
||||
<script src="js/save_schema.js?v=16.07.50" defer></script>
|
||||
<script src="js/snapshot_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=16.07.50" defer></script>
|
||||
<script src="js/save_codec.js?v=16.07.50" defer></script>
|
||||
<script src="js/save_storage.js?v=16.07.50" defer></script>
|
||||
<script src="js/save_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/patch_helpers.js?v=16.07.50" defer></script>
|
||||
<script src="js/freeze_store.js?v=16.07.50" defer></script>
|
||||
<script src="js/freeze_snapshot_adapter.js?v=16.07.50" defer></script>
|
||||
<script src="js/freeze_panel.js?v=16.07.50" defer></script>
|
||||
<script src="js/freeze_system.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_ground.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_tools.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_bind.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_charts.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_family_data.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_family_async.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=16.07.50" defer></script>
|
||||
<script src="js/ui_family_render.js?v=16.07.50" defer></script>
|
||||
<script src="js/main.js?v=16.07.50" defer></script>
|
||||
<script src="js/debug_tools.js?v=16.07.50" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
192
js/command_dispatcher.js
Normal file
192
js/command_dispatcher.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"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");
|
||||
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);
|
||||
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,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
"use strict";
|
||||
|
||||
const GROUND_TYPE_ORDER = Object.freeze(["soil", "concrete", "blanket", "foot_massage", "ice"]);
|
||||
const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice"]);
|
||||
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 }),
|
||||
ice: Object.freeze({ id: "ice", label: "氷", description: "つるつる滑る。草は生えにくい", stressMultiplier: 1.1, grassMultiplier: 0.15 }),
|
||||
// 既存セーブとの互換性のため、デフォルトIDは soil のままにし、表示名だけ「砂」にする。
|
||||
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 }),
|
||||
});
|
||||
|
||||
function groundDefinition(id = "soil") {
|
||||
|
|
@ -18,7 +20,7 @@ function groundExists(id = "soil") {
|
|||
}
|
||||
|
||||
function groundLabel(id = "soil") {
|
||||
return groundDefinition(id).label || "土";
|
||||
return groundDefinition(id).label || "砂";
|
||||
}
|
||||
|
||||
function groundDescription(id = "soil") {
|
||||
|
|
@ -35,6 +37,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 };
|
||||
|
|
@ -71,6 +79,7 @@ window.TarinaiGround = Object.freeze({
|
|||
description: groundDescription,
|
||||
stressMultiplier: normalizedGroundStressMultiplier,
|
||||
grassMultiplier: normalizedGroundGrassMultiplier,
|
||||
grassGrowthMultiplier: normalizedGroundGrassGrowthMultiplier,
|
||||
grassLimit: groundGrassLimit,
|
||||
nextId: nextGroundTypeId,
|
||||
tooltipText: groundTooltipText,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
249
js/item_dynamic_ball_system.js
Normal file
249
js/item_dynamic_ball_system.js
Normal 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 are compatibility facades that 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 || it.type !== "rotator" || seen.has(it)) continue;
|
||||
const reach = 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);
|
||||
104
js/item_dynamic_duplicator_system.js
Normal file
104
js/item_dynamic_duplicator_system.js
Normal 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 compatibility.
|
||||
(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);
|
||||
195
js/item_dynamic_pin_system.js
Normal file
195
js/item_dynamic_pin_system.js
Normal 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 remain as compatibility facades.
|
||||
(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);
|
||||
32
js/item_dynamic_system.js
Normal file
32
js/item_dynamic_system.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"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 "fan":
|
||||
case "magnet":
|
||||
case "rotator":
|
||||
case "gate_fence":
|
||||
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);
|
||||
136
js/item_dynamic_tool_system.js
Normal file
136
js/item_dynamic_tool_system.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/dynamic-system
|
||||
// Owns dynamic tool behavior for fan, magnet, rotator, firecracker, genkotsu,
|
||||
// and static gate-fence ticking. Item.prototype methods delegate here for
|
||||
// compatibility; 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 updateFan(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "fan") return false;
|
||||
item.amount = 999;
|
||||
if (!worldRef) return false;
|
||||
const { fanApplyToBody, itemAngleForRuntime } = support();
|
||||
if (typeof fanApplyToBody !== "function") return false;
|
||||
let changed = false;
|
||||
const range = Math.max(190, (item.r || 32) * 7.4);
|
||||
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, range + 80, true) || []) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
changed = fanApplyToBody(item, t, dt, worldRef, 1.0) || changed;
|
||||
}
|
||||
const kinematicTypes = new Set(["ball", "zunchi", "pushpin", "oshibyo"]);
|
||||
for (const it of worldRef.nearbyItems?.(item.x, item.y, range + 80, true) || []) {
|
||||
if (!it || it === item || it.dead || !kinematicTypes.has(it.type)) continue;
|
||||
changed = fanApplyToBody(item, it, dt, worldRef, it.type === "ball" ? 1.12 : 0.82) || changed;
|
||||
}
|
||||
const now = worldRef.time || 0;
|
||||
if (changed && (item.fanPulseAt || -999) + 0.12 <= now) {
|
||||
item.fanPulseAt = now;
|
||||
const angle = typeof itemAngleForRuntime === "function" ? itemAngleForRuntime(item) : (Number(item.angle) || 0);
|
||||
const x = item.x + Math.cos(angle) * (item.r || 32) * rand(0.8, 2.8);
|
||||
const y = item.y + Math.sin(angle) * (item.r || 32) * rand(0.8, 2.8) + rand(-10, 10);
|
||||
worldRef.effects?.push(new Effect("wind", x, y, { vx: Math.cos(angle) * rand(16, 40), vy: Math.sin(angle) * rand(16, 40), size: rand(8, 18), life: rand(0.18, 0.32), color: "rgba(160,210,245,0.28)" }));
|
||||
}
|
||||
if (changed) {
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("fan-force");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateMagnet(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "magnet") return false;
|
||||
item.amount = 999;
|
||||
if (!worldRef) return false;
|
||||
const { isMagnetTargetItem, magnetApplyToBody, magnetPolePoint } = support();
|
||||
if (typeof isMagnetTargetItem !== "function" || typeof magnetApplyToBody !== "function") return false;
|
||||
let changed = false;
|
||||
const range = Math.max(170, (item.r || 30) * 7.4);
|
||||
for (const it of worldRef.nearbyItems?.(item.x, item.y, range + 80, true) || []) {
|
||||
if (!isMagnetTargetItem(it)) continue;
|
||||
if (it.type === "pushpin" || it.type === "oshibyo") {
|
||||
if (it.pinState === "lodged") continue;
|
||||
}
|
||||
changed = magnetApplyToBody(item, it, dt, worldRef, it.type === "ball" ? 0.42 : 1.0) || changed;
|
||||
}
|
||||
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, range + 80, true) || []) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
const lodged = t.currentLodgedPin?.() || (worldRef.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === t.id);
|
||||
if (!lodged) continue;
|
||||
changed = magnetApplyToBody(item, t, dt, worldRef, 0.55) || changed;
|
||||
}
|
||||
const now = worldRef.time || 0;
|
||||
if (changed && (item.magnetPulseAt || -999) + 0.18 <= now) {
|
||||
item.magnetPulseAt = now;
|
||||
const pole = typeof magnetPolePoint === "function" ? magnetPolePoint(item) : { x: item.x, y: item.y };
|
||||
worldRef.effects?.push(new Effect("ring", pole.x, pole.y, { size: Math.max(12, (item.r || 30) * 0.85), life: 0.16, color: "rgba(120,150,230,0.34)" }));
|
||||
}
|
||||
if (changed) {
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("magnet-force");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateRotator(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "rotator") return false;
|
||||
item.amount = 999;
|
||||
const speed = Number(item.rotatorSpeed || 0) || 0;
|
||||
if (!speed || !Number.isFinite(speed)) return false;
|
||||
const before = Number(item.angle || 0) || 0;
|
||||
item.angle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(before + speed * dt, 0) : before + speed * dt;
|
||||
const extent = worldRef?.rotatorExtent?.(item);
|
||||
if (Number.isFinite(extent)) item.r = Math.max(item.r || 64, Math.min(460, extent));
|
||||
item.rotatorSpinPhase = (item.rotatorSpinPhase || 0) + Math.abs(speed) * dt;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("rotator-spin");
|
||||
return true;
|
||||
}
|
||||
|
||||
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 === "fan") return updateFan(item, dt, worldRef);
|
||||
if (item.type === "magnet") return updateMagnet(item, dt, worldRef);
|
||||
if (item.type === "rotator") return updateRotator(item, dt, worldRef);
|
||||
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
|
||||
return false;
|
||||
}
|
||||
|
||||
global.TarinaiItemDynamicToolSystem = Object.freeze({
|
||||
update,
|
||||
updateFirecracker,
|
||||
updateGenkotsu,
|
||||
updateFan,
|
||||
updateMagnet,
|
||||
updateRotator,
|
||||
updateGateFence,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
100
js/item_dynamic_zunchi_system.js
Normal file
100
js/item_dynamic_zunchi_system.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/dynamic-system
|
||||
// Owns zunchi item motion, high-speed collision, and lifecycle stage ticking.
|
||||
// Prototype methods remain as compatibility facades.
|
||||
(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; 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;
|
||||
}
|
||||
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);
|
||||
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 decayBoost = rainy ? 1.35 : 1;
|
||||
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 * (rainBoost + waterBoost * 0.65);
|
||||
const decayDt = (item.spawnGrace || 0) > 0 ? 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);
|
||||
66
js/item_lifecycle_decay_system.js
Normal file
66
js/item_lifecycle_decay_system.js
Normal 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);
|
||||
38
js/item_lifecycle_growth_system.js
Normal file
38
js/item_lifecycle_growth_system.js
Normal 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);
|
||||
97
js/item_lifecycle_legacy_system.js
Normal file
97
js/item_lifecycle_legacy_system.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"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 === "magnet") item.updateMagnet(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);
|
||||
52
js/item_lifecycle_pipeline.js
Normal file
52
js/item_lifecycle_pipeline.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/pipeline
|
||||
// Owns the ordered item lifecycle pipeline. Runtime calls this before falling
|
||||
// back to the monolithic legacy Item.prototype.update body. Type-specific item
|
||||
// methods still live on Item.prototype for compatibility while dispatch is moved
|
||||
// into explicit lifecycle steps.
|
||||
(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 runConcrete(item, dt, worldRef, context) {
|
||||
for (const step of concreteSteps()) {
|
||||
const result = step.update(item, dt, worldRef, context) || {};
|
||||
if (result.done) return result.ok !== false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function runLegacy(item, dt, worldRef, context) {
|
||||
const step = global.TarinaiItemLegacyLifecycleStep;
|
||||
if (step && typeof step.update === "function") return step.update(item, dt, worldRef, context).ok !== false;
|
||||
const legacyUpdate = context.legacyUpdate;
|
||||
if (typeof legacyUpdate !== "function") return false;
|
||||
legacyUpdate.call(item, dt, worldRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateOne(item, dt, worldRef, options = {}) {
|
||||
if (!item || item.dead) return false;
|
||||
const context = { legacyUpdate: options.legacyUpdate };
|
||||
return canRunConcretePipeline() ? runConcrete(item, dt, worldRef, context) : runLegacy(item, dt, worldRef, context);
|
||||
}
|
||||
|
||||
global.TarinaiItemLifecyclePipeline = Object.freeze({
|
||||
stepNames: CONCRETE_STEP_NAMES,
|
||||
canRunConcretePipeline,
|
||||
updateOne,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,929 +1,45 @@
|
|||
"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", "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 = 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 itemAngleForRuntime(item) {
|
||||
return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
|
||||
}
|
||||
|
||||
function fanAffectsPoint(fan, x, y) {
|
||||
const angle = itemAngleForRuntime(fan);
|
||||
const ux = Math.cos(angle);
|
||||
const uy = Math.sin(angle);
|
||||
const dx = x - (fan.x || 0);
|
||||
const dy = y - (fan.y || 0);
|
||||
const forward = dx * ux + dy * uy;
|
||||
const side = -dx * uy + dy * ux;
|
||||
const range = Math.max(170, (fan.r || 32) * 7.2);
|
||||
const width = Math.max(42, (fan.r || 32) * (1.0 + Math.max(0, forward) / Math.max(1, range) * 3.1));
|
||||
if (forward < -(fan.r || 32) * 0.3 || forward > range || Math.abs(side) > width) return null;
|
||||
const forwardFalloff = 1 - clamp(forward / range, 0, 1);
|
||||
const sideFalloff = 1 - clamp(Math.abs(side) / width, 0, 1);
|
||||
const strength = forwardFalloff * forwardFalloff * (0.25 + sideFalloff * 0.75);
|
||||
return strength > 0.04 ? { ux, uy, strength, range, forward, side } : null;
|
||||
}
|
||||
|
||||
function fanApplyToBody(fan, body, dt, worldRef, scale = 1) {
|
||||
if (!fan || !body || body.dead || body === fan || body.playerHeld || body._heldByPlayer) return false;
|
||||
const hit = fanAffectsPoint(fan, body.x || 0, body.y || 0);
|
||||
if (!hit) return false;
|
||||
const force = Math.max(120, (fan.r || 32) * 5.6) * hit.strength * scale;
|
||||
body.vx = clamp((body.vx || 0) + hit.ux * force * dt, -720, 720);
|
||||
body.vy = clamp((body.vy || 0) + hit.uy * force * dt, -720, 720);
|
||||
if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) {
|
||||
body.impulseVx = clamp((body.impulseVx || 0) + hit.ux * force * dt * 0.42, -420, 420);
|
||||
body.impulseVy = clamp((body.impulseVy || 0) + hit.uy * force * dt * 0.42, -420, 420);
|
||||
if ((worldRef?.time || 0) >= (body.lastFanThoughtAt || -999) + 1.4) {
|
||||
body.lastFanThoughtAt = worldRef?.time || 0;
|
||||
body.thought = "風に流されている。";
|
||||
body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1);
|
||||
}
|
||||
} else {
|
||||
body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x;
|
||||
body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y;
|
||||
if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + hit.side * 0.012 * force * dt, -48, 48);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMagnetTargetItem(item) {
|
||||
if (!item || item.dead) return false;
|
||||
return item.type === "pushpin" || item.type === "oshibyo" || item.type === "ball";
|
||||
}
|
||||
|
||||
function magnetPolePoint(magnet) {
|
||||
const angle = itemAngleForRuntime(magnet);
|
||||
return { x: (magnet.x || 0) + Math.cos(angle) * (magnet.r || 30) * 1.10, y: (magnet.y || 0) + Math.sin(angle) * (magnet.r || 30) * 1.10 };
|
||||
}
|
||||
|
||||
function magnetApplyToBody(magnet, body, dt, worldRef, scale = 1) {
|
||||
if (!magnet || !body || body.dead || body === magnet || body.playerHeld || body._heldByPlayer) return false;
|
||||
const pole = magnetPolePoint(magnet);
|
||||
const dx = pole.x - (body.x || 0);
|
||||
const dy = pole.y - (body.y || 0);
|
||||
const d = Math.max(1, Math.hypot(dx, dy));
|
||||
const range = Math.max(150, (magnet.r || 30) * 7.2);
|
||||
if (d > range) return false;
|
||||
const falloff = Math.pow(1 - d / range, 1.35);
|
||||
const force = Math.max(180, (magnet.r || 30) * 7.8) * falloff * scale;
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
body.vx = clamp((body.vx || 0) + nx * force * dt, -740, 740);
|
||||
body.vy = clamp((body.vy || 0) + ny * force * dt, -740, 740);
|
||||
if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) {
|
||||
body.impulseVx = clamp((body.impulseVx || 0) + nx * force * dt * 0.35, -420, 420);
|
||||
body.impulseVy = clamp((body.impulseVy || 0) + ny * force * dt * 0.35, -420, 420);
|
||||
if ((worldRef?.time || 0) >= (body.lastMagnetThoughtAt || -999) + 1.8) {
|
||||
body.lastMagnetThoughtAt = worldRef?.time || 0;
|
||||
body.thought = "磁石に引っぱられている。";
|
||||
body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1);
|
||||
}
|
||||
} else {
|
||||
body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x;
|
||||
body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y;
|
||||
if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + (nx - ny) * force * dt * 0.08, -54, 54);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.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;
|
||||
}
|
||||
// Layer: entity-runtime/items/compatibility
|
||||
// Keeps legacy 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 === "fan") this.updateFan(dt, worldRef);
|
||||
if (this.type === "magnet") this.updateMagnet(dt, worldRef);
|
||||
if (this.type === "gate_fence") this.amount = 999;
|
||||
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.TarinaiItemLifecycleLegacySystem?.updateOne?.(this, dt, worldRef) ?? false; },
|
||||
|
||||
updateFan(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateFan?.(this, dt, worldRef); },
|
||||
|
||||
updateFan(dt, worldRef) {
|
||||
this.amount = 999;
|
||||
if (!worldRef) return;
|
||||
let changed = false;
|
||||
const range = Math.max(190, (this.r || 32) * 7.4);
|
||||
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range + 80, true) || []) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
changed = fanApplyToBody(this, t, dt, worldRef, 1.0) || changed;
|
||||
}
|
||||
const kinematicTypes = new Set(["ball", "zunchi", "pushpin", "oshibyo"]);
|
||||
for (const it of worldRef.nearbyItems?.(this.x, this.y, range + 80, true) || []) {
|
||||
if (!it || it === this || it.dead || !kinematicTypes.has(it.type)) continue;
|
||||
changed = fanApplyToBody(this, it, dt, worldRef, it.type === "ball" ? 1.12 : 0.82) || changed;
|
||||
}
|
||||
const now = worldRef.time || 0;
|
||||
if (changed && (this.fanPulseAt || -999) + 0.12 <= now) {
|
||||
this.fanPulseAt = now;
|
||||
const angle = itemAngleForRuntime(this);
|
||||
const x = this.x + Math.cos(angle) * (this.r || 32) * rand(0.8, 2.8);
|
||||
const y = this.y + Math.sin(angle) * (this.r || 32) * rand(0.8, 2.8) + rand(-10, 10);
|
||||
worldRef.effects?.push(new Effect("wind", x, y, { vx: Math.cos(angle) * rand(16, 40), vy: Math.sin(angle) * rand(16, 40), size: rand(8, 18), life: rand(0.18, 0.32), color: "rgba(160,210,245,0.28)" }));
|
||||
}
|
||||
if (changed) {
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("fan-force");
|
||||
}
|
||||
},
|
||||
updateMagnet(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateMagnet?.(this, dt, worldRef); },
|
||||
|
||||
updateMagnet(dt, worldRef) {
|
||||
this.amount = 999;
|
||||
if (!worldRef) return;
|
||||
let changed = false;
|
||||
const range = Math.max(170, (this.r || 30) * 7.4);
|
||||
for (const it of worldRef.nearbyItems?.(this.x, this.y, range + 80, true) || []) {
|
||||
if (!isMagnetTargetItem(it)) continue;
|
||||
if (it.type === "pushpin" || it.type === "oshibyo") {
|
||||
if (it.pinState === "lodged") continue;
|
||||
}
|
||||
changed = magnetApplyToBody(this, it, dt, worldRef, it.type === "ball" ? 0.42 : 1.0) || changed;
|
||||
}
|
||||
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range + 80, true) || []) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
const lodged = t.currentLodgedPin?.() || (worldRef.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === t.id);
|
||||
if (!lodged) continue;
|
||||
changed = magnetApplyToBody(this, t, dt, worldRef, 0.55) || changed;
|
||||
}
|
||||
const now = worldRef.time || 0;
|
||||
if (changed && (this.magnetPulseAt || -999) + 0.18 <= now) {
|
||||
this.magnetPulseAt = now;
|
||||
const pole = magnetPolePoint(this);
|
||||
worldRef.effects?.push(new Effect("ring", pole.x, pole.y, { size: Math.max(12, (this.r || 30) * 0.85), life: 0.16, color: "rgba(120,150,230,0.34)" }));
|
||||
}
|
||||
if (changed) {
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("magnet-force");
|
||||
}
|
||||
},
|
||||
updateRotator(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateRotator?.(this, dt, worldRef); },
|
||||
|
||||
updateDuplicator(dt, worldRef) {
|
||||
this.amount = 999;
|
||||
syncDuplicatorRoles(this);
|
||||
duplicatorApplyStoredPin(this, worldRef);
|
||||
this.duplicatorPinFlash = Math.max(0, (this.duplicatorPinFlash || 0) - dt);
|
||||
if (!worldRef?.nearbyItems) return;
|
||||
if (!duplicatorCanLoadNow(this, worldRef)) {
|
||||
this._duplicatorCandidateKey = "";
|
||||
this._duplicatorCandidateSince = 0;
|
||||
return;
|
||||
}
|
||||
updateDuplicator(dt, worldRef) { return window.TarinaiItemDynamicDuplicatorSystem?.update?.(this, dt, worldRef); },
|
||||
|
||||
const loadPoint = duplicatorLoadPoint(this);
|
||||
const pickupRadius = Math.max(68, (this.r || 34) + 44);
|
||||
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(16, item.r || 12);
|
||||
if (distXY(this.x, this.y, item.x, item.y) <= approxReach) candidates.push(item);
|
||||
}
|
||||
}
|
||||
updateZunchiMotion(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateMotion?.(this, dt, worldRef); },
|
||||
|
||||
let best = null, bestType = "", bestSlotD = Infinity, bestScore = Infinity;
|
||||
for (const it of candidates) {
|
||||
if (!it || it === this || it.dead || it.playerHeld || it._heldByPlayer) continue;
|
||||
const loadType = duplicatorLoadTypeForItem(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(this.x, this.y, it.x, it.y);
|
||||
const slotReach = duplicatorCandidateReach(it);
|
||||
const bodyReach = Math.max(22, (this.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;
|
||||
}
|
||||
}
|
||||
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { return window.TarinaiItemDynamicZunchiSystem?.resolveHighSpeedTarinaiCollision?.(this, dt, worldRef, speed); },
|
||||
|
||||
if (!best || !bestType) {
|
||||
this._duplicatorCandidateKey = "";
|
||||
this._duplicatorCandidateSince = 0;
|
||||
return;
|
||||
}
|
||||
updateBall(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.update?.(this, dt, worldRef); },
|
||||
|
||||
const candidateKey = `${best.id || ""}:${bestType}`;
|
||||
const now = Number(worldRef.time || 0);
|
||||
if (this._duplicatorCandidateKey !== candidateKey) {
|
||||
this._duplicatorCandidateKey = candidateKey;
|
||||
this._duplicatorCandidateSince = now;
|
||||
return;
|
||||
}
|
||||
const dwell = this.storedFoodType ? DUPLICATOR_REPLACE_DWELL_SECONDS : DUPLICATOR_LOAD_DWELL_SECONDS;
|
||||
if (now - Number(this._duplicatorCandidateSince || 0) < dwell) return;
|
||||
resolveBallObstacleCollisions(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(this, dt, worldRef); },
|
||||
|
||||
const changed = this.storedFoodType !== bestType;
|
||||
if (!changed && this.storedFoodType) {
|
||||
this._duplicatorCandidateKey = "";
|
||||
this._duplicatorCandidateSince = 0;
|
||||
return;
|
||||
}
|
||||
applyBallHayDrag(bed, dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.applyHayDrag?.(this, bed, dt, worldRef); },
|
||||
|
||||
duplicatorSetStoredType(this, 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: this, foodType: this.storedFoodType, replaced: changed, slotDistance: bestSlotD });
|
||||
worldRef.log?.(`複製機に${this.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(this.storedFoodType) ? "observe" : "food");
|
||||
},
|
||||
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveCircleBounce?.(this, obstacle, hitRadius, restitution, worldRef); },
|
||||
|
||||
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);
|
||||
},
|
||||
resolveBallRectBounce(rect, worldRef, source = null) { return window.TarinaiItemDynamicBallSystem?.resolveRectBounce?.(this, rect, worldRef, source); },
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
emitBallBounce(worldRef, obstacle) { return window.TarinaiItemDynamicBallSystem?.emitBounce?.(this, worldRef, obstacle); },
|
||||
|
||||
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; }
|
||||
},
|
||||
updatePushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.update?.(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);
|
||||
}
|
||||
},
|
||||
tryStickPushpin(worldRef) { return window.TarinaiItemDynamicPinSystem?.tryStick?.(this, worldRef); },
|
||||
|
||||
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)" }));
|
||||
}
|
||||
},
|
||||
attachPushpin(t, worldRef, d = null) { return window.TarinaiItemDynamicPinSystem?.attach?.(this, t, worldRef, d); },
|
||||
|
||||
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);
|
||||
},
|
||||
updateLodgedPushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.updateLodged?.(this, dt, worldRef); },
|
||||
|
||||
resolveBallRectBounce(rect, worldRef, source = null) {
|
||||
if (!rect) return;
|
||||
const hitRadius = (this.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);
|
||||
const ensureMinNormalSpeed = (nx, ny) => {
|
||||
if (!(rect.bounce && minBounceSpeed > 0)) return;
|
||||
const normalSpeed = (this.vx || 0) * nx + (this.vy || 0) * ny;
|
||||
if (normalSpeed >= minBounceSpeed) return;
|
||||
const add = minBounceSpeed - normalSpeed;
|
||||
this.vx = (this.vx || 0) + nx * add;
|
||||
this.vy = (this.vy || 0) + ny * add;
|
||||
};
|
||||
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 + restitution) * toward * nx;
|
||||
this.vy = vy - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
this.vx = vx + nx * 18;
|
||||
this.vy = vy + ny * 18;
|
||||
}
|
||||
ensureMinNormalSpeed(nx, ny);
|
||||
this.vx *= velocityDamp;
|
||||
this.vy *= velocityDamp;
|
||||
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 + restitution) * toward * nx;
|
||||
this.vy = (this.vy || 0) - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
this.vx = (this.vx || 0) + nx * 18;
|
||||
this.vy = (this.vy || 0) + ny * 18;
|
||||
}
|
||||
ensureMinNormalSpeed(nx, ny);
|
||||
this.vx *= velocityDamp;
|
||||
this.vy *= velocityDamp;
|
||||
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
||||
this.emitBallBounce(worldRef, source || rect.item || rect);
|
||||
},
|
||||
detachPushpin(worldRef, reason = "released") { return window.TarinaiItemDynamicPinSystem?.detach?.(this, worldRef, reason); },
|
||||
|
||||
emitBallBounce(worldRef, obstacle) {
|
||||
const now = worldRef?.time || 0;
|
||||
if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return;
|
||||
this.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", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color }));
|
||||
},
|
||||
|
||||
updatePushpin(dt, worldRef) {
|
||||
if (!worldRef) return;
|
||||
if (this.pinState === "lodged") {
|
||||
this.updateLodgedPushpin(dt, worldRef);
|
||||
return;
|
||||
}
|
||||
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
|
||||
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 });
|
||||
} else {
|
||||
this.prevX = this.x;
|
||||
this.prevY = this.y;
|
||||
this.x += (this.vx || 0) * dt;
|
||||
this.y += (this.vy || 0) * dt;
|
||||
}
|
||||
this.tryStickPushpin(worldRef);
|
||||
},
|
||||
|
||||
tryStickPushpin(worldRef) {
|
||||
if (!worldRef?.tarinai?.length) return false;
|
||||
if (this.pinState === "lodged") return false;
|
||||
const now = worldRef.time || 0;
|
||||
if ((this.noStickUntil || 0) > now) return false;
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
const hitRange = Math.max(10, (this.r || 8) * 1.25);
|
||||
const candidates = worldRef.nearbyTarinai?.(this.x, this.y, hitRange + 32, true) || worldRef.tarinai;
|
||||
for (const t of candidates) {
|
||||
if (!t || t.dead) continue;
|
||||
if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue;
|
||||
const d = distXY(this.x, this.y, t.x, t.y);
|
||||
const hit = (t.radius || 16) * 0.86 + hitRange;
|
||||
if (d <= hit && d < bestD) { best = t; bestD = d; }
|
||||
}
|
||||
if (!best) return false;
|
||||
return this.attachPushpin(best, worldRef, bestD);
|
||||
},
|
||||
|
||||
attachPushpin(t, worldRef, d = null) {
|
||||
if (!t || t.dead) return false;
|
||||
if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false;
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
|
||||
const isOshibyo = Boolean(behavior?.blocksZunchi);
|
||||
const dx = this.x - t.x;
|
||||
const dy = this.y - t.y;
|
||||
const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy);
|
||||
this.pinState = "lodged";
|
||||
this.deletable = false;
|
||||
this.pinTargetId = t.id;
|
||||
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
|
||||
const visualSide = isOshibyo ? -faceDir : faceDir;
|
||||
this.pinVisualSide = visualSide;
|
||||
this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide);
|
||||
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
|
||||
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
|
||||
this.pinDamageTick = 0;
|
||||
this.pinFallCheckTimer = 0;
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
this.spinVelocity = 0;
|
||||
this.spin = this.pinAttachAngle;
|
||||
t.stuckPushpinId = this.id;
|
||||
t.sleeping = false;
|
||||
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
|
||||
if (isOshibyo) {
|
||||
t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f";
|
||||
worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
||||
worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }));
|
||||
return true;
|
||||
}
|
||||
if (t.enterPanic) {
|
||||
t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
|
||||
} else {
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
|
||||
}
|
||||
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
|
||||
if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) {
|
||||
this.pinLogAt = worldRef.time || 0;
|
||||
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
||||
}
|
||||
worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }));
|
||||
return true;
|
||||
},
|
||||
|
||||
updateLodgedPushpin(dt, worldRef) {
|
||||
const t = worldRef.liveTarinaiById?.(this.pinTargetId) || null;
|
||||
if (!t) {
|
||||
this.detachPushpin(worldRef, "owner-lost");
|
||||
return;
|
||||
}
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
|
||||
const isOshibyo = Boolean(behavior?.blocksZunchi);
|
||||
t.stuckPushpinId = this.id;
|
||||
this.deletable = false;
|
||||
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
|
||||
const visualSide = isOshibyo ? -faceDir : faceDir;
|
||||
this.pinVisualSide = visualSide;
|
||||
this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36;
|
||||
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04;
|
||||
this.prevX = this.x;
|
||||
this.prevY = this.y;
|
||||
this.spin = visualSide * (isOshibyo ? 0.52 : 0.28);
|
||||
this.vx = t.vx || 0;
|
||||
this.vy = t.vy || 0;
|
||||
if (isOshibyo) {
|
||||
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044";
|
||||
return;
|
||||
}
|
||||
this.pinDamageTick = (this.pinDamageTick || 0) + dt;
|
||||
while (this.pinDamageTick >= 0.55) {
|
||||
this.pinDamageTick -= 0.55;
|
||||
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
|
||||
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
|
||||
if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
|
||||
}
|
||||
t.sleeping = false;
|
||||
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
|
||||
else {
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
|
||||
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
|
||||
}
|
||||
this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt;
|
||||
while (this.pinFallCheckTimer >= 1.0) {
|
||||
this.pinFallCheckTimer -= 1.0;
|
||||
if (Math.random() < 0.10) {
|
||||
this.detachPushpin(worldRef, "fall");
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
detachPushpin(worldRef, reason = "released") {
|
||||
const t = worldRef?.liveTarinaiById?.(this.pinTargetId) || null;
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
|
||||
const wasOshibyo = Boolean(behavior?.blocksZunchi);
|
||||
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
|
||||
if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null;
|
||||
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
|
||||
if (reason === "pinch") {
|
||||
const now = worldRef?.time || 0;
|
||||
this.noStickUntil = now + 1.0;
|
||||
this.noStickTargetId = t?.id || "";
|
||||
this.noStickTargetUntil = now + 1.0;
|
||||
} else if (t) {
|
||||
const now = worldRef?.time || 0;
|
||||
this.noStickTargetId = t.id || "";
|
||||
this.noStickTargetUntil = now + 0.55;
|
||||
}
|
||||
this.pinState = "loose";
|
||||
this.pinTargetId = "";
|
||||
this.pinDamageTick = 0;
|
||||
this.pinFallCheckTimer = 0;
|
||||
this.deletable = true;
|
||||
if (reason === "pinch") {
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
this.spinVelocity = 0;
|
||||
} else {
|
||||
this.vx = rand(-24, 24);
|
||||
this.vy = rand(-10, 18);
|
||||
this.spinVelocity = rand(-1.6, 1.6);
|
||||
this.spin += rand(-0.25, 0.25);
|
||||
if (t) {
|
||||
this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30));
|
||||
this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30));
|
||||
}
|
||||
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }));
|
||||
if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] });
|
||||
}
|
||||
|
||||
if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) {
|
||||
worldRef.spawnBurstZunchi(t || this, storedZunchi, this);
|
||||
}
|
||||
},
|
||||
|
||||
updateZunchi(dt, worldRef) {
|
||||
const rainy = worldRef.weather === "light_rain";
|
||||
const rainBoost = rainy ? 1.28 : 1;
|
||||
const decayBoost = rainy ? 1.35 : 1;
|
||||
let waterBoost = 0;
|
||||
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
|
||||
for (const it of nearby) {
|
||||
if (it.type !== "water") continue;
|
||||
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
|
||||
}
|
||||
this.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65);
|
||||
const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt;
|
||||
if (this.stage === "fresh") {
|
||||
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
|
||||
this.amount -= decayDt * 0.018 * decayBoost;
|
||||
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
|
||||
} else if (this.stage === "dry") {
|
||||
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
|
||||
this.amount -= decayDt * 0.032 * decayBoost;
|
||||
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
|
||||
} else if (this.stage === "decomposing") {
|
||||
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
|
||||
this.amount -= decayDt * 0.046 * decayBoost;
|
||||
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
|
||||
} else if (this.stage === "fertile_soil") {
|
||||
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
|
||||
this.amount -= decayDt * 0.090 * decayBoost;
|
||||
}
|
||||
}
|
||||
updateZunchi(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateLifecycle?.(this, dt, worldRef); }
|
||||
});
|
||||
|
|
|
|||
13
js/item_lifecycle_step_decay.js
Normal file
13
js/item_lifecycle_step_decay.js
Normal 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);
|
||||
14
js/item_lifecycle_step_dynamic.js
Normal file
14
js/item_lifecycle_step_dynamic.js
Normal 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);
|
||||
23
js/item_lifecycle_step_frame.js
Normal file
23
js/item_lifecycle_step_frame.js
Normal 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);
|
||||
13
js/item_lifecycle_step_growth.js
Normal file
13
js/item_lifecycle_step_growth.js
Normal 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);
|
||||
15
js/item_lifecycle_step_legacy.js
Normal file
15
js/item_lifecycle_step_legacy.js
Normal 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);
|
||||
246
js/item_lifecycle_support.js
Normal file
246
js/item_lifecycle_support.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/support
|
||||
// Owns shared lifecycle support helpers used by dynamic item systems and
|
||||
// placement/tool commands. The compatibility runtime now 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);
|
||||
}
|
||||
|
||||
function fanAffectsPoint(fan, x, y) {
|
||||
const angle = itemAngleForRuntime(fan);
|
||||
const ux = Math.cos(angle);
|
||||
const uy = Math.sin(angle);
|
||||
const dx = x - (fan.x || 0);
|
||||
const dy = y - (fan.y || 0);
|
||||
const forward = dx * ux + dy * uy;
|
||||
const side = -dx * uy + dy * ux;
|
||||
const range = Math.max(170, (fan.r || 32) * 7.2);
|
||||
const width = Math.max(42, (fan.r || 32) * (1.0 + Math.max(0, forward) / Math.max(1, range) * 3.1));
|
||||
if (forward < -(fan.r || 32) * 0.3 || forward > range || Math.abs(side) > width) return null;
|
||||
const forwardFalloff = 1 - clamp(forward / range, 0, 1);
|
||||
const sideFalloff = 1 - clamp(Math.abs(side) / width, 0, 1);
|
||||
const strength = forwardFalloff * forwardFalloff * (0.25 + sideFalloff * 0.75);
|
||||
return strength > 0.04 ? { ux, uy, strength, range, forward, side } : null;
|
||||
}
|
||||
|
||||
function fanApplyToBody(fan, body, dt, worldRef, scale = 1) {
|
||||
if (!fan || !body || body.dead || body === fan || body.playerHeld || body._heldByPlayer) return false;
|
||||
const hit = fanAffectsPoint(fan, body.x || 0, body.y || 0);
|
||||
if (!hit) return false;
|
||||
const force = Math.max(120, (fan.r || 32) * 5.6) * hit.strength * scale;
|
||||
body.vx = clamp((body.vx || 0) + hit.ux * force * dt, -720, 720);
|
||||
body.vy = clamp((body.vy || 0) + hit.uy * force * dt, -720, 720);
|
||||
if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) {
|
||||
body.impulseVx = clamp((body.impulseVx || 0) + hit.ux * force * dt * 0.42, -420, 420);
|
||||
body.impulseVy = clamp((body.impulseVy || 0) + hit.uy * force * dt * 0.42, -420, 420);
|
||||
if ((worldRef?.time || 0) >= (body.lastFanThoughtAt || -999) + 1.4) {
|
||||
body.lastFanThoughtAt = worldRef?.time || 0;
|
||||
body.thought = "風に流されている。";
|
||||
body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1);
|
||||
}
|
||||
} else {
|
||||
body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x;
|
||||
body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y;
|
||||
if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + hit.side * 0.012 * force * dt, -48, 48);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMagnetTargetItem(item) {
|
||||
if (!item || item.dead) return false;
|
||||
return item.type === "pushpin" || item.type === "oshibyo" || item.type === "ball";
|
||||
}
|
||||
|
||||
function magnetPolePoint(magnet) {
|
||||
const angle = itemAngleForRuntime(magnet);
|
||||
return { x: (magnet.x || 0) + Math.cos(angle) * (magnet.r || 30) * 1.10, y: (magnet.y || 0) + Math.sin(angle) * (magnet.r || 30) * 1.10 };
|
||||
}
|
||||
|
||||
function magnetApplyToBody(magnet, body, dt, worldRef, scale = 1) {
|
||||
if (!magnet || !body || body.dead || body === magnet || body.playerHeld || body._heldByPlayer) return false;
|
||||
const pole = magnetPolePoint(magnet);
|
||||
const dx = pole.x - (body.x || 0);
|
||||
const dy = pole.y - (body.y || 0);
|
||||
const d = Math.max(1, Math.hypot(dx, dy));
|
||||
const range = Math.max(150, (magnet.r || 30) * 7.2);
|
||||
if (d > range) return false;
|
||||
const falloff = Math.pow(1 - d / range, 1.35);
|
||||
const force = Math.max(180, (magnet.r || 30) * 7.8) * falloff * scale;
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
body.vx = clamp((body.vx || 0) + nx * force * dt, -740, 740);
|
||||
body.vy = clamp((body.vy || 0) + ny * force * dt, -740, 740);
|
||||
if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) {
|
||||
body.impulseVx = clamp((body.impulseVx || 0) + nx * force * dt * 0.35, -420, 420);
|
||||
body.impulseVy = clamp((body.impulseVy || 0) + ny * force * dt * 0.35, -420, 420);
|
||||
if ((worldRef?.time || 0) >= (body.lastMagnetThoughtAt || -999) + 1.8) {
|
||||
body.lastMagnetThoughtAt = worldRef?.time || 0;
|
||||
body.thought = "磁石に引っぱられている。";
|
||||
body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1);
|
||||
}
|
||||
} else {
|
||||
body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x;
|
||||
body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y;
|
||||
if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + (nx - ny) * force * dt * 0.08, -54, 54);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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,
|
||||
fanAffectsPoint,
|
||||
fanApplyToBody,
|
||||
isMagnetTargetItem,
|
||||
magnetPolePoint,
|
||||
magnetApplyToBody,
|
||||
duplicatorLoadTypeForItem,
|
||||
duplicatorLoadPoint,
|
||||
duplicatorCandidateReach,
|
||||
duplicatorCanLoadNow,
|
||||
syncDuplicatorRoles,
|
||||
duplicatorSetStoredType,
|
||||
duplicatorApplyStoredPin,
|
||||
DUPLICATOR_LOAD_DWELL_SECONDS,
|
||||
DUPLICATOR_REPLACE_DWELL_SECONDS,
|
||||
});
|
||||
|
||||
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
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: "置いたアイテムをクリックしてコピーし、次のクリックで設定ごと貼り付ける。" },
|
||||
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" },
|
||||
|
|
@ -43,8 +45,9 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
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: "\u89b3\u5bdf\u30e2\u30fc\u30c9\u3067\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" },
|
||||
fan: { id: "fan", itemType: "fan", label: "\u6247\u98a8\u6a5f", placeable: true, scalable: true, radius: 32, amount: 999, collisionShape: "oriented_rect", tooltip: "\u524d\u65b9\u306b\u98a8\u3092\u51fa\u3057\u3001\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u62bc\u3059\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
magnet: { id: "magnet", itemType: "magnet", label: "\u78c1\u77f3", placeable: true, scalable: true, radius: 30, amount: 999, collisionShape: "oriented_rect", tooltip: "\u524d\u65b9\u306e\u6975\u306b\u753b\u92f2\u30fb\u304a\u3057\u308a\u92f2\u30fb\u30dc\u30fc\u30eb\u3092\u5f15\u304d\u5bc4\u305b\u308b\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: "設置後に観察でクリックすると、回転速度と輪郭を編集できる。直線・自由描画で大きな回転物も作れる。" },
|
||||
water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" },
|
||||
trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
|
||||
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 },
|
||||
|
|
@ -52,17 +55,17 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
|
||||
|
||||
const ITEM_TRAITS = Object.freeze({
|
||||
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet"]),
|
||||
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]),
|
||||
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", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet"]),
|
||||
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]),
|
||||
});
|
||||
|
||||
|
||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet"]));
|
||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]));
|
||||
function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); }
|
||||
function defaultItemAngle(type = "") {
|
||||
const t = String(type || "");
|
||||
|
|
@ -115,10 +118,10 @@ 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: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "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_h", "bounce_fence", "gate_fence", "fan", "magnet"] },
|
||||
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_h", "bounce_fence", "gate_fence", "fan", "magnet", "rotator"] },
|
||||
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
|
||||
]);
|
||||
|
||||
|
|
@ -507,7 +510,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);
|
||||
|
|
|
|||
|
|
@ -685,6 +685,48 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.fillText("N", this.r * 0.56, -this.r * 0.56);
|
||||
ctx.fillText("S", this.r * 0.56, this.r * 0.56);
|
||||
ctx.restore();
|
||||
} else if (this.type === "rotator") {
|
||||
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
|
||||
const speed = Number(this.rotatorSpeed || 0) || 0;
|
||||
const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]])).slice(0, 96);
|
||||
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.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5";
|
||||
ctx.lineWidth = thick;
|
||||
ctx.shadowColor = styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)";
|
||||
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 = styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)";
|
||||
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 = styleNight ? "#3c324e" : "#f4eaff";
|
||||
ctx.strokeStyle = styleNight ? "#a28cc8" : "#6e4fa4";
|
||||
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 = styleNight ? "#d8c5ff" : "#6e4fa4";
|
||||
ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(speed >= 0 ? "↻" : "↺", 0, 0);
|
||||
} else if (this.type === "zunchi") {
|
||||
const zunchiId = this.zunchiVariant || "zunchi";
|
||||
const img = images.get(zunchiId) || images.get("zunchi");
|
||||
|
|
|
|||
80
js/item_runtime.js
Normal file
80
js/item_runtime.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items
|
||||
// Owns the public item update boundary. Item.prototype.update remains available
|
||||
// for legacy callers, but it now routes through TarinaiItemRuntime so callers do
|
||||
// not need to know where the lifecycle implementation lives.
|
||||
(function (global) {
|
||||
let lifecycleUpdate = null;
|
||||
|
||||
function installPrototypeBoundary() {
|
||||
const proto = global.Item?.prototype;
|
||||
if (!proto || proto._tarinaiItemRuntimeInstalled) return false;
|
||||
lifecycleUpdate = proto.update || lifecycleUpdate;
|
||||
if (typeof lifecycleUpdate === "function" && !proto._tarinaiLifecycleUpdate) {
|
||||
Object.defineProperty(proto, "_tarinaiLifecycleUpdate", {
|
||||
value: lifecycleUpdate,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
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 lifecycleFor(item) {
|
||||
return item?._tarinaiLifecycleUpdate || lifecycleUpdate;
|
||||
}
|
||||
|
||||
function updateOne(item, dt, worldRef = global.world) {
|
||||
const fn = lifecycleFor(item);
|
||||
if (!item || item.dead || typeof fn !== "function") return false;
|
||||
if (global.TarinaiItemLifecyclePipeline?.updateOne) {
|
||||
return global.TarinaiItemLifecyclePipeline.updateOne(item, dt, worldRef, { legacyUpdate: fn }) !== false;
|
||||
}
|
||||
fn.call(item, dt, worldRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -117,6 +117,13 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
if (type === "magnet") {
|
||||
item.magnetPulseAt = rand(0, 0.4);
|
||||
}
|
||||
if (type === "rotator") {
|
||||
item.rotatorSpeed = Math.PI * 0.65;
|
||||
item.rotatorThickness = 12;
|
||||
item.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]];
|
||||
item.rotatorEditorOpenAt = -999;
|
||||
item.amount = 999;
|
||||
}
|
||||
|
||||
if (isServingFoodType(type)) {
|
||||
item.toolSize = "medium";
|
||||
|
|
|
|||
82
js/item_update_policy.js
Normal file
82
js/item_update_policy.js
Normal 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", "fan", "magnet", "rotator"]);
|
||||
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", "fan", "magnet", "rotator", "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);
|
||||
|
|
@ -1,30 +1,22 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "fan", "magnet"]);
|
||||
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", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet",
|
||||
];
|
||||
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 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 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;
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@ function previewItemRadiusFor(type = "") {
|
|||
if (type === "genkotsu") return 30;
|
||||
if (type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") return 24;
|
||||
if (type === "fan" || type === "magnet") return 24;
|
||||
if (type === "rotator") return 25;
|
||||
if (type === "nest_box") return 26;
|
||||
if (type === "duplicator") return 24;
|
||||
if (type === "signboard") return 22;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,15 @@
|
|||
obj.vy = (obj.vy || 0) + ny * add;
|
||||
}
|
||||
}
|
||||
if (rect.rotator) {
|
||||
const omega = clamp(Number(rect.angularVelocity || 0) || 0, -8, 8);
|
||||
const rx = obj.x - (rect.centerX ?? rect.item?.x ?? rect.cx ?? 0);
|
||||
const ry = obj.y - (rect.centerY ?? rect.item?.y ?? rect.cy ?? 0);
|
||||
const tvx = -ry * omega;
|
||||
const tvy = rx * omega;
|
||||
obj.vx = clamp((obj.vx || 0) + tvx * 0.28 + nx * 20, -900, 900);
|
||||
obj.vy = clamp((obj.vy || 0) + tvy * 0.28 + ny * 20, -900, 900);
|
||||
}
|
||||
if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
123
js/render.js
123
js/render.js
|
|
@ -205,6 +205,14 @@ function placementPreviewFor(world) {
|
|||
const influence = type === "fan" ? world.fanInfluenceShape?.(tmp) : world.magnetInfluenceShape?.(tmp);
|
||||
return { type, x, y, r, rect: rawRect, influence, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "rotator" && world.rotatorObstacleRects) {
|
||||
tmp.rotatorSpeed = Math.PI * 0.65;
|
||||
tmp.rotatorThickness = 12;
|
||||
tmp.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]];
|
||||
const rects = world.rotatorObstacleRects(tmp) || [];
|
||||
const bounds = world.rotatorBoundsAabb?.(tmp) || 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) };
|
||||
|
|
@ -219,7 +227,7 @@ function placementPreviewFor(world) {
|
|||
function drawPlacementPreview(ctx, world) {
|
||||
const preview = placementPreviewFor(world);
|
||||
if (!preview) return;
|
||||
const { type, x, y, r, rect, influence, 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;
|
||||
|
|
@ -228,20 +236,25 @@ function drawPlacementPreview(ctx, world) {
|
|||
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 (influence) drawDeviceInfluenceShape(ctx, influence, blocked, 1.15);
|
||||
if (rect) {
|
||||
if (rect.oriented) {
|
||||
const drawRectPreview = (rr) => {
|
||||
if (!rr) return;
|
||||
if (rr.oriented) {
|
||||
ctx.save();
|
||||
ctx.translate(rect.cx || x, rect.cy || y);
|
||||
ctx.rotate(rect.angle || 0);
|
||||
roundedRect(ctx, -(rect.halfW || (rect.right - rect.left) / 2), -(rect.halfH || (rect.bottom - rect.top) / 2), (rect.halfW || (rect.right - rect.left) / 2) * 2, (rect.halfH || (rect.bottom - rect.top) / 2) * 2, 8);
|
||||
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, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, 8);
|
||||
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)";
|
||||
|
|
@ -271,6 +284,67 @@ 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) {
|
||||
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 !== "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();
|
||||
|
|
@ -723,8 +797,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 === "ice" ? "#bfe6f4" : groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#79a9cf" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
|
||||
const borderBottom = groundType === "ice" ? "#77b7d2" : groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#4f79a3" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
|
||||
const borderTop = 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 === "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;
|
||||
|
|
@ -748,6 +822,8 @@ function drawGardenBed(w, h, lighting) {
|
|||
? ["#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"
|
||||
|
|
@ -765,12 +841,12 @@ function drawGardenBed(w, h, lighting) {
|
|||
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
|
||||
activeCtx.clip();
|
||||
|
||||
const speckCount = groundType === "ice" ? 54 : groundType === "blanket" ? 28 : groundType === "foot_massage" ? 180 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
|
||||
const speckCount = 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, groundType === "foot_massage" ? 5.2 : 3.8);
|
||||
const a = randSeed(1900 + i, groundType === "foot_massage" ? 0.065 : 0.03, groundType === "foot_massage" ? 0.18 : 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") {
|
||||
const warm = i % 5 === 0;
|
||||
activeCtx.fillStyle = warm
|
||||
|
|
@ -780,7 +856,7 @@ function drawGardenBed(w, h, lighting) {
|
|||
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})` : (groundType === "ice" ? `rgba(255,255,255,${a * 1.65})` : (groundType === "foot_massage" ? `rgba(${Math.round(randSeed(2000 + i, 86, 128))}, ${Math.round(randSeed(2300 + i, 142, 196))}, ${Math.round(randSeed(2600 + i, 186, 235))}, ${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.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();
|
||||
|
|
@ -823,6 +899,24 @@ function drawGardenBed(w, h, lighting) {
|
|||
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)";
|
||||
|
|
@ -1099,6 +1193,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.
|
||||
|
|
@ -1270,7 +1365,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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,23 @@
|
|||
|
||||
(function (global) {
|
||||
const Snapshot = global.TarinaiSnapshot;
|
||||
const SaveSchema = global.TarinaiSaveSchema;
|
||||
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
|
||||
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 = 5;
|
||||
const MIN_BINARY_SCHEMA_VERSION = 4;
|
||||
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
|
||||
const GROUND_IDS = Object.freeze(["soil", "concrete", "blanket", "foot_massage", "ice"]);
|
||||
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", "fan", "magnet"
|
||||
]);
|
||||
const STRUCTURE_TYPE_IDS = new Set([3, 4]);
|
||||
const SERVING_FOOD_TYPE_IDS = new Set([7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 31]);
|
||||
const PIN_TYPE_IDS = new Set([26, 27]);
|
||||
const ROTATABLE_ITEM_TYPE_IDS = new Set([28, 29, 34, 35, 36, 37, 38]);
|
||||
const {
|
||||
BINARY_SCHEMA_VERSION,
|
||||
MIN_BINARY_SCHEMA_VERSION,
|
||||
FIELD_IDS,
|
||||
GROUND_IDS,
|
||||
ITEM_TYPE_IDS,
|
||||
STRUCTURE_TYPE_IDS,
|
||||
SERVING_FOOD_TYPE_IDS,
|
||||
PIN_TYPE_IDS,
|
||||
ROTATABLE_ITEM_TYPE_IDS,
|
||||
} = SaveSchema;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
|
@ -454,7 +453,7 @@
|
|||
}
|
||||
|
||||
function writeWorld(writer, w = []) {
|
||||
writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 2, 4, 3]);
|
||||
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
|
||||
|
|
@ -463,7 +462,12 @@
|
|||
writer.u(w[9] || 0); // birthSerial
|
||||
}
|
||||
function readWorld(reader) {
|
||||
const [field, ground, mood, weather] = readBitFields(reader, [2, 2, 4, 3]);
|
||||
const schema = reader.schemaVersion || BINARY_SCHEMA_VERSION;
|
||||
const groundBits = schema >= 6 ? 3 : 2;
|
||||
const [field, rawGround, mood, weather] = readBitFields(reader, [2, groundBits, 4, 3]);
|
||||
// Schema 5 and earlier used [soil, concrete, blanket, foot_massage] in two bits.
|
||||
// Schema 6 inserts the new high-growth dirt at index 1, so old indices are remapped here.
|
||||
const ground = schema >= 6 ? rawGround : ([0, 2, 3, 4][Number(rawGround) | 0] ?? 0);
|
||||
return [field, ground, mood, reader.u(), weather, reader.s(), reader.u(), reader.u(), reader.str(), reader.u()];
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +549,8 @@
|
|||
writer.str(extra[0] || "");
|
||||
} else if (typeId === 6) { // duplicator
|
||||
writer.s(extra[0] ?? -1);
|
||||
} else if (typeId === 39) { // rotator
|
||||
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
|
||||
|
|
@ -567,6 +573,7 @@
|
|||
if (typeId === 1) return [reader.u(), reader.s(), reader.s()];
|
||||
if (typeId === 22) return [reader.str()];
|
||||
if (typeId === 6) return [reader.s()];
|
||||
if (typeId === 39) return [reader.str()];
|
||||
if (typeId === 21) return [reader.s(), reader.s()];
|
||||
if (typeId === 23) return [reader.s(), reader.s()];
|
||||
if (typeId === 24) return [reader.s()];
|
||||
|
|
|
|||
79
js/save_schema.js
Normal file
79
js/save_schema.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: persistence/schema
|
||||
// Owns stable save identifiers shared by snapshot_system.js and save_codec.js.
|
||||
// Keep numeric order append-only while the current save format is compatible.
|
||||
(function (global) {
|
||||
const SNAPSHOT_VERSION = 5;
|
||||
const BINARY_SCHEMA_VERSION = 7;
|
||||
const MIN_BINARY_SCHEMA_VERSION = 4;
|
||||
|
||||
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
|
||||
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice"]);
|
||||
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", "fan", "magnet", "rotator"
|
||||
]);
|
||||
|
||||
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 ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]);
|
||||
|
||||
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,
|
||||
MIN_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,
|
||||
ROTATABLE_ITEM_TYPE_IDS,
|
||||
enumIndex,
|
||||
enumValue,
|
||||
itemTypeId,
|
||||
itemTypeValue,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -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"];
|
||||
|
|
@ -622,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;
|
||||
|
|
|
|||
23
js/simulation.js
Normal file
23
js/simulation.js
Normal 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);
|
||||
50
js/simulation_ambient_system.js
Normal file
50
js/simulation_ambient_system.js
Normal 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);
|
||||
70
js/simulation_creature_system.js
Normal file
70
js/simulation_creature_system.js
Normal 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);
|
||||
13
js/simulation_effects_system.js
Normal file
13
js/simulation_effects_system.js
Normal 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);
|
||||
71
js/simulation_environment_system.js
Normal file
71
js/simulation_environment_system.js
Normal 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);
|
||||
63
js/simulation_item_ant_system.js
Normal file
63
js/simulation_item_ant_system.js
Normal 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);
|
||||
51
js/simulation_maintenance_system.js
Normal file
51
js/simulation_maintenance_system.js
Normal 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);
|
||||
140
js/simulation_runtime_helpers.js
Normal file
140
js/simulation_runtime_helpers.js
Normal 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
76
js/simulation_systems.js
Normal 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 legacy 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);
|
||||
|
|
@ -1,34 +1,27 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const SaveSchema = global.TarinaiSaveSchema;
|
||||
if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for snapshot_system.js");
|
||||
const SNAPSHOT_VERSION = 5;
|
||||
if (SNAPSHOT_VERSION !== SaveSchema.SNAPSHOT_VERSION) throw new Error("snapshot schema version mismatch");
|
||||
const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||
const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]);
|
||||
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
|
||||
const GROUND_IDS = Object.freeze(["soil", "concrete", "blanket", "foot_massage", "ice"]);
|
||||
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", "fan", "magnet"
|
||||
]);
|
||||
|
||||
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] ?? "";
|
||||
}
|
||||
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);
|
||||
|
|
@ -330,6 +323,12 @@
|
|||
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 === "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]]).slice(0, 96).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)])
|
||||
];
|
||||
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)];
|
||||
|
|
@ -375,6 +374,19 @@
|
|||
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") {
|
||||
let e = extra;
|
||||
if (typeof e[0] === "string") {
|
||||
try { e = JSON.parse(e[0]); } catch (_) { e = []; }
|
||||
}
|
||||
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]]).slice(0,96).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 === "duplicator") {
|
||||
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") {
|
||||
|
|
|
|||
41
js/system_order.js
Normal file
41
js/system_order.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: simulation/system-order
|
||||
// Names the update order in one place. Prefer concrete simulation systems,
|
||||
// with the legacy phase facade kept as a fallback for transitional builds.
|
||||
(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);
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
21
js/tarinai_action_planner_system.js
Normal file
21
js/tarinai_action_planner_system.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use strict";
|
||||
|
||||
// Boundary for ordinary need/action planning. The current implementation still
|
||||
// delegates to the legacy planner body, but 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.TarinaiNeedPlannerLegacySystem?.updateOne?.(tarinai, dt));
|
||||
}
|
||||
|
||||
function updateWithThis(dt = 0) {
|
||||
return updateOne(this, dt);
|
||||
}
|
||||
|
||||
global.TarinaiActionPlannerSystem = Object.freeze({
|
||||
updateOne,
|
||||
updateWithThis,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
86
js/tarinai_contact_item_system.js
Normal file
86
js/tarinai_contact_item_system.js
Normal 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);
|
||||
40
js/tarinai_cursor_care_system.js
Normal file
40
js/tarinai_cursor_care_system.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/tarinai/update-system
|
||||
// Owns pointer proximity care and cursor-heart effects.
|
||||
(function (global) {
|
||||
function legacyMethod(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;
|
||||
legacyMethod.call(tarinai, dt);
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiCursorCareSystem = Object.freeze({
|
||||
legacyMethod,
|
||||
updateOne,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
194
js/tarinai_food_interaction_system.js
Normal file
194
js/tarinai_food_interaction_system.js
Normal 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);
|
||||
22
js/tarinai_forced_action_planner_system.js
Normal file
22
js/tarinai_forced_action_planner_system.js
Normal 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);
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
69
js/tarinai_item_interaction_context.js
Normal file
69
js/tarinai_item_interaction_context.js
Normal 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);
|
||||
39
js/tarinai_item_interaction_system.js
Normal file
39
js/tarinai_item_interaction_system.js
Normal 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 legacy 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);
|
||||
52
js/tarinai_local_environment_system.js
Normal file
52
js/tarinai_local_environment_system.js
Normal 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 legacyMethod(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;
|
||||
legacyMethod.call(tarinai, dt);
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiLocalEnvironmentSystem = Object.freeze({
|
||||
legacyMethod,
|
||||
updateOne,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
22
js/tarinai_need_planner_system.js
Normal file
22
js/tarinai_need_planner_system.js
Normal 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.TarinaiNeedPlannerLegacySystem?.updateOne?.(tarinai, dt));
|
||||
}
|
||||
|
||||
function updateWithThis(dt = 0) {
|
||||
return updateOne(this, dt);
|
||||
}
|
||||
|
||||
global.TarinaiNeedPlannerSystem = Object.freeze({
|
||||
updateOne,
|
||||
updateWithThis,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -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 resolveNeedsLegacy(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.TarinaiNeedPlannerLegacySystem) {
|
||||
global.TarinaiNeedPlannerLegacySystem = Object.freeze({
|
||||
updateOne(tarinai, dt = 0) {
|
||||
if (!tarinai || tarinai.dead) return false;
|
||||
resolveNeedsLegacy.call(tarinai, dt);
|
||||
return true;
|
||||
},
|
||||
updateWithThis(dt = 0) {
|
||||
return this && !this.dead ? (resolveNeedsLegacy.call(this, dt), true) : false;
|
||||
},
|
||||
resolveNeedsLegacy,
|
||||
});
|
||||
}
|
||||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||
forceBehavior(actionId, options = {}) {
|
||||
return queueForcedTarinaiBehavior(this, actionId, options);
|
||||
|
|
@ -836,296 +1004,26 @@ 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 });
|
||||
const legacyUpdate = globalThis.TarinaiLegacyUpdateSystem?.updateWithThis;
|
||||
if (globalThis.TarinaiUpdatePipeline?.updateOne) {
|
||||
return globalThis.TarinaiUpdatePipeline.updateOne(this, dt, { legacyUpdate });
|
||||
}
|
||||
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 (globalThis.TarinaiLegacyUpdateSystem?.updateOne) {
|
||||
return globalThis.TarinaiLegacyUpdateSystem.updateOne(this, dt);
|
||||
}
|
||||
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);
|
||||
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 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 = {}) {
|
||||
|
|
@ -1252,153 +1150,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: "へこへこしている" });
|
||||
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;
|
||||
return global.TarinaiNeedPlannerSystem?.updateOne?.(this, dt) ?? global.TarinaiNeedPlannerLegacySystem?.updateOne?.(this, dt);
|
||||
},
|
||||
|
||||
makePoop(force = 1) {
|
||||
|
|
@ -1427,267 +1179,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);
|
||||
|
|
|
|||
|
|
@ -548,7 +548,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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
js/tarinai_runtime.js
Normal file
61
js/tarinai_runtime.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/tarinai
|
||||
// Owns the public Tarinai update boundary. Tarinai.prototype.update remains a
|
||||
// compatibility facade; concrete behavior now routes through TarinaiUpdatePipeline
|
||||
// when available, with the captured legacy update kept as a fallback.
|
||||
(function (global) {
|
||||
let legacyUpdate = null;
|
||||
|
||||
function preferredLegacyUpdate(proto) {
|
||||
const systemLegacy = global.TarinaiLegacyUpdateSystem?.updateWithThis;
|
||||
if (typeof systemLegacy === "function") return systemLegacy;
|
||||
return proto?.update || legacyUpdate;
|
||||
}
|
||||
|
||||
function installPrototypeBoundary() {
|
||||
const proto = global.Tarinai?.prototype;
|
||||
if (!proto || proto._tarinaiCreatureRuntimeInstalled) return false;
|
||||
legacyUpdate = preferredLegacyUpdate(proto);
|
||||
if (typeof legacyUpdate === "function" && !proto._tarinaiLegacyUpdate) {
|
||||
Object.defineProperty(proto, "_tarinaiLegacyUpdate", {
|
||||
value: legacyUpdate,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
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) {
|
||||
const fn = tarinai?._tarinaiLegacyUpdate || legacyUpdate || global.TarinaiLegacyUpdateSystem?.updateWithThis;
|
||||
if (!tarinai || tarinai.dead || typeof fn !== "function") return false;
|
||||
if (global.TarinaiUpdatePipeline?.updateOne) {
|
||||
return global.TarinaiUpdatePipeline.updateOne(tarinai, dt, { legacyUpdate: fn }) !== false;
|
||||
}
|
||||
fn.call(tarinai, dt);
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -215,9 +215,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;
|
||||
|
|
|
|||
51
js/tarinai_sunbath_system.js
Normal file
51
js/tarinai_sunbath_system.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/tarinai/update-system
|
||||
// Owns active sunbath timer, sprite frame cycling, and recovery finish logic.
|
||||
(function (global) {
|
||||
function legacyMethod(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, 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;
|
||||
legacyMethod.call(tarinai, dt);
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiSunbathSystem = Object.freeze({
|
||||
legacyMethod,
|
||||
updateOne,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
226
js/tarinai_update_legacy_system.js
Normal file
226
js/tarinai_update_legacy_system.js
Normal 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);
|
||||
55
js/tarinai_update_pipeline.js
Normal file
55
js/tarinai_update_pipeline.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/tarinai/pipeline
|
||||
// Owns the ordered Tarinai update pipeline. Runtime calls this instead of
|
||||
// depending on the monolithic prototype update body. The legacy step is retained
|
||||
// as a compatibility fallback while concrete steps are split out incrementally.
|
||||
(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 runConcrete(tarinai, dt, context) {
|
||||
for (const step of concreteSteps()) {
|
||||
const result = step.update(tarinai, dt, context) || {};
|
||||
if (result.done) return result.ok !== false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function runLegacy(tarinai, dt, context) {
|
||||
const step = global.TarinaiLegacyUpdateStep;
|
||||
if (step && typeof step.update === "function") return step.update(tarinai, dt, context).ok !== false;
|
||||
const legacyUpdate = context.legacyUpdate;
|
||||
if (typeof legacyUpdate !== "function") return false;
|
||||
legacyUpdate.call(tarinai, dt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateOne(tarinai, dt, options = {}) {
|
||||
if (!tarinai || tarinai.dead) return false;
|
||||
const context = {
|
||||
legacyUpdate: options.legacyUpdate,
|
||||
wasSleepingAtFrameStart: false,
|
||||
};
|
||||
return canRunConcretePipeline() ? runConcrete(tarinai, dt, context) : runLegacy(tarinai, dt, context);
|
||||
}
|
||||
|
||||
global.TarinaiUpdatePipeline = Object.freeze({
|
||||
stepNames: CONCRETE_STEP_NAMES,
|
||||
canRunConcretePipeline,
|
||||
updateOne,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
29
js/tarinai_update_policy.js
Normal file
29
js/tarinai_update_policy.js
Normal 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);
|
||||
41
js/tarinai_update_step_ai.js
Normal file
41
js/tarinai_update_step_ai.js
Normal 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 legacy 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);
|
||||
21
js/tarinai_update_step_environment.js
Normal file
21
js/tarinai_update_step_environment.js
Normal 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);
|
||||
169
js/tarinai_update_step_frame.js
Normal file
169
js/tarinai_update_step_frame.js
Normal 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 legacy
|
||||
// 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);
|
||||
15
js/tarinai_update_step_health.js
Normal file
15
js/tarinai_update_step_health.js
Normal 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);
|
||||
14
js/tarinai_update_step_legacy.js
Normal file
14
js/tarinai_update_step_legacy.js
Normal 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);
|
||||
19
js/tarinai_update_step_movement.js
Normal file
19
js/tarinai_update_step_movement.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/tarinai/update-step
|
||||
// Owns sunbath/runtime movement, collisions, and nest-box occupancy refresh.
|
||||
(function (global) {
|
||||
function update(t, dt) {
|
||||
if (!t || t.dead) return { done: true };
|
||||
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);
|
||||
t.updateNestBoxPresence(dt);
|
||||
if (t.world.resolveFenceCollision) t.world.resolveFenceCollision(t);
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
global.TarinaiMovementUpdateStep = Object.freeze({ update });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -26,7 +26,7 @@ const TEXT_CATALOG = {
|
|||
{
|
||||
title: "草、地面、天気",
|
||||
image: "assets/ui/tool_grass.webp",
|
||||
text: "草は成長し、食料やかんたんベッドの材料になります。地面は土、コンクリート、ふわふわ毛布、足つぼ、氷を切り替えられ、草の発生やストレス増加に影響します。雨や時間帯も状態に関わります。",
|
||||
text: "草は成長し、食料やかんたんベッドの材料になります。地面は砂、土、コンクリート、ふわふわ毛布、足つぼ、氷を切り替えられ、草の発生やストレス増加に影響します。雨や時間帯も状態に関わります。",
|
||||
},
|
||||
{
|
||||
title: "性格と行動傾向",
|
||||
|
|
|
|||
|
|
@ -174,14 +174,8 @@ function bindUI() {
|
|||
const id = node.getAttribute("data-family-tarinai-id");
|
||||
const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead);
|
||||
if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; }
|
||||
world.selected = t;
|
||||
t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15);
|
||||
window.TarinaiCommands?.dispatch?.(world, { type: "selection.focus", target: t, options: { pulse: 1.15 } });
|
||||
selectTool("observe");
|
||||
if (Number.isFinite(t.x) && Number.isFinite(t.y)) {
|
||||
world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2;
|
||||
world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2;
|
||||
world.clampCamera?.();
|
||||
}
|
||||
renderStats();
|
||||
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
|
||||
});
|
||||
|
|
@ -216,11 +210,12 @@ function bindUI() {
|
|||
}
|
||||
const btn = e.target.closest("[data-selected-action=\"favorite\"]");
|
||||
if (!btn || !world.selected || world.selected.dead) return;
|
||||
world.selected.favorite = !world.selected.favorite;
|
||||
const favResult = window.TarinaiCommands?.dispatch?.(world, { type: "selection.favorite.toggle" });
|
||||
if (!favResult?.ok) return;
|
||||
uiCache.selectedSnapshot = "";
|
||||
renderSelected();
|
||||
render();
|
||||
showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002");
|
||||
showToast(favResult.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002");
|
||||
});
|
||||
ui.selectedInfo?.addEventListener("change", (e) => {
|
||||
const input = e.target.closest("[data-selected-name]");
|
||||
|
|
@ -234,7 +229,7 @@ function bindUI() {
|
|||
if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); }
|
||||
});
|
||||
ui.selectedCloseBtn?.addEventListener("click", () => {
|
||||
world.selected = null;
|
||||
window.TarinaiCommands?.dispatch?.(world, { type: "selection.clear" });
|
||||
uiCache.showSelectedEmpty = false;
|
||||
uiCache.selectedSnapshot = "";
|
||||
uiCache.selectedEmpty = false;
|
||||
|
|
@ -244,9 +239,10 @@ function bindUI() {
|
|||
|
||||
ui.pauseBtn?.addEventListener("click", () => {
|
||||
audio.uiClick?.();
|
||||
world.paused = !world.paused;
|
||||
const pauseResult = window.TarinaiCommands?.dispatch?.(world, { type: "simulation.paused.toggle" });
|
||||
const paused = pauseResult?.ok ? pauseResult.paused : world.paused;
|
||||
const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0);
|
||||
if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) {
|
||||
if (archiveAutoUpdateEnabled() && paused && (uiCache.archivePendingWhileRunning || archiveStale)) {
|
||||
uiCache.archivePendingWhileRunning = false;
|
||||
scheduleArchiveWindowRender();
|
||||
}
|
||||
|
|
@ -254,9 +250,7 @@ function bindUI() {
|
|||
});
|
||||
ui.speedBtn?.addEventListener("click", () => {
|
||||
audio.uiClick?.();
|
||||
const speeds = [1, 2, 4];
|
||||
const i = speeds.indexOf(world.speed);
|
||||
world.speed = speeds[(i + 1) % speeds.length];
|
||||
window.TarinaiCommands?.dispatch?.(world, { type: "simulation.speed.cycle", speeds: [1, 2, 4] });
|
||||
syncTopButtons();
|
||||
});
|
||||
ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); openEcologyDialog(); });
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ function chartSeries(mode = "population") {
|
|||
if (mode === "objects") return [
|
||||
{ key: "obj_grass", label: "草", color: "#5b9d61" },
|
||||
{ key: "obj_zunchi", label: "ずんち", color: "#6f8a3f" },
|
||||
{ key: "obj_trace", label: "足跡", color: "#8a7054" },
|
||||
{ key: "obj_trace", label: "死骸", color: "#8a7054" },
|
||||
{ key: "obj_grass_bed", label: "かんたんベッド", color: "#4f9bc4" },
|
||||
{ key: "obj_water", label: "雨滴", color: "#5aa8d6" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
function groundDef(worldRef = null) {
|
||||
const worldObj = activeWorld(worldRef);
|
||||
const id = worldObj?.groundType || "soil";
|
||||
return global.TarinaiGround?.definition?.(id) || (typeof GROUND_TYPES !== "undefined" ? GROUND_TYPES[id] || GROUND_TYPES.soil : { id: "soil", label: "土", description: "いつもの地面" });
|
||||
return global.TarinaiGround?.definition?.(id) || (typeof GROUND_TYPES !== "undefined" ? GROUND_TYPES[id] || GROUND_TYPES.soil : { id: "soil", label: "砂", description: "普通の地面。" });
|
||||
}
|
||||
|
||||
function groundTip(worldRef = null) {
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
function updateGroundUI(worldRef = null) {
|
||||
const uiRef = global.ui || {};
|
||||
const def = groundDef(worldRef);
|
||||
const label = def.label || "土";
|
||||
const label = def.label || "砂";
|
||||
const statEl = uiRef.statGroundType || document.getElementById("statGroundType");
|
||||
const btnEl = uiRef.groundTypeBtn || document.getElementById("groundTypeBtn");
|
||||
if (statEl) setTextIfChanged(statEl, "groundType", label);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
}
|
||||
const p = ctx.screenToWorldEvent(e);
|
||||
if (!p.inside) return;
|
||||
world.handleClick(p.x, p.y);
|
||||
window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y });
|
||||
renderStats();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
if (tool === "water_hose") return "crosshair";
|
||||
if (tool === "pinch") return "grab";
|
||||
if (tool === "observe") return "default";
|
||||
if (tool === "poke" || tool === "delete") return "pointer";
|
||||
if (tool === "poke" || tool === "delete" || tool === "area_delete") return "pointer";
|
||||
return "crosshair";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,13 +109,13 @@
|
|||
} else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) {
|
||||
const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
|
||||
if (p.inside) {
|
||||
world.handleClick(p.x, p.y);
|
||||
window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y });
|
||||
renderStats();
|
||||
}
|
||||
} else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) {
|
||||
const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
|
||||
if (p.inside) {
|
||||
world.handleClick(p.x, p.y);
|
||||
window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y });
|
||||
renderStats();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,12 +125,13 @@ function resizeFieldFromWheel(e) {
|
|||
}
|
||||
|
||||
function selectTool(tool) {
|
||||
world.tool = tool;
|
||||
const result = window.TarinaiCommands?.dispatch?.(world, { type: "tool.select", toolId: tool });
|
||||
const activeTool = result?.toolId || world.tool || tool;
|
||||
for (const btn of uiCache.toolButtons) {
|
||||
btn.classList.toggle("selected", btn.dataset.tool === tool);
|
||||
btn.classList.toggle("selected", btn.dataset.tool === activeTool);
|
||||
}
|
||||
syncToolSizeBadges();
|
||||
canvas.style.cursor = tool === "observe" ? "default" : (tool === "pinch" ? "grab" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair"));
|
||||
canvas.style.cursor = activeTool === "observe" ? "default" : (activeTool === "pinch" ? "grab" : (activeTool === "poke" || activeTool === "delete" ? "pointer" : "crosshair"));
|
||||
}
|
||||
|
||||
function showToast(text) {
|
||||
|
|
|
|||
19
js/ui_log.js
19
js/ui_log.js
|
|
@ -138,9 +138,16 @@ function relationIconHtml(id) {
|
|||
function relationEntries(t, kind = "friend") {
|
||||
const rows = [];
|
||||
const liveIds = new Set((world?.tarinai || []).filter(o => o && !o.dead && o !== t).map(o => o.id));
|
||||
for (const [id, rel] of Object.entries(t?.relationships || {})) {
|
||||
const ids = new Set(Object.keys(t?.relationships || {}));
|
||||
for (const other of world?.tarinai || []) {
|
||||
if (other && other !== t && other.id && other.relationships?.[t?.id]) ids.add(other.id);
|
||||
}
|
||||
for (const id of ids) {
|
||||
if (!liveIds.has(id)) continue;
|
||||
const score = kind === "enemy" ? (rel.fear || 0) : (rel.affinity || 0);
|
||||
const rel = t?.relationships?.[id] || relationDefaults();
|
||||
const other = world?.tarinai?.find(o => o && o.id === id) || null;
|
||||
const reverse = other?.relationships?.[t?.id] || null;
|
||||
const score = kind === "enemy" ? Math.max(rel.fear || 0, reverse?.fear || 0) : Math.max(rel.affinity || 0, reverse?.affinity || 0);
|
||||
const threshold = kind === "enemy" ? 0.5 : FRIEND_AFFINITY_THRESHOLD;
|
||||
if (score <= threshold) continue;
|
||||
rows.push({ id, score, name: relationDisplayName(world, id) });
|
||||
|
|
@ -164,14 +171,8 @@ function relationListHtml(t, kind = "friend") {
|
|||
function focusTarinaiById(id) {
|
||||
const t = world?.tarinai?.find(o => o && !o.dead && (o.id === id || o.familyKey === id));
|
||||
if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; }
|
||||
world.selected = t;
|
||||
t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25);
|
||||
window.TarinaiCommands?.dispatch?.(world, { type: "selection.focus", target: t, options: { pulse: 1.25 } });
|
||||
selectTool("observe");
|
||||
if (Number.isFinite(t.x) && Number.isFinite(t.y)) {
|
||||
world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2;
|
||||
world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2;
|
||||
world.clampCamera?.();
|
||||
}
|
||||
uiCache.selectedSnapshot = "";
|
||||
renderSelected();
|
||||
renderStats();
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ function renderSelected() {
|
|||
const healthHtml = [
|
||||
`<div class="info-row action-reason-row"><span>\u884c\u52d5</span><strong>${escapeHtml(reasonLabel(t))}</strong></div>`,
|
||||
`<div class="need-grid">${needRowsHtml}</div>`,
|
||||
selectedInfoRowHtml("\u4f53\u529b", fmt(t.energy), "energy"),
|
||||
selectedInfoRowHtml("\u4f53\u529b", `${fmt(t.energy)} / ${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, "energy"),
|
||||
selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"),
|
||||
].join("");
|
||||
const itemEffectHtml = itemEffectSummary.length
|
||||
|
|
|
|||
|
|
@ -96,14 +96,10 @@ function toolSizeFor(tool) {
|
|||
|
||||
function cycleToolSize(tool) {
|
||||
if (!SCALABLE_TOOLS.has(tool)) return false;
|
||||
if (!world.toolSizes) world.toolSizes = {};
|
||||
const current = toolSizeFor(tool);
|
||||
const idx = SIZE_ORDER.indexOf(current);
|
||||
const next = SIZE_ORDER[(idx + 1 + SIZE_ORDER.length) % SIZE_ORDER.length];
|
||||
world.toolSizes[tool] = next;
|
||||
world.toolSize = next;
|
||||
const result = window.TarinaiCommands?.dispatch?.(world, { type: "tool.size.cycle", toolId: tool, order: SIZE_ORDER });
|
||||
if (!result?.ok) return false;
|
||||
syncToolSizeBadges();
|
||||
showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[next] || next}`);
|
||||
showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[result.size] || result.size}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
(function () {
|
||||
const APP_VERSION = "16.07.32";
|
||||
const APP_BUILD = "fan-magnet-gate-ice-rotation-v3";
|
||||
const APP_VERSION = "16.07.50";
|
||||
const APP_BUILD = "boundary-cleanup-v15";
|
||||
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
let world;
|
||||
try {
|
||||
world = new World();
|
||||
if (typeof window !== "undefined") window.world = world;
|
||||
if (typeof globalThis !== "undefined") globalThis.world = world;
|
||||
} catch (error) {
|
||||
if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -797,7 +797,7 @@
|
|||
ball.lastKickerId = t.id;
|
||||
t.vx -= nx * Math.min(22, impulse * 0.10);
|
||||
t.vy -= ny * Math.min(22, impulse * 0.10);
|
||||
t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100);
|
||||
t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100));
|
||||
const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20;
|
||||
const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0;
|
||||
if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 });
|
||||
|
|
|
|||
|
|
@ -27,6 +27,72 @@
|
|||
return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s };
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sanitizeRotatorSegmentsLocal(item) {
|
||||
const raw = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : [];
|
||||
const out = [];
|
||||
const clampCoord = (v) => Math.max(-420, Math.min(420, Number(v) || 0));
|
||||
for (const seg of raw) {
|
||||
if (!Array.isArray(seg) || seg.length < 4) continue;
|
||||
const x1 = clampCoord(seg[0]);
|
||||
const y1 = clampCoord(seg[1]);
|
||||
const x2 = clampCoord(seg[2]);
|
||||
const 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([-78, 0, 78, 0], [0, -52, 0, 52]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rotatorExtentLocal(item) {
|
||||
let maxD = 48;
|
||||
for (const seg of sanitizeRotatorSegmentsLocal(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, Number(item?.rotatorThickness || 12) || 12) + 8);
|
||||
}
|
||||
|
||||
function rotatorWorldSegmentsLocal(item) {
|
||||
const angle = angleForItemLocal(item);
|
||||
const c = Math.cos(angle);
|
||||
const ss = Math.sin(angle);
|
||||
const cx = Number(item?.x) || 0;
|
||||
const cy = Number(item?.y) || 0;
|
||||
return sanitizeRotatorSegmentsLocal(item).map(([x1, y1, x2, y2]) => [
|
||||
cx + x1 * c - y1 * ss,
|
||||
cy + x1 * ss + y1 * c,
|
||||
cx + x2 * c - y2 * ss,
|
||||
cy + x2 * ss + y2 * c,
|
||||
]);
|
||||
}
|
||||
|
||||
function rotatorSegmentRectLocal(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 = Math.max(4, Math.min(34, Number(item?.rotatorThickness || 12) || 12)) / 2;
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle);
|
||||
return {
|
||||
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: "rotator", item, rotator: true, restitution: 0.72,
|
||||
angularVelocity: Number(item?.rotatorSpeed || 0) || 0,
|
||||
centerX: Number(item?.x) || 0, centerY: Number(item?.y) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rotatorObstacleRectsLocal(item) {
|
||||
if (!item || item.dead || item.type !== "rotator") return [];
|
||||
const rects = [];
|
||||
for (const seg of rotatorWorldSegmentsLocal(item)) rects.push(rotatorSegmentRectLocal(item, seg[0], seg[1], seg[2], seg[3]));
|
||||
return rects;
|
||||
}
|
||||
|
||||
function rectLocalPoint(r, x, y) {
|
||||
const dx = x - (r.cx ?? 0);
|
||||
const dy = y - (r.cy ?? 0);
|
||||
|
|
@ -236,6 +302,35 @@
|
|||
};
|
||||
},
|
||||
|
||||
|
||||
rotatorSegments(it) {
|
||||
return sanitizeRotatorSegmentsLocal(it);
|
||||
},
|
||||
|
||||
rotatorWorldSegments(it) {
|
||||
return rotatorWorldSegmentsLocal(it);
|
||||
},
|
||||
|
||||
rotatorExtent(it) {
|
||||
return rotatorExtentLocal(it);
|
||||
},
|
||||
|
||||
rotatorObstacleRects(it) {
|
||||
return rotatorObstacleRectsLocal(it);
|
||||
},
|
||||
|
||||
rotatorBoundsAabb(it) {
|
||||
const rects = rotatorObstacleRectsLocal(it);
|
||||
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: "rotator", item: it,
|
||||
};
|
||||
},
|
||||
|
||||
solidObstacleRects(it) {
|
||||
if (!it || it.dead) return [];
|
||||
if (this.isFenceType(it.type)) {
|
||||
|
|
@ -247,6 +342,7 @@
|
|||
const rect = this.deviceRect?.(it);
|
||||
return rect ? [{ ...rect, type: it.type, item: it }] : [];
|
||||
}
|
||||
if (it.type === "rotator") return this.rotatorObstacleRects?.(it) || [];
|
||||
if (it.type === "nest_box") return this.nestBoxSolidRects(it);
|
||||
return [];
|
||||
},
|
||||
|
|
@ -267,16 +363,33 @@
|
|||
|
||||
nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) {
|
||||
const rects = [];
|
||||
const seen = new Set();
|
||||
let checked = 0;
|
||||
for (const it of this.nearbyItems(x, y, radius)) {
|
||||
if (!it || it === exclude || it.dead) continue;
|
||||
if (include && !include(it)) continue;
|
||||
const addRectsFor = (it) => {
|
||||
if (!it || it === exclude || it.dead || seen.has(it)) return false;
|
||||
if (include && !include(it)) return false;
|
||||
const partRects = this.solidObstacleRects(it);
|
||||
if (!partRects.length) continue;
|
||||
if (!partRects.length) return false;
|
||||
seen.add(it);
|
||||
checked += 1;
|
||||
for (const rect of partRects) rects.push(rect);
|
||||
return true;
|
||||
};
|
||||
for (const it of this.nearbyItems(x, y, radius)) {
|
||||
addRectsFor(it);
|
||||
if (checked >= maxChecks) break;
|
||||
}
|
||||
// Large custom rotators can extend well beyond their center cell; scan them
|
||||
// explicitly so their blades keep colliding even when the hub is far away.
|
||||
if (checked < maxChecks) {
|
||||
for (const it of this.items || []) {
|
||||
if (!it || it.type !== "rotator" || it.dead || seen.has(it)) continue;
|
||||
const reach = this.rotatorExtent?.(it) || Math.max(60, it.r || 64);
|
||||
if (distXY(x, y, it.x, it.y) > radius + reach + 36) continue;
|
||||
addRectsFor(it);
|
||||
if (checked >= maxChecks) break;
|
||||
}
|
||||
}
|
||||
return rects;
|
||||
},
|
||||
|
||||
|
|
@ -372,6 +485,21 @@
|
|||
t.lastBounceFenceEffectAt = this.time || 0;
|
||||
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" }));
|
||||
}
|
||||
} else if (r.rotator) {
|
||||
const omega = clamp(Number(r.angularVelocity || 0) || 0, -8, 8);
|
||||
const rx = t.x - (r.centerX ?? r.item?.x ?? r.cx ?? 0);
|
||||
const ry = t.y - (r.centerY ?? r.item?.y ?? r.cy ?? 0);
|
||||
const tvx = -ry * omega;
|
||||
const tvy = rx * omega;
|
||||
t.vx = clamp((t.vx || 0) * 0.78 + tvx * 0.52 + nx * 42, -680, 680);
|
||||
t.vy = clamp((t.vy || 0) * 0.78 + tvy * 0.52 + ny * 42, -680, 680);
|
||||
t.impulseVx = clamp((t.impulseVx || 0) + tvx * 0.10, -360, 360);
|
||||
t.impulseVy = clamp((t.impulseVy || 0) + tvy * 0.10, -360, 360);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12);
|
||||
if ((this.time || 0) >= (t.lastRotatorHitEffectAt || -999) + 0.10) {
|
||||
t.lastRotatorHitEffectAt = this.time || 0;
|
||||
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: "rgba(175,116,230,0.42)" }));
|
||||
}
|
||||
} else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
|
||||
else t.vy *= -0.18;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -148,8 +148,286 @@
|
|||
}
|
||||
|
||||
|
||||
function ensureRotatorEditor() {
|
||||
let dialog = document.getElementById("rotatorEditor");
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("div");
|
||||
dialog.id = "rotatorEditor";
|
||||
dialog.className = "rotator-editor hidden";
|
||||
dialog.innerHTML = `
|
||||
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="rotatorEditorTitle">
|
||||
<div class="rotator-editor-titlebar">
|
||||
<h2 id="rotatorEditorTitle">回転体</h2>
|
||||
<button id="rotatorEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button>
|
||||
</div>
|
||||
<div class="rotator-editor-controls">
|
||||
<label>速度 <input id="rotatorSpeed" type="range" min="-360" max="360" step="5"><input id="rotatorSpeedNum" type="number" min="-720" max="720" step="5"> 度/秒</label>
|
||||
<label>太さ <input id="rotatorThickness" type="range" min="4" max="30" step="1"><input id="rotatorThicknessNum" type="number" min="4" max="34" step="1"></label>
|
||||
</div>
|
||||
<div class="rotator-editor-tools" role="toolbar" aria-label="描画方法">
|
||||
<button type="button" data-rotator-mode="line" class="active">直線</button>
|
||||
<button type="button" data-rotator-mode="free">自由描画</button>
|
||||
<button type="button" data-rotator-template="bar">棒</button>
|
||||
<button type="button" data-rotator-template="cross">十字</button>
|
||||
<button type="button" data-rotator-template="circle">円っぽい</button>
|
||||
<button id="rotatorUndo" type="button">一つ戻す</button>
|
||||
<button id="rotatorClear" type="button">全消し</button>
|
||||
</div>
|
||||
<canvas id="rotatorCanvas" width="520" height="360" aria-label="回転体の輪郭編集"></canvas>
|
||||
<p class="rotator-editor-hint">中央が回転軸。ドラッグで輪郭を描く。大きい図形はキャンバス端まで使用可能。</p>
|
||||
<div class="rotator-editor-actions">
|
||||
<button id="rotatorCancel" class="btn" type="button">キャンセル</button>
|
||||
<button id="rotatorApply" class="btn primary" type="button">反映</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function cloneSegmentsForRotator(item) {
|
||||
const src = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]];
|
||||
return src.filter(seg => Array.isArray(seg) && seg.length >= 4).slice(0, 96).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]);
|
||||
}
|
||||
|
||||
function openRotatorEditor(item, worldRef) {
|
||||
if (!item || item.type !== "rotator") return false;
|
||||
const dialog = ensureRotatorEditor();
|
||||
const canvas = dialog.querySelector("#rotatorCanvas");
|
||||
const ctx = canvas?.getContext?.("2d");
|
||||
const speed = dialog.querySelector("#rotatorSpeed");
|
||||
const speedNum = dialog.querySelector("#rotatorSpeedNum");
|
||||
const thick = dialog.querySelector("#rotatorThickness");
|
||||
const thickNum = dialog.querySelector("#rotatorThicknessNum");
|
||||
const apply = dialog.querySelector("#rotatorApply");
|
||||
const cancel = dialog.querySelector("#rotatorCancel");
|
||||
const closeBtn = dialog.querySelector("#rotatorEditorClose");
|
||||
const undo = dialog.querySelector("#rotatorUndo");
|
||||
const clear = dialog.querySelector("#rotatorClear");
|
||||
if (!canvas || !ctx) return false;
|
||||
let mode = "line";
|
||||
let segments = cloneSegmentsForRotator(item);
|
||||
let draft = null;
|
||||
let drawing = false;
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const deg = Math.round((Number(item.rotatorSpeed || 0) || 0) * 180 / Math.PI);
|
||||
const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 12) || 12));
|
||||
if (speed) speed.value = String(Math.max(-360, Math.min(360, deg)));
|
||||
if (speedNum) speedNum.value = String(deg);
|
||||
if (thick) thick.value = String(initialThickness);
|
||||
if (thickNum) thickNum.value = String(initialThickness);
|
||||
const toLocal = (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = canvas.width / Math.max(1, rect.width);
|
||||
const sy = canvas.height / Math.max(1, rect.height);
|
||||
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
|
||||
};
|
||||
const cleanSegments = () => {
|
||||
const out = [];
|
||||
for (const seg of segments) {
|
||||
const x1 = Math.max(-420, Math.min(420, Number(seg[0]) || 0));
|
||||
const y1 = Math.max(-420, Math.min(420, Number(seg[1]) || 0));
|
||||
const x2 = Math.max(-420, Math.min(420, Number(seg[2]) || 0));
|
||||
const y2 = Math.max(-420, Math.min(420, Number(seg[3]) || 0));
|
||||
if (Math.hypot(x2 - x1, y2 - y1) >= 4) out.push([x1, y1, x2, y2]);
|
||||
if (out.length >= 96) break;
|
||||
}
|
||||
if (!out.length) out.push([-78, 0, 78, 0]);
|
||||
segments = out;
|
||||
};
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#f7f3ea";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.translate(cx, cy);
|
||||
ctx.strokeStyle = "rgba(100,80,60,0.16)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
|
||||
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
|
||||
ctx.strokeStyle = "rgba(70,60,50,0.38)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
|
||||
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(127,92,190,0.92)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.beginPath();
|
||||
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.58)";
|
||||
ctx.lineWidth = Math.max(1.3, lw * 0.20);
|
||||
ctx.beginPath();
|
||||
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
if (draft) {
|
||||
ctx.setLineDash([7, 5]);
|
||||
ctx.strokeStyle = "rgba(60,130,210,0.85)";
|
||||
ctx.lineWidth = Math.max(2, lw * 0.45);
|
||||
ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.fillStyle = "#f5edff";
|
||||
ctx.strokeStyle = "#6d4ca0";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
const setMode = (next) => {
|
||||
mode = next === "free" ? "free" : "line";
|
||||
for (const btn of dialog.querySelectorAll("[data-rotator-mode]")) btn.classList.toggle("active", btn.dataset.rotatorMode === mode);
|
||||
};
|
||||
const setTemplate = (name) => {
|
||||
if (name === "bar") segments = [[-130, 0, 130, 0]];
|
||||
else if (name === "cross") segments = [[-118, 0, 118, 0], [0, -90, 0, 90]];
|
||||
else if (name === "circle") {
|
||||
segments = [];
|
||||
const n = 18, r = 108;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = i / n * Math.PI * 2;
|
||||
const b = (i + 1) / n * Math.PI * 2;
|
||||
segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]);
|
||||
}
|
||||
}
|
||||
cleanSegments(); draw();
|
||||
};
|
||||
const syncPair = (a, b) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; draw(); }; b.oninput = () => { a.value = b.value; draw(); }; };
|
||||
syncPair(speed, speedNum);
|
||||
syncPair(thick, thickNum);
|
||||
dialog.querySelectorAll("[data-rotator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.rotatorMode); });
|
||||
dialog.querySelectorAll("[data-rotator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.rotatorTemplate); });
|
||||
canvas.onpointerdown = (e) => {
|
||||
canvas.setPointerCapture?.(e.pointerId);
|
||||
const p = toLocal(e);
|
||||
drawing = true;
|
||||
draft = [p.x, p.y, p.x, p.y];
|
||||
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
|
||||
draw();
|
||||
};
|
||||
canvas.onpointermove = (e) => {
|
||||
if (!drawing || !draft) return;
|
||||
const p = toLocal(e);
|
||||
if (mode === "free") {
|
||||
const last = segments[segments.length - 1];
|
||||
if (!last) return;
|
||||
const lx = last[2], ly = last[3];
|
||||
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
|
||||
last[2] = p.x; last[3] = p.y;
|
||||
if (segments.length < 96) segments.push([p.x, p.y, p.x, p.y]);
|
||||
}
|
||||
} else {
|
||||
draft[2] = p.x; draft[3] = p.y;
|
||||
}
|
||||
draw();
|
||||
};
|
||||
canvas.onpointerup = (e) => {
|
||||
if (!drawing || !draft) return;
|
||||
drawing = false;
|
||||
const p = toLocal(e);
|
||||
if (mode === "line") {
|
||||
const seg = [draft[0], draft[1], p.x, p.y];
|
||||
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
|
||||
}
|
||||
draft = null;
|
||||
cleanSegments();
|
||||
draw();
|
||||
};
|
||||
if (undo) undo.onclick = () => { segments.pop(); cleanSegments(); draw(); };
|
||||
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
|
||||
const close = () => {
|
||||
dialog.classList.add("hidden");
|
||||
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
|
||||
};
|
||||
if (apply) apply.onclick = () => {
|
||||
cleanSegments();
|
||||
item.rotatorSegments = segments.map(seg => seg.slice(0, 4));
|
||||
item.rotatorSpeed = (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180;
|
||||
item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
|
||||
const extent = worldRef?.rotatorExtent?.(item);
|
||||
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
|
||||
item.rotatorEditorOpenAt = worldRef?.time || 0;
|
||||
worldRef?.markSpatialDirty?.("rotator-edited");
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef?.log?.("回転体の設計を変更した。", "observe");
|
||||
global.render?.();
|
||||
close();
|
||||
};
|
||||
if (cancel) cancel.onclick = close;
|
||||
if (closeBtn) closeBtn.onclick = close;
|
||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
||||
setMode(mode);
|
||||
draw();
|
||||
dialog.classList.remove("hidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
function copyableItemAt(worldRef, x, y) {
|
||||
const it = worldRef?.findDeleteToolTargetAt?.(x, y);
|
||||
if (!it || it.dead || it.type === "trace" || it.type === "splat" || it.type === "ant_corpse") return null;
|
||||
if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") return null;
|
||||
return it;
|
||||
}
|
||||
|
||||
function makeCopyBufferForItem(item) {
|
||||
if (!item) return null;
|
||||
const data = {
|
||||
type: item.type,
|
||||
r: Number(item.r || itemRadiusFor?.(item.type, 12) || 12),
|
||||
amount: Math.max(1, Number(item.amount || itemAmountFor?.(item.type, 80) || 80)),
|
||||
angle: Number(item.angle || 0) || 0,
|
||||
toolSize: item.toolSize || "medium",
|
||||
foodServingScale: Number(item.foodServingScale || 1) || 1,
|
||||
foodServingsMax: Number(item.foodServingsMax || 0) || 0,
|
||||
foodServingsRemaining: Number(item.foodServingsRemaining || 0) || 0,
|
||||
};
|
||||
if (item.type === "signboard") data.text = item.text || "";
|
||||
if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen);
|
||||
if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; }
|
||||
if (item.type === "rotator") {
|
||||
data.rotatorSpeed = Number(item.rotatorSpeed || 0) || 0;
|
||||
data.rotatorThickness = Number(item.rotatorThickness || 12) || 12;
|
||||
data.rotatorSegments = cloneSegmentsForRotator(item);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function applyCopyBufferToItem(item, data) {
|
||||
if (!item || !data) return item;
|
||||
item.r = Number(data.r || item.r || itemRadiusFor?.(item.type, 12) || 12);
|
||||
item.amount = Math.max(1, Number(data.amount || item.amount || 1));
|
||||
if (typeof isRotatableItemType === "function" && isRotatableItemType(item.type)) item.angle = Number(data.angle || 0) || 0;
|
||||
if (item.type === "signboard") item.text = String(data.text || "");
|
||||
if (item.type === "gate_fence") item.gateOpen = Boolean(data.gateOpen);
|
||||
if (item.type === "duplicator") {
|
||||
item.storedFoodType = String(data.storedFoodType || "");
|
||||
item.storedFoodLabel = String(data.storedFoodLabel || (item.storedFoodType ? toolLabel(item.storedFoodType) : ""));
|
||||
if (item.roles) item.roles.food = Boolean(item.storedFoodType);
|
||||
}
|
||||
if (item.type === "rotator") {
|
||||
item.rotatorSpeed = Number(data.rotatorSpeed || 0) || 0;
|
||||
item.rotatorThickness = Math.max(4, Math.min(34, Number(data.rotatorThickness || 12) || 12));
|
||||
item.rotatorSegments = Array.isArray(data.rotatorSegments) ? data.rotatorSegments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item);
|
||||
item.r = Math.max(item.r || 64, Math.min(460, Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8));
|
||||
}
|
||||
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) {
|
||||
item.toolSize = data.toolSize || item.toolSize || "medium";
|
||||
item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1;
|
||||
item.foodServingsMax = Math.max(1, Number(data.foodServingsMax || item.foodServingsMax || item.amount || 1));
|
||||
item.foodServingsRemaining = Math.max(1, Number(data.foodServingsRemaining || item.foodServingsRemaining || item.amount || 1));
|
||||
item.amount = item.foodServingsRemaining;
|
||||
}
|
||||
if (typeof isPinType === "function" && isPinType(item.type)) {
|
||||
item.pinState = "loose";
|
||||
item.pinTargetId = "";
|
||||
item.vx = 0; item.vy = 0; item.spinVelocity = 0;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
function isEditableItem(item) {
|
||||
return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence");
|
||||
return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator");
|
||||
}
|
||||
|
||||
function findEditableItemAt(worldRef, x, y) {
|
||||
|
|
@ -167,6 +445,7 @@
|
|||
function openEditableItemEditor(item, worldRef) {
|
||||
if (!isEditableItem(item)) return false;
|
||||
if (item.type === "signboard") return openSignboardEditor(item, worldRef);
|
||||
if (item.type === "rotator") return openRotatorEditor(item, worldRef);
|
||||
if (item.type === "gate_fence") {
|
||||
item.gateOpen = !item.gateOpen;
|
||||
item.gateLastToggleAt = worldRef?.time || 0;
|
||||
|
|
@ -249,6 +528,42 @@
|
|||
return directSetDuplicatorAt(this, x, y, itemType);
|
||||
},
|
||||
|
||||
copyPreviewItemAt(x, y) {
|
||||
if (!this.copyBuffer?.type) return null;
|
||||
const item = new Item(this.copyBuffer.type, x, y);
|
||||
applyCopyBufferToItem(item, this.copyBuffer);
|
||||
item.x = x; item.y = y;
|
||||
return item;
|
||||
},
|
||||
|
||||
useCopyToolAt(x, y) {
|
||||
if (!this.copyBuffer) {
|
||||
const target = copyableItemAt(this, x, y);
|
||||
if (!target) { showToast("コピーできるものがありません。"); return true; }
|
||||
this.copyBuffer = makeCopyBufferForItem(target);
|
||||
showToast(`${toolLabel(target.type)}をコピーしました。`);
|
||||
this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe");
|
||||
return true;
|
||||
}
|
||||
const item = this.copyPreviewItemAt?.(x, y);
|
||||
if (item) {
|
||||
const placed = this.placeItem(item, false);
|
||||
if (placed) {
|
||||
const label = toolLabel(item.type);
|
||||
this.log?.(`${label}を貼り付けた。`, "observe");
|
||||
showToast(`${label}を貼り付けました。`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const target = copyableItemAt(this, x, y);
|
||||
if (target) {
|
||||
this.copyBuffer = makeCopyBufferForItem(target);
|
||||
showToast(`${toolLabel(target.type)}をコピーしました。`);
|
||||
this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
fencePlacementBlocked(item) {
|
||||
if (!item || !this.isFenceType(item.type)) return false;
|
||||
const rect = this.fenceRect(item);
|
||||
|
|
@ -268,7 +583,7 @@
|
|||
|
||||
importantPlacementOverlapBlocked(item) {
|
||||
if (!item || item.dead) return true;
|
||||
const important = new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "nest_box", "water"]);
|
||||
const important = new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator", "nest_box", "water"]);
|
||||
if (!important.has(item.type)) return false;
|
||||
const placingGrass = item.type === "grass";
|
||||
const itemRects = this.solidObstacleRects(item);
|
||||
|
|
@ -387,7 +702,7 @@
|
|||
const original = this.placementProbeItem(item, start.x, start.y);
|
||||
if (allowOriginal && !this.placementBlocked(original)) return start;
|
||||
|
||||
const solid = this.isFenceType(item.type) || item.type === "nest_box" || item.type === "fan" || item.type === "magnet";
|
||||
const solid = this.isFenceType(item.type) || item.type === "nest_box" || item.type === "fan" || item.type === "magnet" || item.type === "rotator";
|
||||
const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2));
|
||||
const count = attempts || (solid ? 72 : 36);
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
|
|
@ -471,10 +786,10 @@
|
|||
|
||||
handleClick(x, y) {
|
||||
const tool = this.tool;
|
||||
if (!isPlacementTool(tool) && !["delete", "poke", "pinch", "water_hose", "new"].includes(tool)) {
|
||||
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "new"].includes(tool)) {
|
||||
const editable = findEditableItemAt(this, x, y);
|
||||
if (editable && openEditableItemEditor(editable, this)) {
|
||||
this.selected = null;
|
||||
global.TarinaiCommands?.setSelection?.(this, null) || (this.selected = null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -486,11 +801,11 @@
|
|||
if (!found) {
|
||||
const editable = findEditableItemAt(this, x, y);
|
||||
if (editable && openEditableItemEditor(editable, this)) {
|
||||
this.selected = null;
|
||||
global.TarinaiCommands?.setSelection?.(this, null) || (this.selected = null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.selected = found;
|
||||
global.TarinaiCommands?.setSelection?.(this, found) || (this.selected = found);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -522,9 +837,14 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (tool === "copy") { this.useCopyToolAt?.(x, y); return; }
|
||||
if (this.useToolAt?.(x, y)) return;
|
||||
|
||||
const itemType = toolItemType(tool);
|
||||
if (itemType === "rotator") {
|
||||
const editable = findEditableItemAt(this, x, y);
|
||||
if (editable?.type === "rotator" && openEditableItemEditor(editable, this)) return;
|
||||
}
|
||||
if (itemType && this.directSetDuplicatorAt?.(x, y, itemType)) return;
|
||||
if (itemType) {
|
||||
let px = x, py = y;
|
||||
|
|
|
|||
|
|
@ -38,8 +38,52 @@
|
|||
return true;
|
||||
},
|
||||
|
||||
areaDeleteRadius() {
|
||||
return 138;
|
||||
},
|
||||
|
||||
deleteItemsInArea(x, y) {
|
||||
const radius = this.areaDeleteRadius?.() || 138;
|
||||
const candidates = [];
|
||||
for (let i = (this.items || []).length - 1; i >= 0; i -= 1) {
|
||||
const it = this.items[i];
|
||||
if (!it || it.dead) continue;
|
||||
if (isPinType(it.type) && it.pinState === "lodged") continue;
|
||||
const hitR = Math.max(8, it.r || itemRadiusFor?.(it.type, 12) || 12);
|
||||
const d = distXY(x, y, it.x, it.y);
|
||||
if (d <= radius + hitR * 0.85) candidates.push(it);
|
||||
}
|
||||
if (!candidates.length) { showToast("範囲内に消せるものがありません。"); return true; }
|
||||
let removedCount = 0;
|
||||
const kindCounts = new Map();
|
||||
for (const target of candidates) {
|
||||
if (!target || target.dead) continue;
|
||||
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, target, {
|
||||
reason: "area-delete-tool",
|
||||
userReason: target.type === "grass_bed" ? "ベッドがなくなった" : "置きものがなくなった",
|
||||
wake: true,
|
||||
});
|
||||
if (!removed) continue;
|
||||
removedCount += 1;
|
||||
const label = toolLabel(target.type);
|
||||
kindCounts.set(label, (kindCounts.get(label) || 0) + 1);
|
||||
}
|
||||
if (!removedCount) { showToast("範囲内に消せるものがありません。"); return true; }
|
||||
audio.delete?.();
|
||||
this.compactItems?.();
|
||||
this.updateItemCounts?.();
|
||||
this.rebuildSpatial?.();
|
||||
this.drawListDirty = true;
|
||||
if (typeof global.render === "function") global.render();
|
||||
const detail = [...kindCounts.entries()].slice(0, 4).map(([label, count]) => `${label}${count > 1 ? `×${count}` : ""}`).join("、");
|
||||
this.log?.(`範囲削除で${removedCount}個消した${detail ? `(${detail})` : ""}。`, "observe");
|
||||
showToast(`${removedCount}個消しました。`);
|
||||
return true;
|
||||
},
|
||||
|
||||
useToolAt(x, y) {
|
||||
if (this.tool === "delete") return this.deleteItemAt(x, y);
|
||||
if (this.tool === "area_delete") return this.deleteItemsInArea(x, y);
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -132,9 +132,9 @@
|
|||
},
|
||||
|
||||
update(dt) {
|
||||
const phases = window.TarinaiWorldUpdatePhases;
|
||||
if (!phases?.update) throw new Error("TarinaiWorldUpdatePhases is not available");
|
||||
phases.update(this, dt);
|
||||
const simulation = window.TarinaiSimulation;
|
||||
if (!simulation?.update) throw new Error("TarinaiSimulation is not available; TarinaiWorldUpdatePhases cannot be reached");
|
||||
return simulation.update(this, dt);
|
||||
},
|
||||
|
||||
sanitizeOutOfBounds() {
|
||||
|
|
|
|||
|
|
@ -1,410 +1,39 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: simulation/phase-facade
|
||||
// Backward-compatible phase names. Concrete update work lives in
|
||||
// js/simulation_systems.js so system_order.js can evolve without World owning
|
||||
// the phase implementation details.
|
||||
(function (global) {
|
||||
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "fan", "magnet"]);
|
||||
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", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "water_bowl",
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
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 systems() {
|
||||
const impl = global.TarinaiSimulationSystems;
|
||||
if (!impl?.phases) throw new Error("TarinaiSimulationSystems is not available");
|
||||
return impl;
|
||||
}
|
||||
|
||||
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 isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0;
|
||||
return Infinity;
|
||||
function phase(name) {
|
||||
const fn = systems().phases?.[name];
|
||||
if (typeof fn !== "function") throw new Error(`Tarinai simulation phase is not available: ${name}`);
|
||||
return fn;
|
||||
}
|
||||
|
||||
function updateItemsScheduled(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) {
|
||||
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;
|
||||
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.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 updateRandomGrassGrowth(worldRef, dt) {
|
||||
if (!worldRef || !global.TarinaiGrass) return false;
|
||||
if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return false;
|
||||
worldRef.grassGrowthTimer = (worldRef.grassGrowthTimer || 0) + dt;
|
||||
const field = worldRef.fieldType || "garden";
|
||||
const growthSpeedMul = 6.0;
|
||||
const interval = (field === "park" ? 1.8 : (field === "cage" ? 3.2 : 2.4)) / 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 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 updateItemsAndAnts(worldRef, dt) {
|
||||
const itemCountBefore = worldRef.items.length;
|
||||
updateItemsScheduled(worldRef, dt);
|
||||
updateRandomGrassGrowth(worldRef, dt);
|
||||
const itemsMoved = markScheduledItemsMoved(worldRef, "items-moved", 0.25);
|
||||
if (worldRef.itemCounts?.ball) {
|
||||
worldRef.ensureSpatial?.("ball-ball-collisions");
|
||||
worldRef.resolveBallBallCollisions(dt);
|
||||
worldRef.markSpatialDirty?.("ball-ball-collisions");
|
||||
}
|
||||
worldRef.updateAnts?.(dt);
|
||||
const antsMoved = markSpatialDirtyIfMoved(worldRef, worldRef.ants || [], "ants-moved", 0.25);
|
||||
if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update");
|
||||
return { itemCountBefore, itemsMoved, antsMoved };
|
||||
}
|
||||
|
||||
function updateEffects(worldRef, dt) {
|
||||
for (const ef of worldRef.effects) ef.update(dt);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function tarinaiUpdateInterval(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 (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 = updateVisibleWorldRect(worldRef, 520);
|
||||
return pointInRect(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70;
|
||||
}
|
||||
|
||||
function updateCreatures(worldRef, dt) {
|
||||
const end = global.TarinaiPerf?.begin?.("update.tarinai") || null;
|
||||
const visibleRect = updateVisibleWorldRect(worldRef, 160);
|
||||
try {
|
||||
for (const t of worldRef.tarinai) {
|
||||
if (!t || t.dead) continue;
|
||||
const interval = tarinaiUpdateInterval(worldRef, t, visibleRect);
|
||||
if (!Number.isFinite(interval)) continue;
|
||||
if (interval <= 0) {
|
||||
const runDt = Math.min(1.4, (t._updateAccum || 0) + dt);
|
||||
t._updateAccum = 0;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function emitPhaseChange(worldRef) {
|
||||
const phase = worldRef.phaseName();
|
||||
if (phase !== worldRef.lastPhase) {
|
||||
worldRef.lastPhase = phase;
|
||||
worldRef.emit("world:phase", { world: worldRef, phase });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if ((worldRef.canAddGrass?.(1) ?? true) && Math.random() < dt * 0.070) {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeDiagnostics(worldRef) {
|
||||
worldRef.lastRuntimeDiagnostics = worldRef.runtimeDiagnostics?.() || null;
|
||||
}
|
||||
function normalizeDelta(worldRef, dt) { return phase("normalizeDelta")(worldRef, dt); }
|
||||
function updateClock(worldRef, dt) { return phase("updateClock")(worldRef, dt); }
|
||||
function updateFrameSignals(worldRef, dt) { return phase("updateFrameSignals")(worldRef, dt); }
|
||||
function updateEnvironment(worldRef, dt) { return phase("updateEnvironment")(worldRef, dt); }
|
||||
function prepareSpatialFrame(worldRef) { return phase("prepareSpatialFrame")(worldRef); }
|
||||
function updateItemsAndAnts(worldRef, dt) { return phase("updateItemsAndAnts")(worldRef, dt); }
|
||||
function updateEffects(worldRef, dt) { return phase("updateEffects")(worldRef, dt); }
|
||||
function updateCreatures(worldRef, dt) { return phase("updateCreatures")(worldRef, dt); }
|
||||
function runMaintenance(worldRef, dt, itemCountBefore) { return phase("runMaintenance")(worldRef, dt, itemCountBefore); }
|
||||
function emitPhaseChange(worldRef) { return phase("emitPhaseChange")(worldRef); }
|
||||
function spawnAmbientTarinai(worldRef, dt) { return phase("spawnAmbientTarinai")(worldRef, dt); }
|
||||
function spawnAmbientGrass(worldRef, dt) { return phase("spawnAmbientGrass")(worldRef, dt); }
|
||||
function spawnRainWater(worldRef, dt) { return phase("spawnRainWater")(worldRef, dt); }
|
||||
function finalizeDiagnostics(worldRef) { return phase("finalizeDiagnostics")(worldRef); }
|
||||
|
||||
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);
|
||||
return systems().update(worldRef, dt);
|
||||
}
|
||||
|
||||
global.TarinaiWorldUpdatePhases = Object.freeze({
|
||||
|
|
@ -426,4 +55,4 @@
|
|||
finalizeDiagnostics,
|
||||
}),
|
||||
});
|
||||
})(window);
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -121,11 +121,11 @@
|
|||
},
|
||||
|
||||
groundDefinition() {
|
||||
return window.TarinaiGround?.definition?.(this.groundType || "soil") || GROUND_TYPES?.[this.groundType || "soil"] || GROUND_TYPES?.soil || { id: "soil", label: "土", stressMultiplier: 1, grassMultiplier: 1 };
|
||||
return window.TarinaiGround?.definition?.(this.groundType || "soil") || GROUND_TYPES?.[this.groundType || "soil"] || GROUND_TYPES?.soil || { id: "soil", label: "砂", stressMultiplier: 1, grassMultiplier: 1 };
|
||||
},
|
||||
|
||||
groundLabel() {
|
||||
return window.TarinaiGround?.label?.(this.groundType || "soil") || this.groundDefinition().label || "土";
|
||||
return window.TarinaiGround?.label?.(this.groundType || "soil") || this.groundDefinition().label || "砂";
|
||||
},
|
||||
|
||||
groundStressMultiplier() {
|
||||
|
|
@ -136,6 +136,10 @@
|
|||
return window.TarinaiGround?.grassMultiplier?.(this.groundType || "soil") ?? 1;
|
||||
},
|
||||
|
||||
groundGrassGrowthMultiplier() {
|
||||
return window.TarinaiGround?.grassGrowthMultiplier?.(this.groundType || "soil") ?? this.groundGrassMultiplier?.() ?? 1;
|
||||
},
|
||||
|
||||
setGroundType(id = "soil", opts = {}) {
|
||||
const next = window.TarinaiGround?.exists?.(id) ? id : "soil";
|
||||
const previous = this.groundType || "soil";
|
||||
|
|
@ -152,10 +156,10 @@
|
|||
if (typeof renderSelected === "function") renderSelected();
|
||||
if (typeof document !== "undefined") {
|
||||
const groundTextEl = document.getElementById("statGroundType");
|
||||
if (groundTextEl) groundTextEl.textContent = this.groundLabel?.() || "土";
|
||||
if (groundTextEl) groundTextEl.textContent = this.groundLabel?.() || "砂";
|
||||
}
|
||||
if (!opts.silent) {
|
||||
const label = this.groundLabel?.() || "土";
|
||||
const label = this.groundLabel?.() || "砂";
|
||||
this.log?.(`じめん: ${label}`);
|
||||
if (typeof showToast === "function") showToast(`じめんを ${label} にしました。`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,9 +281,10 @@ def check_runtime_diagnostics_hooks() -> None:
|
|||
debug_tools = read_text(ROOT / "js/debug_tools.js")
|
||||
world_update = read_text(ROOT / "js/world_update.js")
|
||||
world_update_phases = read_text(ROOT / "js/world_update_phases.js")
|
||||
simulation_systems = read_text(ROOT / "js/simulation_systems.js")
|
||||
if "runtimeDiagnostics()" not in spatial_budget:
|
||||
fail("World.runtimeDiagnostics hook missing")
|
||||
if "lastRuntimeDiagnostics" not in (world_update + world_update_phases):
|
||||
if "lastRuntimeDiagnostics" not in (world_update + world_update_phases + simulation_systems):
|
||||
fail("world update phases do not publish lastRuntimeDiagnostics")
|
||||
if "spatial rebuild" not in debug_tools or "behavior forced" not in debug_tools:
|
||||
fail("debug overlay is missing runtime diagnostic lines")
|
||||
|
|
@ -359,7 +360,7 @@ def check_item_bucket_polish() -> None:
|
|||
fail("debug overlay is missing item bucket/id-map diagnostics")
|
||||
routed_files = {
|
||||
"js/world_placement_log.js": "addItem?.(item",
|
||||
"js/world_update_phases.js": "addItem?.(drop",
|
||||
"js/simulation_ambient_system.js": "addItem?.(drop",
|
||||
"js/world_combat_effects.js": "addItem?.(it",
|
||||
"js/ants.js": "addItem?.(corpse",
|
||||
"js/tarinai_social_move_life.js": "addItem?.(new Item(\"trace\"",
|
||||
|
|
@ -503,6 +504,14 @@ def check_phase_and_input_split() -> None:
|
|||
data = manifest()
|
||||
js_files = data["js"]
|
||||
required = [
|
||||
"js/simulation_runtime_helpers.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/ui_input_shared.js",
|
||||
"js/ui_input_touch.js",
|
||||
|
|
@ -517,10 +526,11 @@ def check_phase_and_input_split() -> None:
|
|||
touch = read_text(ROOT / "js/ui_input_touch.js")
|
||||
mouse = read_text(ROOT / "js/ui_input_mouse.js")
|
||||
shared = read_text(ROOT / "js/ui_input_shared.js")
|
||||
if "TarinaiWorldUpdatePhases" not in world_update or "function update(worldRef, dt)" not in phases:
|
||||
systems = read_text(ROOT / "js/simulation_systems.js")
|
||||
if "TarinaiSimulation" not in world_update or "function update(worldRef, dt)" not in phases:
|
||||
fail("World.update is not delegated to world_update_phases.js")
|
||||
for token in ["updateClock", "updateEnvironment", "updateItemsAndAnts", "updateCreatures", "runMaintenance", "spawnRainWater"]:
|
||||
if token not in phases:
|
||||
if token not in phases or token not in systems:
|
||||
fail(f"world update phase missing: {token}")
|
||||
if "touchstart" in ui_bind or "mousedown" in ui_bind or "touchmove" in ui_bind:
|
||||
fail("ui_bind.js still owns field pointer input handlers")
|
||||
|
|
@ -591,6 +601,625 @@ def check_processing_split() -> None:
|
|||
fail(f"tarinai_needs_items.js still owns extracted processing: {token}")
|
||||
ok("item and Tarinai processing split present")
|
||||
|
||||
|
||||
def command_dispatcher_smoke() -> None:
|
||||
source = read_text(ROOT / "js/command_dispatcher.js")
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.World = class World {{}};
|
||||
context.scalableToolIds = () => ['grass', 'sweet'];
|
||||
context.normalizedToolSizeFor = (world, tool) => (world.toolSizes && world.toolSizes[tool]) || world.toolSize || 'medium';
|
||||
vm.createContext(context);
|
||||
vm.runInContext({source!r}, context, {{ filename: 'command_dispatcher.js' }});
|
||||
const events = [];
|
||||
const world = new context.World();
|
||||
world.tool = 'observe';
|
||||
world.toolSize = 'medium';
|
||||
world.toolSizes = {{}};
|
||||
world.speed = 1;
|
||||
world.paused = false;
|
||||
world.w = 1000;
|
||||
world.h = 700;
|
||||
world.viewportW = 500;
|
||||
world.viewportH = 300;
|
||||
world.clampCamera = () => {{ world.clamped = true; }};
|
||||
world.emit = (type, payload) => events.push([type, payload]);
|
||||
world.handleClick = (x, y) => {{ world.clicked = [x, y]; }};
|
||||
const target = {{ x: 420, y: 260, dead: false }};
|
||||
if (!context.TarinaiCommands.dispatch(world, {{ type: 'tool.select', toolId: 'grass' }}).ok || world.tool !== 'grass') throw new Error('tool select failed');
|
||||
if (!context.TarinaiCommands.dispatch(world, {{ type: 'tool.size.cycle', toolId: 'grass', order: ['small', 'medium', 'large'] }}).ok || world.toolSize !== 'large') throw new Error('tool size failed');
|
||||
if (!context.TarinaiCommands.dispatch(world, {{ type: 'selection.focus', target }}).ok || world.selected !== target || world.cameraX !== 170 || world.cameraY !== 110) throw new Error('focus failed');
|
||||
if (!context.TarinaiCommands.dispatch(world, {{ type: 'selection.favorite.toggle' }}).ok || target.favorite !== true) throw new Error('favorite failed');
|
||||
if (!context.TarinaiCommands.dispatch(world, {{ type: 'simulation.paused.toggle' }}).ok || world.paused !== true) throw new Error('pause failed');
|
||||
if (!context.TarinaiCommands.dispatch(world, {{ type: 'simulation.speed.cycle', speeds: [1, 2, 4] }}).ok || world.speed !== 2) throw new Error('speed failed');
|
||||
if (!context.TarinaiCommands.dispatch(world, {{ type: 'pointer.primary', x: 7, y: 9 }}).ok || world.clicked[0] !== 7 || world.clicked[1] !== 9) throw new Error('pointer failed');
|
||||
if (typeof world.dispatchCommand !== 'function' || !world.dispatchCommand({{ type: 'selection.clear' }}).ok || world.selected !== null) throw new Error('World facade failed');
|
||||
if (!events.some(([type]) => type === 'selection:changed')) throw new Error('selection event missing');
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("command dispatcher smoke test failed")
|
||||
ok("command dispatcher smoke test passed")
|
||||
|
||||
|
||||
def check_system_order_boundary() -> None:
|
||||
data = manifest()
|
||||
js_files = data["js"]
|
||||
required = [
|
||||
"js/simulation_runtime_helpers.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",
|
||||
]
|
||||
for rel in required:
|
||||
if rel not in js_files:
|
||||
fail(f"simulation boundary module missing from manifest: {rel}")
|
||||
split_order = [
|
||||
"js/simulation_runtime_helpers.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",
|
||||
]
|
||||
if [rel for rel in js_files if rel in split_order] != split_order:
|
||||
fail("simulation boundary load order is invalid")
|
||||
system_order = read_text(ROOT / "js/system_order.js")
|
||||
simulation = read_text(ROOT / "js/simulation.js")
|
||||
systems_source = read_text(ROOT / "js/simulation_systems.js")
|
||||
if "TarinaiSimulationSystems" not in systems_source or "const phases = Object.freeze" not in systems_source:
|
||||
fail("concrete simulation systems facade missing exported phase registry")
|
||||
concrete_modules = {
|
||||
"js/simulation_runtime_helpers.js": "TarinaiSimulationRuntime",
|
||||
"js/simulation_environment_system.js": "TarinaiEnvironmentSimulationSystem",
|
||||
"js/simulation_item_ant_system.js": "TarinaiItemAntSimulationSystem",
|
||||
"js/simulation_effects_system.js": "TarinaiEffectsSimulationSystem",
|
||||
"js/simulation_creature_system.js": "TarinaiCreatureSimulationSystem",
|
||||
"js/simulation_maintenance_system.js": "TarinaiMaintenanceSimulationSystem",
|
||||
"js/simulation_ambient_system.js": "TarinaiAmbientSimulationSystem",
|
||||
}
|
||||
for rel, token in concrete_modules.items():
|
||||
if token not in read_text(ROOT / rel):
|
||||
fail(f"concrete simulation split module missing export: {rel}")
|
||||
if "TarinaiSimulationSystems" not in read_text(ROOT / "js/world_update_phases.js"):
|
||||
fail("world_update_phases.js is not delegating to the concrete simulation systems module")
|
||||
for token in ["const SYSTEM_ORDER", "clock", "environment", "items_and_ants", "creatures", "maintenance", "diagnostics"]:
|
||||
if token not in system_order:
|
||||
fail(f"system order token missing: {token}")
|
||||
if "TarinaiSystemOrder" not in simulation or "systemOrder" not in simulation:
|
||||
fail("simulation.js does not expose the named system order boundary")
|
||||
ok("named simulation system order present")
|
||||
|
||||
|
||||
def simulation_boundary_smoke() -> None:
|
||||
sources = {rel: read_text(ROOT / rel) for rel in [
|
||||
"js/simulation_runtime_helpers.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 = f"""
|
||||
const vm = require('vm');
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
vm.createContext(context);
|
||||
for (const [filename, source] of Object.entries({sources!r})) {{
|
||||
vm.runInContext(source, context, {{ filename }});
|
||||
}}
|
||||
if (!context.TarinaiSimulationSystems?.phases?.updateClock) throw new Error('simulation systems missing');
|
||||
if (!context.TarinaiWorldUpdatePhases?.phases?.updateClock) throw new Error('phase facade missing');
|
||||
if (!context.TarinaiSystemOrder?.names?.includes('clock')) throw new Error('system order missing');
|
||||
if (!context.TarinaiSimulation?.systemOrder) throw new Error('simulation facade missing');
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("simulation boundary smoke test failed")
|
||||
ok("simulation boundary smoke test passed")
|
||||
|
||||
|
||||
|
||||
def check_update_policy_boundaries() -> None:
|
||||
data = manifest()
|
||||
js_files = data["js"]
|
||||
required = ["js/item_update_policy.js", "js/tarinai_update_policy.js"]
|
||||
for rel in required:
|
||||
if rel not in js_files:
|
||||
fail(f"update policy boundary module missing from manifest: {rel}")
|
||||
if not (js_files.index("js/item_lifecycle_runtime.js") < js_files.index("js/item_update_policy.js") < js_files.index("js/item_runtime.js")):
|
||||
fail("item update policy load order is invalid")
|
||||
if not (js_files.index("js/simulation_runtime_helpers.js") < js_files.index("js/tarinai_update_policy.js") < js_files.index("js/simulation_creature_system.js")):
|
||||
fail("Tarinai update policy load order is invalid")
|
||||
item_policy = read_text(ROOT / "js/item_update_policy.js")
|
||||
tarinai_policy = read_text(ROOT / "js/tarinai_update_policy.js")
|
||||
scheduler = read_text(ROOT / "js/item_update_scheduler.js")
|
||||
sim_helpers = read_text(ROOT / "js/simulation_runtime_helpers.js")
|
||||
creature_system = read_text(ROOT / "js/simulation_creature_system.js")
|
||||
required_tokens = [
|
||||
(item_policy, "global.TarinaiItemUpdatePolicy"),
|
||||
(item_policy, "itemUpdateInterval"),
|
||||
(item_policy, "forScheduledItems"),
|
||||
(tarinai_policy, "global.TarinaiCreatureUpdatePolicy"),
|
||||
(tarinai_policy, "updateInterval"),
|
||||
(scheduler, "TarinaiItemUpdatePolicy"),
|
||||
(sim_helpers, "TarinaiItemUpdatePolicy"),
|
||||
(creature_system, "TarinaiCreatureUpdatePolicy"),
|
||||
]
|
||||
for source, token in required_tokens:
|
||||
if token not in source:
|
||||
fail(f"update policy boundary token missing: {token}")
|
||||
duplicated_item_policy_sources = [scheduler, sim_helpers]
|
||||
for source in duplicated_item_policy_sources:
|
||||
if "const REALTIME_ITEM_TYPES" in source or "const PIN_ITEM_TYPES" in source or "SCHEDULED_ITEM_TYPES =" in source:
|
||||
fail("item update policy constants are duplicated outside item_update_policy.js")
|
||||
if "function tarinaiUpdateInterval" in creature_system:
|
||||
fail("Tarinai update cadence still lives inside simulation_creature_system.js")
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.isServingFoodType = (type) => type === 'food';
|
||||
context.TarinaiItemRuntime = {{
|
||||
updateOne(item, dt) {{ item.runs = (item.runs || 0) + 1; item.lastDt = dt; return true; }}
|
||||
}};
|
||||
vm.createContext(context);
|
||||
for (const file of ['js/item_update_policy.js', 'js/item_update_scheduler.js']) {{
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }});
|
||||
}}
|
||||
const world = {{ time: 0, items: [{{ type: 'ball' }}, {{ type: 'food' }}, {{ type: 'grass' }}], ensureItemBuckets() {{}}, itemBucketRebuildsTotal: 0 }};
|
||||
if (context.TarinaiItemUpdateScheduler.run(world, 0.1) < 1) throw new Error('realtime item did not run');
|
||||
world.time = 5.1;
|
||||
if (context.TarinaiItemUpdateScheduler.run(world, 0.1) < 1) throw new Error('scheduled item did not run');
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("update policy scheduler smoke test failed")
|
||||
ok("update policy boundaries present")
|
||||
|
||||
|
||||
def check_entity_runtime_boundary() -> None:
|
||||
data = manifest()
|
||||
js_files = data["js"]
|
||||
required = ["js/item_runtime.js", "js/tarinai_runtime.js"]
|
||||
for rel in required:
|
||||
if rel not in js_files:
|
||||
fail(f"entity runtime boundary module missing from manifest: {rel}")
|
||||
if not (js_files.index("js/item_lifecycle_runtime.js") < js_files.index("js/item_runtime.js") < js_files.index("js/structure_lifecycle.js")):
|
||||
fail("item runtime boundary load order is invalid")
|
||||
if not (js_files.index("js/tarinai_needs_items.js") < js_files.index("js/tarinai_social_move_life.js") < js_files.index("js/tarinai_runtime.js") < js_files.index("js/tarinai_render.js")):
|
||||
fail("Tarinai runtime boundary load order is invalid")
|
||||
item_runtime = read_text(ROOT / "js/item_runtime.js")
|
||||
tarinai_runtime = read_text(ROOT / "js/tarinai_runtime.js")
|
||||
item_scheduler = read_text(ROOT / "js/item_update_scheduler.js")
|
||||
sim_helpers = read_text(ROOT / "js/simulation_runtime_helpers.js")
|
||||
creature_system = read_text(ROOT / "js/simulation_creature_system.js")
|
||||
required_tokens = [
|
||||
(item_runtime, "global.TarinaiItemRuntime"),
|
||||
(item_runtime, "installPrototypeBoundary"),
|
||||
(item_runtime, "updateScheduled"),
|
||||
(tarinai_runtime, "global.TarinaiCreatureRuntime"),
|
||||
(tarinai_runtime, "updateBatch"),
|
||||
(item_scheduler, "TarinaiItemRuntime?.updateOne"),
|
||||
(sim_helpers, "TarinaiItemRuntime?.updateScheduled"),
|
||||
(creature_system, "TarinaiCreatureRuntime?.updateOne"),
|
||||
]
|
||||
for source, token in required_tokens:
|
||||
if token not in source:
|
||||
fail(f"entity runtime boundary token missing: {token}")
|
||||
ok("entity update runtime boundary present")
|
||||
|
||||
|
||||
def check_tarinai_update_pipeline_boundary() -> None:
|
||||
data = manifest()
|
||||
js_files = data["js"]
|
||||
required = [
|
||||
"js/tarinai_sunbath_system.js",
|
||||
"js/tarinai_cursor_care_system.js",
|
||||
"js/tarinai_local_environment_system.js",
|
||||
"js/tarinai_update_legacy_system.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_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_step_legacy.js",
|
||||
"js/tarinai_update_pipeline.js",
|
||||
]
|
||||
for rel in required:
|
||||
if rel not in js_files:
|
||||
fail(f"Tarinai update pipeline module missing from manifest: {rel}")
|
||||
expected_order = [
|
||||
"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_update_legacy_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_step_legacy.js",
|
||||
"js/tarinai_update_pipeline.js",
|
||||
"js/tarinai_runtime.js",
|
||||
]
|
||||
if [rel for rel in js_files if rel in expected_order] != expected_order:
|
||||
fail("Tarinai update pipeline load order is invalid")
|
||||
exports = {
|
||||
"js/tarinai_sunbath_system.js": "global.TarinaiSunbathSystem",
|
||||
"js/tarinai_cursor_care_system.js": "global.TarinaiCursorCareSystem",
|
||||
"js/tarinai_local_environment_system.js": "global.TarinaiLocalEnvironmentSystem",
|
||||
"js/tarinai_update_legacy_system.js": "global.TarinaiLegacyUpdateSystem",
|
||||
"js/tarinai_forced_action_planner_system.js": "global.TarinaiForcedActionPlannerSystem",
|
||||
"js/tarinai_action_planner_system.js": "global.TarinaiActionPlannerSystem",
|
||||
"js/tarinai_need_planner_system.js": "global.TarinaiNeedPlannerSystem",
|
||||
"js/tarinai_item_interaction_context.js": "global.TarinaiItemInteractionContext",
|
||||
"js/tarinai_food_interaction_system.js": "global.TarinaiFoodInteractionSystem",
|
||||
"js/tarinai_contact_item_system.js": "global.TarinaiContactItemSystem",
|
||||
"js/tarinai_item_interaction_system.js": "global.TarinaiItemInteractionSystem",
|
||||
"js/tarinai_update_step_frame.js": "global.TarinaiFrameUpdateStep",
|
||||
"js/tarinai_update_step_ai.js": "global.TarinaiAiUpdateStep",
|
||||
"js/tarinai_update_step_environment.js": "global.TarinaiEnvironmentUpdateStep",
|
||||
"js/tarinai_update_step_movement.js": "global.TarinaiMovementUpdateStep",
|
||||
"js/tarinai_update_step_health.js": "global.TarinaiHealthUpdateStep",
|
||||
"js/tarinai_update_step_legacy.js": "global.TarinaiLegacyUpdateStep",
|
||||
"js/tarinai_update_pipeline.js": "global.TarinaiUpdatePipeline",
|
||||
}
|
||||
for rel, token in exports.items():
|
||||
if token not in read_text(ROOT / rel):
|
||||
fail(f"Tarinai update pipeline export missing: {rel}")
|
||||
runtime = read_text(ROOT / "js/tarinai_runtime.js")
|
||||
if "TarinaiUpdatePipeline.updateOne" not in runtime or "legacyUpdate: fn" not in runtime:
|
||||
fail("Tarinai runtime does not route through TarinaiUpdatePipeline with legacy fallback")
|
||||
|
||||
needs_items = read_text(ROOT / "js/tarinai_needs_items.js")
|
||||
legacy_system = read_text(ROOT / "js/tarinai_update_legacy_system.js")
|
||||
sunbath_system = read_text(ROOT / "js/tarinai_sunbath_system.js")
|
||||
cursor_system = read_text(ROOT / "js/tarinai_cursor_care_system.js")
|
||||
local_env_system = read_text(ROOT / "js/tarinai_local_environment_system.js")
|
||||
interaction_system = read_text(ROOT / "js/tarinai_item_interaction_system.js")
|
||||
if "TarinaiLegacyUpdateSystem.updateOne" not in needs_items or "const minute = dt / 60" in needs_items:
|
||||
fail("Tarinai.prototype.update is not a thin facade over the update pipeline/legacy system")
|
||||
if "TarinaiItemInteractionSystem?.updateOne" not in needs_items or "const foodNeed = Number(this.needRaw?.food" in needs_items:
|
||||
fail("Tarinai.prototype.interactWithItems is not a thin facade over the interaction system")
|
||||
for facade_token in ["TarinaiSunbathSystem?.updateOne", "TarinaiCursorCareSystem?.updateOne", "TarinaiLocalEnvironmentSystem?.updateOne"]:
|
||||
if facade_token not in needs_items:
|
||||
fail(f"Tarinai prototype subsystem facade missing: {facade_token}")
|
||||
for token in ["const minute = dt / 60", "this.resolveNeeds(needStepDt)", "this.applyEnvironment(envDt)", "this.checkLife(dt)"]:
|
||||
if token not in legacy_system:
|
||||
fail(f"Tarinai legacy update system missing extracted monolith token: {token}")
|
||||
interaction_context = read_text(ROOT / "js/tarinai_item_interaction_context.js")
|
||||
food_interaction = read_text(ROOT / "js/tarinai_food_interaction_system.js")
|
||||
contact_interaction = read_text(ROOT / "js/tarinai_contact_item_system.js")
|
||||
for source, token in [
|
||||
(sunbath_system, "this.sunbathFrameTimer"),
|
||||
(cursor_system, "this.nextCursorHeartAt"),
|
||||
(local_env_system, "grassComfort"),
|
||||
(interaction_context, "const foodNeed = Number(tarinai.needRaw?.food"),
|
||||
(food_interaction, "tarinai.applyParamItemEffect"),
|
||||
(contact_interaction, "tarinai.applyWaterEffect(dt, it.type)"),
|
||||
]:
|
||||
if token not in source:
|
||||
fail(f"Tarinai update subsystem missing extracted token: {token}")
|
||||
if "TarinaiItemInteractionContext?.create" not in interaction_system or "TarinaiFoodInteractionSystem?.updateCandidate" not in interaction_system or "TarinaiContactItemSystem?.updateCandidate" not in interaction_system:
|
||||
fail("Tarinai item interaction orchestrator does not delegate to context/food/contact candidate subsystems")
|
||||
if "for (const item of ctx.foodCandidates" not in interaction_system:
|
||||
fail("Tarinai item interaction orchestrator does not preserve per-candidate interaction order")
|
||||
if "TarinaiNeedPlannerSystem?.updateOne" not in needs_items or "function resolveNeedsLegacy" not in needs_items:
|
||||
fail("Tarinai resolveNeeds is not routed through planner system with legacy fallback")
|
||||
if "TarinaiNeedPlannerLegacySystem" not in needs_items:
|
||||
fail("Tarinai need planner legacy system export missing")
|
||||
if "TarinaiItemInteractionSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_ai.js"):
|
||||
fail("Tarinai AI step does not delegate item interactions to subsystem")
|
||||
if "TarinaiCursorCareSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_frame.js"):
|
||||
fail("Tarinai frame step does not delegate cursor care to subsystem")
|
||||
if "TarinaiLocalEnvironmentSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_environment.js"):
|
||||
fail("Tarinai environment step does not delegate local environment to subsystem")
|
||||
if "TarinaiSunbathSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_movement.js"):
|
||||
fail("Tarinai movement step does not delegate sunbath to subsystem")
|
||||
|
||||
pipeline = read_text(ROOT / "js/tarinai_update_pipeline.js")
|
||||
for token in ["TarinaiFrameUpdateStep", "TarinaiAiUpdateStep", "TarinaiEnvironmentUpdateStep", "TarinaiMovementUpdateStep", "TarinaiHealthUpdateStep", "TarinaiLegacyUpdateStep"]:
|
||||
if token not in pipeline:
|
||||
fail(f"Tarinai update pipeline missing step token: {token}")
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.CONFIG = {{ hungerPerMinute: 60, lonelinessPerMinute: 60, energyPerMinute: 60 }};
|
||||
context.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
context.rand = (a, b) => (a + b) / 2;
|
||||
context.pick = (arr) => arr[0];
|
||||
context.stableUnit = () => 0.5;
|
||||
context.applyNeedShock = () => undefined;
|
||||
context.applyNeedRelief = () => undefined;
|
||||
context.calculateStressFromNeeds = () => 0;
|
||||
context.applyGroundStressModifier = (t, v) => v;
|
||||
context.createDefaultNeeds = () => ({{ food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }});
|
||||
context.tarinaiMaxEnergy = () => 100;
|
||||
context.tarinaiMetabolicHungerScale = () => 1;
|
||||
vm.createContext(context);
|
||||
for (const file of {required!r}) {{
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }});
|
||||
}}
|
||||
const calls = [];
|
||||
const world = {{
|
||||
time: 1,
|
||||
groundType: 'soil',
|
||||
weather: 'cloudy',
|
||||
colonyMood: {{ id: 'relaxed' }},
|
||||
selected: null,
|
||||
spendScheduledWork() {{ return true; }},
|
||||
temperatureAt() {{ return 0.62; }},
|
||||
lightLevel() {{ return 0.5; }},
|
||||
spawnBleedEffect() {{ calls.push('bleed'); }},
|
||||
resolveFenceCollision() {{ calls.push('fence'); }},
|
||||
}};
|
||||
const t = {{
|
||||
world, dead: false, x: 10, y: 12, vx: 0, vy: 0, radius: 20,
|
||||
age: 0, reproductionTimer: 10, blink: 0, eatTimer: 0, eatCooldown: 0, foodReactTimer: 0,
|
||||
surpriseTimer: 0, fearTimer: 0, intimidateTimer: 0, intimidatedTimer: 0, stress: 0,
|
||||
hpBarTimer: 0, stressBarTimer: 0, loveMochiTimer: 0, fightMochiTimer: 0,
|
||||
birthRitualTimer: 0, pokeFlashTimer: 0, cursorPetting: 0, pinchThoughtTimer: 0,
|
||||
sunbathCooldown: 0, fightTimer: 0, fightCooldown: 0, defeatedTimer: 0, stretchTimer: 0,
|
||||
grassEatTimer: 0, hurtTimer: 0, fallTimer: 0, tempTimer: 0, tempComfort: 0.62,
|
||||
hunger: 10, loneliness: 0, energy: 100, trait: {{ hunger: 1, social: 1, sleep: 1 }},
|
||||
state: 'idle', aiTimer: 0, envTimer: 0, needs: context.createDefaultNeeds(), needRaw: context.createDefaultNeeds(),
|
||||
updateZunchiDisease() {{ calls.push('zunchiDisease'); }},
|
||||
updateSpecialDiseases() {{ calls.push('specialDisease'); }},
|
||||
freezeSleepDiseaseMotion() {{ return false; }},
|
||||
applyCursorContactCare() {{ calls.push('cursorCare'); }},
|
||||
personalityProfile() {{ return {{ fear: 1 }}; }},
|
||||
updateFacing() {{ calls.push('facing'); }},
|
||||
updateSunbath() {{ calls.push('sunbath'); }},
|
||||
maintainTargetProgress() {{ calls.push('targetProgress'); }},
|
||||
move() {{ calls.push('move'); }},
|
||||
updateNestBoxPresence() {{ calls.push('nestBox'); }},
|
||||
checkLife() {{ calls.push('checkLife'); }},
|
||||
}};
|
||||
if (!context.TarinaiUpdatePipeline.canRunConcretePipeline()) throw new Error('pipeline cannot run concrete steps');
|
||||
if (!context.TarinaiUpdatePipeline.updateOne(t, 0.016, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('pipeline update failed');
|
||||
for (const expected of ['zunchiDisease', 'specialDisease', 'facing', 'targetProgress', 'move', 'nestBox', 'fence', 'checkLife']) {{
|
||||
if (!calls.includes(expected)) throw new Error('missing pipeline call: ' + expected);
|
||||
}}
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("Tarinai update pipeline smoke test failed")
|
||||
ok("Tarinai update pipeline boundary present")
|
||||
|
||||
|
||||
|
||||
def check_item_lifecycle_pipeline_boundary() -> None:
|
||||
data = manifest()
|
||||
js_files = data["js"]
|
||||
dynamic_required = [
|
||||
"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",
|
||||
]
|
||||
required = [
|
||||
"js/item_lifecycle_support.js",
|
||||
"js/item_lifecycle_legacy_system.js",
|
||||
*dynamic_required,
|
||||
"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_step_legacy.js",
|
||||
"js/item_lifecycle_pipeline.js",
|
||||
]
|
||||
for rel in required:
|
||||
if rel not in js_files:
|
||||
fail(f"Item lifecycle pipeline module missing from manifest: {rel}")
|
||||
expected_order = [
|
||||
"js/item_lifecycle_support.js",
|
||||
"js/item_lifecycle_legacy_system.js",
|
||||
"js/item_lifecycle_runtime.js",
|
||||
"js/item_update_policy.js",
|
||||
*dynamic_required,
|
||||
"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_step_legacy.js",
|
||||
"js/item_lifecycle_pipeline.js",
|
||||
"js/item_runtime.js",
|
||||
]
|
||||
if [rel for rel in js_files if rel in expected_order] != expected_order:
|
||||
fail("Item lifecycle pipeline load order is invalid")
|
||||
exports = {
|
||||
"js/item_lifecycle_support.js": "global.TarinaiItemLifecycleRuntimeSupport",
|
||||
"js/item_lifecycle_legacy_system.js": "global.TarinaiItemLifecycleLegacySystem",
|
||||
"js/item_dynamic_tool_system.js": "global.TarinaiItemDynamicToolSystem",
|
||||
"js/item_dynamic_ball_system.js": "global.TarinaiItemDynamicBallSystem",
|
||||
"js/item_dynamic_duplicator_system.js": "global.TarinaiItemDynamicDuplicatorSystem",
|
||||
"js/item_dynamic_pin_system.js": "global.TarinaiItemDynamicPinSystem",
|
||||
"js/item_dynamic_zunchi_system.js": "global.TarinaiItemDynamicZunchiSystem",
|
||||
"js/item_dynamic_system.js": "global.TarinaiItemDynamicSystem",
|
||||
"js/item_lifecycle_decay_system.js": "global.TarinaiItemDecaySystem",
|
||||
"js/item_lifecycle_growth_system.js": "global.TarinaiItemGrowthSystem",
|
||||
"js/item_lifecycle_step_frame.js": "global.TarinaiItemFrameLifecycleStep",
|
||||
"js/item_lifecycle_step_decay.js": "global.TarinaiItemDecayLifecycleStep",
|
||||
"js/item_lifecycle_step_dynamic.js": "global.TarinaiItemDynamicLifecycleStep",
|
||||
"js/item_lifecycle_step_growth.js": "global.TarinaiItemGrowthLifecycleStep",
|
||||
"js/item_lifecycle_step_legacy.js": "global.TarinaiItemLegacyLifecycleStep",
|
||||
"js/item_lifecycle_pipeline.js": "global.TarinaiItemLifecyclePipeline",
|
||||
}
|
||||
for rel, token in exports.items():
|
||||
if token not in read_text(ROOT / rel):
|
||||
fail(f"Item lifecycle pipeline export missing: {rel}")
|
||||
runtime = read_text(ROOT / "js/item_runtime.js")
|
||||
if "TarinaiItemLifecyclePipeline.updateOne" not in runtime or "legacyUpdate: fn" not in runtime:
|
||||
fail("item runtime does not route through TarinaiItemLifecyclePipeline with legacy fallback")
|
||||
pipeline = read_text(ROOT / "js/item_lifecycle_pipeline.js")
|
||||
for token in ["TarinaiItemFrameLifecycleStep", "TarinaiItemDecayLifecycleStep", "TarinaiItemDynamicLifecycleStep", "TarinaiItemGrowthLifecycleStep", "TarinaiItemLegacyLifecycleStep"]:
|
||||
if token not in pipeline:
|
||||
fail(f"Item lifecycle pipeline missing step token: {token}")
|
||||
dynamic_step = read_text(ROOT / "js/item_lifecycle_step_dynamic.js")
|
||||
dynamic_system = read_text(ROOT / "js/item_dynamic_system.js")
|
||||
if "TarinaiItemDynamicSystem?.update" not in dynamic_step:
|
||||
fail("Item dynamic lifecycle step does not delegate to TarinaiItemDynamicSystem")
|
||||
for token in ["TarinaiItemDynamicToolSystem", "TarinaiItemDynamicBallSystem", "TarinaiItemDynamicDuplicatorSystem", "TarinaiItemDynamicPinSystem", "TarinaiItemDynamicZunchiSystem"]:
|
||||
if token not in dynamic_system:
|
||||
fail(f"Item dynamic system missing subsystem token: {token}")
|
||||
for rel in ["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"]:
|
||||
body = read_text(ROOT / rel)
|
||||
for token in ["item.updateFan", "item.updateMagnet", "item.updateRotator", "item.updateBall", "item.updateDuplicator", "item.updatePushpin", "item.updateZunchiMotion", "item.updateZunchi"]:
|
||||
if token in body:
|
||||
fail(f"dynamic subsystem still delegates to prototype method {token} in {rel}")
|
||||
lifecycle_runtime = read_text(ROOT / "js/item_lifecycle_runtime.js")
|
||||
lifecycle_support = read_text(ROOT / "js/item_lifecycle_support.js")
|
||||
lifecycle_decay = read_text(ROOT / "js/item_lifecycle_step_decay.js")
|
||||
lifecycle_growth = read_text(ROOT / "js/item_lifecycle_step_growth.js")
|
||||
if "TarinaiItemLifecycleRuntimeSupport" not in lifecycle_support or "TarinaiDuplicatorRuntime" not in lifecycle_support:
|
||||
fail("item lifecycle support export missing")
|
||||
if "TarinaiItemDecaySystem?.update" not in lifecycle_decay or "TarinaiItemGrowthSystem?.update" not in lifecycle_growth:
|
||||
fail("item lifecycle decay/growth steps are not thin system adapters")
|
||||
for helper_token in ["function fanAffectsPoint", "function duplicatorLoadTypeForItem", "function duplicatorApplyStoredPin"]:
|
||||
if helper_token in lifecycle_runtime:
|
||||
fail(f"support helper still lives in item_lifecycle_runtime.js: {helper_token}")
|
||||
for token in ["updateFan(dt, worldRef) { return window.TarinaiItemDynamicToolSystem", "updateBall(dt, worldRef) { return window.TarinaiItemDynamicBallSystem", "attachPushpin(t, worldRef"]:
|
||||
if token not in lifecycle_runtime:
|
||||
fail(f"prototype compatibility facade missing: {token}")
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.isServingFoodType = (type) => type === 'food';
|
||||
context.passiveFoodDecayInterval = () => 0.5;
|
||||
context.passiveFoodDecayRate = () => 0.1;
|
||||
context.PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING = 0.055;
|
||||
context.TarinaiItemRegistry = {{ food: {{ hygienePenalty() {{ return 0.05; }} }} }};
|
||||
context.isPinType = (type) => type === 'pushpin';
|
||||
context.normalizeGrassStage = (item) => {{ item.normalized = true; }};
|
||||
context.stableUnit = () => 0.5;
|
||||
vm.createContext(context);
|
||||
for (const file of {required!r}) {{
|
||||
vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }});
|
||||
}}
|
||||
if (!context.TarinaiItemLifecyclePipeline.canRunConcretePipeline()) throw new Error('item pipeline cannot run concrete steps');
|
||||
const events = [];
|
||||
const world = {{
|
||||
itemDropImpact(item) {{ events.push(['drop', item.type]); }},
|
||||
registerFoodSpoilage(value, item) {{ events.push(['spoil', value, item.type]); }},
|
||||
markTerrainDirty(reason) {{ events.push(['terrain', reason]); }},
|
||||
emit(type, payload) {{ events.push(['emit', type, payload.type]); }},
|
||||
}};
|
||||
const food = {{ type: 'food', dead: false, age: 0, dropTimer: 0.1, dropImpactDone: false, foodServingsRemaining: 1, amount: 1, passiveFoodDecayTimer: 0 }};
|
||||
if (!context.TarinaiItemLifecyclePipeline.updateOne(food, 0.6, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('food update failed');
|
||||
if (food.age !== 0.6 || !food.dropImpactDone || !(food.foodServingsRemaining < 1) || !events.some(([kind]) => kind === 'spoil')) throw new Error('frame/decay step did not run');
|
||||
context.CONFIG = {{ worldPadding: 30 }};
|
||||
context.clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
context.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
||||
const dynamic = {{
|
||||
type: 'ball', dead: false, age: 0, x: 80, y: 80, prevX: 80, prevY: 80,
|
||||
vx: 12, vy: 0, r: 18, spin: 0, spinVelocity: 0
|
||||
}};
|
||||
const ballWorld = {{ ...world, w: 500, h: 500, nearbyItems() {{ return []; }} }};
|
||||
context.TarinaiItemLifecyclePipeline.updateOne(dynamic, 0.25, ballWorld, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }});
|
||||
if (!(dynamic.x > 80) || !(dynamic.spin > 0)) throw new Error('dynamic step did not run');
|
||||
const grass = {{ type: 'grass', dead: false, age: 0 }};
|
||||
context.TarinaiItemLifecyclePipeline.updateOne(grass, 0.1, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }});
|
||||
if (!grass.normalized) throw new Error('growth step did not run');
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("Item lifecycle pipeline smoke test failed")
|
||||
ok("Item lifecycle pipeline boundary present")
|
||||
|
||||
def main() -> None:
|
||||
data = manifest()
|
||||
check_files_exist(data)
|
||||
|
|
@ -610,7 +1239,14 @@ def main() -> None:
|
|||
check_item_bucket_polish()
|
||||
check_current_save_format()
|
||||
check_phase_and_input_split()
|
||||
check_system_order_boundary()
|
||||
simulation_boundary_smoke()
|
||||
check_entity_runtime_boundary()
|
||||
check_item_lifecycle_pipeline_boundary()
|
||||
check_tarinai_update_pipeline_boundary()
|
||||
check_update_policy_boundaries()
|
||||
check_processing_split()
|
||||
command_dispatcher_smoke()
|
||||
check_legacy_behavior_refs()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use strict";
|
||||
|
||||
const APP_VERSION = "16.07.32";
|
||||
const APP_VERSION = "16.07.50";
|
||||
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||
const v = `v=${APP_VERSION}`;
|
||||
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
|
||||
const coreScriptNames = [
|
||||
"version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "world_update_phases", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "restore_coordinator", "save_codec", "save_storage", "save_system", "patch_helpers", "freeze_store", "freeze_snapshot_adapter", "freeze_panel", "freeze_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
|
||||
"version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_lifecycle_legacy_system", "item_lifecycle_runtime", "item_update_policy", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "item_lifecycle_step_legacy", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_forced_action_planner_system", "tarinai_action_planner_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_update_legacy_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_step_legacy", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "world_update_phases", "system_order", "simulation", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "save_codec", "save_storage", "save_system", "patch_helpers", "freeze_store", "freeze_snapshot_adapter", "freeze_panel", "freeze_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
|
||||
];
|
||||
const CORE_ASSETS = [
|
||||
"./",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue