This commit is contained in:
33333-33333 2026-06-26 19:04:32 +09:00
commit 077d4ab9ac
71 changed files with 2905 additions and 1063 deletions

View file

@ -1,5 +1,5 @@
{
"version": "16.07.50",
"version": "38.00.00",
"css": [
"css/base.css",
"css/layout.css",
@ -19,6 +19,9 @@
"js/input_mode_manager.js",
"js/math.js",
"js/physics_helpers.js",
"js/collision_footprint_system.js",
"js/mechanical_system.js",
"js/constraint_system.js",
"js/assets.js",
"js/audio.js",
"js/perf_profiler.js",
@ -29,7 +32,6 @@
"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",
@ -44,7 +46,6 @@
"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",
@ -77,14 +78,12 @@
"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",
@ -123,14 +122,11 @@
"js/save_schema.js",
"js/snapshot_system.js",
"js/restore_coordinator.js",
"js/history_system.js",
"js/save_codec.js",
"js/save_storage.js",
"js/save_system.js",
"js/patch_helpers.js",
"js/freeze_store.js",
"js/freeze_snapshot_adapter.js",
"js/freeze_panel.js",
"js/freeze_system.js",
"js/ui_tooltips.js",
"js/ui_layout_dialogs.js",
"js/ui_ground.js",
@ -156,5 +152,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. 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."
"notes": "Boundary cleanup v38: freezer removed, washing expanded and continuous, laboratory ground added, and save schema synchronized."
}

View file

@ -347,23 +347,23 @@ h1 {
.tool-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
gap: 8px;
gap: 6px;
}
.tool {
--tool-main-icon-size: 38px;
--tool-icon-zoom: 1.12;
--tool-main-icon-size: 28px;
--tool-icon-zoom: 1.05;
--tool-watermark-size: 0px;
border-radius: 12px;
display: grid;
grid-template-columns: 42px minmax(0,1fr);
grid-template-columns: 29px minmax(0,1fr);
align-items: center;
justify-items: stretch;
gap: 4px;
padding: 4px 7px 4px 3px;
min-height: 48px;
font-size: 12px;
line-height: 1.08;
gap: 3px;
padding: 4px 4px 4px 2px;
min-height: 47px;
font-size: 10.2px;
line-height: 1.06;
letter-spacing: -0.015em;
text-align: left;
position: relative;
@ -375,7 +375,7 @@ h1 {
.tool::before {
content: "";
position: absolute;
left: 4px;
left: 3px;
top: 50%;
width: var(--tool-main-icon-size);
height: var(--tool-main-icon-size);
@ -405,31 +405,34 @@ h1 {
max-width: 100%;
min-width: 0;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.12;
font-size: 10.2px;
line-height: 1.10;
font-weight: 700;
max-width: 5.05em;
word-break: normal;
overflow-wrap: anywhere;
text-shadow: 0 1px 0 rgba(255,255,255,0.62);
}
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-columns: 42px minmax(0,1fr); min-height: 48px; padding-top: 4px; }
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-columns: 29px minmax(0,1fr); min-height: 47px; padding-top: 4px; }
.tool.tool-has-canvas-icon::before,
.tool.tool-has-canvas-icon::after { background: none !important; }
.tool-sprite-preview {
position: absolute;
left: 2px;
left: 1px;
top: 50%;
width: 42px;
height: 42px;
width: 30px;
height: 30px;
transform: translateY(-50%);
pointer-events: none;
z-index: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool-sprite-watermark { display: none !important; }
.tool[data-tool="giant_drug"] .tool-sprite-preview { width: 56px; height: 56px; left: -5px; }
.tool[data-tool="giant_drug"] .tool-sprite-preview { width: 39px; height: 39px; left: -4px; }
.tool[data-tool="dwarf_drug"] .tool-sprite-preview,
.tool[data-tool="oshibyo"] .tool-sprite-preview { width: 28px; height: 28px; left: 9px; }
.tool[data-tool="oshibyo"] .tool-sprite-preview { width: 22px; height: 22px; left: 5px; }
.tool[data-tool="poke"], .tool[data-tool="water_hose"] { --tool-icon-zoom: 0.92; }
.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.webp"); }
.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.webp"); }

View file

@ -736,3 +736,52 @@
border-radius: 999px;
background: rgba(111, 153, 102, 0.13);
}
/* v36: pin only the operation tool category under the quick-scroll tabs. */
.tool-category[data-tool-category="operate"]:not(.collapsed) {
position: sticky;
top: 58px;
z-index: 11;
overflow: hidden;
background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(221, 237, 255, 0.98));
border-color: rgba(82,128,190,0.36);
box-shadow: 0 8px 18px rgba(58, 82, 112, 0.13), inset 0 1px 0 rgba(255,255,255,0.82);
}
.tool-category[data-tool-category="operate"] .tool-category-toggle {
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98));
border-bottom-color: rgba(82,128,190,0.30);
}
.tool-category[data-tool-category="operate"] .tool-category-body {
background: rgba(238, 247, 255, 0.98);
}
.tool-category[data-tool-category="operate"] .tool-category-body {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 5px;
}
.tool-category[data-tool-category="operate"] .tool {
grid-template-columns: 1fr;
min-height: 38px;
padding: 4px;
justify-items: center;
align-items: center;
text-align: center;
background: rgba(255,255,255,0.96);
border-color: rgba(82,128,190,0.28);
}
.tool-category[data-tool-category="operate"] .tool-label {
display: none;
}
.tool-category[data-tool-category="operate"] .tool::before {
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(var(--tool-icon-zoom, 1));
}
.tool-category[data-tool-category="operate"] .tool-sprite-preview {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
@media (max-width: 980px) {
.tool-category[data-tool-category="operate"]:not(.collapsed) { top: 58px; }
}

View file

@ -10,6 +10,13 @@
.tool[data-tool="poke"] { --tool-emoji: "\1F448"; }
.tool[data-tool="pinch"] { --tool-emoji: "\1F90F"; }
.tool[data-tool="water_hose"] { --tool-emoji: "\1F6BF"; }
.tool[data-tool="copy"] { --tool-emoji: "⧉"; }
.tool[data-tool="area_delete"] { --tool-emoji: "✕"; }
.tool[data-tool="rope"] { --tool-emoji: "╰"; }
.tool[data-tool="rod"] { --tool-emoji: "━"; }
.tool[data-tool="reciprocator"] { --tool-emoji: "⇄"; }
.tool[data-tool="undo"] { --tool-emoji: "↶"; }
.tool[data-tool="redo"] { --tool-emoji: "↷"; }
.tool.tool-emoji-icon { --tool-icon: none; }
.tool.tool-emoji-icon::before {
content: var(--tool-emoji);
@ -25,8 +32,21 @@
.tool[data-tool="poke"].tool-emoji-icon::before,
.tool[data-tool="water_hose"].tool-emoji-icon::before { font-size: 25px; }
.tool[data-tool="rope"].tool-emoji-icon::before,
.tool[data-tool="rod"].tool-emoji-icon::before,
.tool[data-tool="reciprocator"].tool-emoji-icon::before,
.tool[data-tool="undo"].tool-emoji-icon::before,
.tool[data-tool="redo"].tool-emoji-icon::before { font-size: 29px; font-family: var(--font, sans-serif); }
.tool[data-tool="delete"],
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; }
.tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; }
.tool[data-tool="area_delete"].tool-emoji-icon::before {
font-size: 30px;
color: #f3a7aa;
text-shadow: 0 1px 0 rgba(255,255,255,0.65), 0 2px 5px rgba(146,54,62,0.16);
filter: none;
}
.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; }
.tool[data-tool="observe"],
.tool[data-tool="new"] { --tool-icon-zoom: 1.20; }
@ -328,6 +348,7 @@ meter {
.tool-category[data-tool-category="food"] { --tool-cat-a: rgba(236,252,219,0.82); --tool-cat-b: rgba(214,244,194,0.74); --tool-cat-line: rgba(96,156,72,0.26); }
.tool-category[data-tool-category="medicine"] { --tool-cat-a: rgba(243,231,255,0.84); --tool-cat-b: rgba(230,214,252,0.78); --tool-cat-line: rgba(136,94,184,0.28); }
.tool-category[data-tool-category="habitat"] { --tool-cat-a: rgba(255,245,218,0.84); --tool-cat-b: rgba(250,229,186,0.76); --tool-cat-line: rgba(174,125,54,0.28); }
.tool-category[data-tool-category="physics"] { --tool-cat-a: rgba(226,247,246,0.84); --tool-cat-b: rgba(199,232,231,0.76); --tool-cat-line: rgba(70,145,148,0.28); }
.tool-category[data-tool-category="hazard"] { --tool-cat-a: rgba(255,230,224,0.84); --tool-cat-b: rgba(250,207,197,0.78); --tool-cat-line: rgba(189,88,66,0.30); }
.tool-category { border-color: var(--tool-cat-line, rgba(84,68,48,0.10)); background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,255,255,0.36)), rgba(255,255,255,0.28)); }
.tool-category-toggle { background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54))); border-bottom-color: var(--tool-cat-line, rgba(84,68,48,0.08)); }

View file

@ -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.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" />
<link rel="stylesheet" href="css/base.css?v=38.00.00" />
<link rel="stylesheet" href="css/layout.css?v=38.00.00" />
<link rel="stylesheet" href="css/panel.css?v=38.00.00" />
<link rel="stylesheet" href="css/components.css?v=38.00.00" />
<link rel="stylesheet" href="css/mobile.css?v=38.00.00" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -189,144 +189,140 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=16.07.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>
<script src="js/version.js?v=38.00.00" defer></script>
<script src="js/event_bus.js?v=38.00.00" defer></script>
<script src="js/registry_base.js?v=38.00.00" defer></script>
<script src="js/disease_registry.js?v=38.00.00" defer></script>
<script src="js/sound_pack.js?v=38.00.00" defer></script>
<script src="js/item_registry.js?v=38.00.00" defer></script>
<script src="js/data.js?v=38.00.00" defer></script>
<script src="js/ground_types.js?v=38.00.00" defer></script>
<script src="js/input_mode_manager.js?v=38.00.00" defer></script>
<script src="js/math.js?v=38.00.00" defer></script>
<script src="js/physics_helpers.js?v=38.00.00" defer></script>
<script src="js/collision_footprint_system.js?v=38.00.00" defer></script>
<script src="js/mechanical_system.js?v=38.00.00" defer></script>
<script src="js/constraint_system.js?v=38.00.00" defer></script>
<script src="js/assets.js?v=38.00.00" defer></script>
<script src="js/audio.js?v=38.00.00" defer></script>
<script src="js/perf_profiler.js?v=38.00.00" defer></script>
<script src="js/render.js?v=38.00.00" defer></script>
<script src="js/sim_core.js?v=38.00.00" defer></script>
<script src="js/tarinai_seed_factory.js?v=38.00.00" defer></script>
<script src="js/structures.js?v=38.00.00" defer></script>
<script src="js/items.js?v=38.00.00" defer></script>
<script src="js/item_type_initializers.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_support.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_runtime.js?v=38.00.00" defer></script>
<script src="js/item_update_policy.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_tool_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_ball_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_pin_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=38.00.00" defer></script>
<script src="js/item_dynamic_system.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=38.00.00" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=38.00.00" defer></script>
<script src="js/item_runtime.js?v=38.00.00" defer></script>
<script src="js/structure_lifecycle.js?v=38.00.00" defer></script>
<script src="js/item_render_runtime.js?v=38.00.00" defer></script>
<script src="js/ants.js?v=38.00.00" defer></script>
<script src="js/health.js?v=38.00.00" defer></script>
<script src="js/tarinai.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_spec.js?v=38.00.00" defer></script>
<script src="js/tarinai_behavior_state.js?v=38.00.00" defer></script>
<script src="js/tarinai_identity_social.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_state.js?v=38.00.00" defer></script>
<script src="js/tarinai_disease_nest.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_effects.js?v=38.00.00" defer></script>
<script src="js/tarinai_needs_core.js?v=38.00.00" defer></script>
<script src="js/tarinai_behavior_text.js?v=38.00.00" defer></script>
<script src="js/tarinai_forced_behavior.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_targeting.js?v=38.00.00" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=38.00.00" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=38.00.00" defer></script>
<script src="js/tarinai_building_behavior.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_definitions.js?v=38.00.00" defer></script>
<script src="js/tarinai_needs_items.js?v=38.00.00" defer></script>
<script src="js/tarinai_forced_action_planner_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_action_planner_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_need_planner_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=38.00.00" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_contact_item_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_sunbath_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_local_environment_system.js?v=38.00.00" defer></script>
<script src="js/tarinai_social_move_life.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_frame.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_ai.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_environment.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_movement.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_step_health.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_pipeline.js?v=38.00.00" defer></script>
<script src="js/tarinai_runtime.js?v=38.00.00" defer></script>
<script src="js/tarinai_render.js?v=38.00.00" defer></script>
<script src="js/world.js?v=38.00.00" defer></script>
<script src="js/world_view.js?v=38.00.00" defer></script>
<script src="js/family_graph.js?v=38.00.00" defer></script>
<script src="js/world_family_social.js?v=38.00.00" defer></script>
<script src="js/world_combat_effects.js?v=38.00.00" defer></script>
<script src="js/world_environment.js?v=38.00.00" defer></script>
<script src="js/world_spatial_budget.js?v=38.00.00" defer></script>
<script src="js/world_ants_system.js?v=38.00.00" defer></script>
<script src="js/weather_system.js?v=38.00.00" defer></script>
<script src="js/item_update_scheduler.js?v=38.00.00" defer></script>
<script src="js/simulation_runtime_helpers.js?v=38.00.00" defer></script>
<script src="js/tarinai_update_policy.js?v=38.00.00" defer></script>
<script src="js/simulation_environment_system.js?v=38.00.00" defer></script>
<script src="js/simulation_item_ant_system.js?v=38.00.00" defer></script>
<script src="js/simulation_effects_system.js?v=38.00.00" defer></script>
<script src="js/simulation_creature_system.js?v=38.00.00" defer></script>
<script src="js/simulation_maintenance_system.js?v=38.00.00" defer></script>
<script src="js/simulation_ambient_system.js?v=38.00.00" defer></script>
<script src="js/simulation_systems.js?v=38.00.00" defer></script>
<script src="js/world_update_phases.js?v=38.00.00" defer></script>
<script src="js/system_order.js?v=38.00.00" defer></script>
<script src="js/simulation.js?v=38.00.00" defer></script>
<script src="js/world_update.js?v=38.00.00" defer></script>
<script src="js/world_tool_actions.js?v=38.00.00" defer></script>
<script src="js/world_placement_log.js?v=38.00.00" defer></script>
<script src="js/world_event_effects.js?v=38.00.00" defer></script>
<script src="js/command_dispatcher.js?v=38.00.00" defer></script>
<script src="js/text_catalog.js?v=38.00.00" defer></script>
<script src="js/ui.js?v=38.00.00" defer></script>
<script src="js/ui_log.js?v=38.00.00" defer></script>
<script src="js/ui_helpers.js?v=38.00.00" defer></script>
<script src="js/ui_selected.js?v=38.00.00" defer></script>
<script src="js/save_schema.js?v=38.00.00" defer></script>
<script src="js/snapshot_system.js?v=38.00.00" defer></script>
<script src="js/restore_coordinator.js?v=38.00.00" defer></script>
<script src="js/history_system.js?v=38.00.00" defer></script>
<script src="js/save_codec.js?v=38.00.00" defer></script>
<script src="js/save_storage.js?v=38.00.00" defer></script>
<script src="js/save_system.js?v=38.00.00" defer></script>
<script src="js/patch_helpers.js?v=38.00.00" defer></script>
<script src="js/ui_tooltips.js?v=38.00.00" defer></script>
<script src="js/ui_layout_dialogs.js?v=38.00.00" defer></script>
<script src="js/ui_ground.js?v=38.00.00" defer></script>
<script src="js/ui_tools.js?v=38.00.00" defer></script>
<script src="js/ui_input_shared.js?v=38.00.00" defer></script>
<script src="js/ui_input_touch.js?v=38.00.00" defer></script>
<script src="js/ui_input_mouse.js?v=38.00.00" defer></script>
<script src="js/ui_bind.js?v=38.00.00" defer></script>
<script src="js/ui_charts.js?v=38.00.00" defer></script>
<script src="js/ui_family_data.js?v=38.00.00" defer></script>
<script src="js/ui_family_async.js?v=38.00.00" defer></script>
<script src="js/ui_family_layout.js?v=38.00.00" defer></script>
<script src="js/ui_family_paths.js?v=38.00.00" defer></script>
<script src="js/ui_family_render.js?v=38.00.00" defer></script>
<script src="js/main.js?v=38.00.00" defer></script>
<script src="js/debug_tools.js?v=38.00.00" defer></script>
</body>
</html>

View file

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

View file

@ -125,6 +125,7 @@
const py = Number(y);
if (!Number.isFinite(px) || !Number.isFinite(py)) return fail("invalid_point", { x, y });
if (typeof worldRef.handleClick !== "function") return fail("missing_pointer_handler");
if (worldRef.tool && worldRef.tool !== "observe") global.TarinaiHistory?.capture?.(worldRef, `tool:${worldRef.tool}`);
worldRef.handleClick(px, py);
emit(worldRef, "pointer:primary", { x: px, y: py, toolId: worldRef.tool || "" });
return ok({ x: px, y: py, toolId: worldRef.tool || "" });
@ -154,6 +155,14 @@
return cycleSpeed(worldRef, command.speeds);
case "pointer.primary":
return primaryPointer(worldRef, command.x, command.y);
case "history.undo": {
const result = global.TarinaiHistory?.undo?.(worldRef) || { ok: false, reason: "missing_history" };
return result.ok ? ok({ action: "undo" }) : fail(result.reason || "undo_failed");
}
case "history.redo": {
const result = global.TarinaiHistory?.redo?.(worldRef) || { ok: false, reason: "missing_history" };
return result.ok ? ok({ action: "redo" }) : fail(result.reason || "redo_failed");
}
default:
return fail("unknown_command", { type: command.type || "" });
}
@ -188,5 +197,7 @@
togglePaused,
cycleSpeed,
primaryPointer,
undo(worldRef = global.world) { return global.TarinaiHistory?.undo?.(worldRef) || fail("missing_history"); },
redo(worldRef = global.world) { return global.TarinaiHistory?.redo?.(worldRef) || fail("missing_history"); },
});
})(typeof window !== "undefined" ? window : globalThis);

294
js/constraint_system.js Normal file
View file

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

View file

@ -1,27 +1,44 @@
"use strict";
(function (global) {
const Store = global.TarinaiFreezeStore;
const Adapter = global.TarinaiFreezeSnapshotAdapter;
const Panel = global.TarinaiFreezePanel;
if (!Store || !Adapter || !Panel) throw new Error("freeze_system.js dependencies are not available");
function deps() {
return {
Store: global.TarinaiFreezeStore,
Adapter: global.TarinaiFreezeSnapshotAdapter,
Panel: global.TarinaiFreezePanel,
};
}
function depsReady() {
const d = deps();
return Boolean(d.Store && d.Adapter && d.Panel);
}
function warnMissing(context) {
if (global.console?.warn) console.warn(`freeze_system.js dependencies are not available yet (${context})`);
}
let pendingThawId = "";
function frozenList(worldRef = global.world) {
const { Store } = deps();
if (!Store?.frozenList) return [];
return Store.frozenList(worldRef);
}
function renderFrozenPanel(worldRef = global.world) {
const { Panel } = deps();
if (!Panel?.render) return;
Panel.render(frozenList(worldRef));
}
function saveFrozenStore(worldRef = global.world) {
const { Store } = deps();
if (!Store?.save) return;
Store.save(worldRef);
}
function loadFrozenStore(worldRef = global.world) {
return Store.load(worldRef);
const { Store } = deps();
return Store?.load ? Store.load(worldRef) : [];
}
function refreshWorldAfterFreezeChange(worldRef = global.world, selected = null) {
@ -36,6 +53,8 @@
}
function freezeTarinai(t, worldRef = global.world) {
const { Adapter } = deps();
if (!Adapter?.makeEntry) { warnMissing("freezeTarinai"); return false; }
if (!t || t.dead || !worldRef) return false;
const entry = Adapter.makeEntry(t, worldRef);
worldRef.clearLiveReferencesTo?.(t);
@ -44,13 +63,15 @@
if (worldRef.selected === t) worldRef.selected = null;
frozenList(worldRef).push(entry);
refreshWorldAfterFreezeChange(worldRef, worldRef.selected);
global.showToast?.(`${t.name}\u3092\u51b7\u51cd\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3057\u305f\u3002`, "observe", { participants: [t] });
global.showToast?.(`${t.name}を冷凍保存しました。`);
worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] });
worldRef.emit?.("tarinai:frozen", { entry, tarinai: t });
return true;
}
function thawFrozen(freezeId, x, y, worldRef = global.world) {
const { Adapter } = deps();
if (!Adapter?.thawEntry) { warnMissing("thawFrozen"); return false; }
const list = frozenList(worldRef);
const index = list.findIndex(e => (e.freezeId || e.id) === freezeId);
if (index < 0 || !worldRef) return false;
@ -65,37 +86,44 @@
worldRef.recordFamily?.(t);
list.splice(index, 1);
refreshWorldAfterFreezeChange(worldRef, t);
global.showToast?.(`${t.name}\u3092\u89e3\u51cd\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u89e3\u51cd\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3057\u305f\u3002`, "birth", { participants: [t] });
global.showToast?.(`${t.name}を解凍しました。`);
worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] });
worldRef.emit?.("tarinai:thawed", { entry, tarinai: t });
return true;
}
function bindFreezeSystem() {
if (!depsReady()) {
warnMissing("bindFreezeSystem");
global.setTimeout?.(() => { if (depsReady()) bindFreezeSystem(); }, 0);
return false;
}
const { Panel } = deps();
const worldRef = global.world;
loadFrozenStore(worldRef);
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
Panel.bind({
onSelect(id) {
pendingThawId = id || "";
global.showToast?.("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u89e3\u51cd\u3057\u307e\u3059\u3002\u30c9\u30e9\u30c3\u30b0&\u30c9\u30ed\u30c3\u30d7\u3067\u3082\u914d\u7f6e\u3067\u304d\u307e\u3059\u3002");
global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。");
},
onDrop(id, x, y) {
thawFrozen(id, x, y, global.world);
},
});
return true;
}
function tryPendingThawAt(x, y, worldRef = global.world) {
if (!pendingThawId) return false;
const id = pendingThawId;
pendingThawId = "";
Panel.clearPending();
deps().Panel?.clearPending?.();
return thawFrozen(id, x, y, worldRef);
}
function afterWorldRestored(worldRef = global.world) {
Store.ensure(worldRef);
deps().Store?.ensure?.(worldRef);
saveFrozenStore(worldRef);
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
}

View file

@ -1,14 +1,15 @@
"use strict";
const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice"]);
const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
const GROUND_TYPES = Object.freeze({
// 既存セーブとの互換性のため、デフォルトIDは soil のままにし、表示名だけ「砂」にする
// デフォルト地面ID。表示名は「砂」
soil: Object.freeze({ id: "soil", label: "砂", description: "普通の地面。", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
dirt: Object.freeze({ id: "dirt", label: "土", description: "草がよく育つ地面。", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }),
concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "青いまだら地面。ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
ice: Object.freeze({ id: "ice", label: "氷", description: "つるつる滑る。草は生えにくい", stressMultiplier: 1.1, grassMultiplier: 0.15, grassGrowthMultiplier: 0.35 }),
laboratory: Object.freeze({ id: "laboratory", label: "実験室", description: "常に晴れ。草は生えず、ずんちはすぐ消える。", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0, fixedWeather: "sunny", zunchiStageMultiplier: 8.0, zunchiDecayMultiplier: 520.0 }),
});
function groundDefinition(id = "soil") {
@ -83,4 +84,7 @@ window.TarinaiGround = Object.freeze({
grassLimit: groundGrassLimit,
nextId: nextGroundTypeId,
tooltipText: groundTooltipText,
fixedWeather(id = "soil") { return groundDefinition(id).fixedWeather || ""; },
zunchiStageMultiplier(id = "soil") { const v = Number(groundDefinition(id).zunchiStageMultiplier); return Number.isFinite(v) && v > 0 ? v : 1; },
zunchiDecayMultiplier(id = "soil") { const v = Number(groundDefinition(id).zunchiDecayMultiplier); return Number.isFinite(v) && v > 0 ? v : 1; },
});

91
js/history_system.js Normal file
View file

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

View file

@ -2,7 +2,7 @@
// 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.
// methods delegate to this system.
(function (global) {
function update(item, dt, worldRef) {
if (!item || item.dead || item.type !== "ball") return false;
@ -36,12 +36,12 @@
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 (!it || it.dead || it === item || !global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type) || seen.has(it)) continue;
const reach = global.TarinaiMechanicalSystem?.reach?.(it) || worldRef.rotatorExtent?.(it) || Math.max(64, it.r || 64);
if (distXY(item.x, item.y, it.x, it.y) <= searchRadius + reach + 36) {
items.push(it);
seen.add(it);

View file

@ -3,7 +3,7 @@
// 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.
// save/restore stability.
(function (global) {
function support() {
return global.TarinaiItemLifecycleRuntimeSupport || {};

View file

@ -2,7 +2,7 @@
// Layer: entity-runtime/items/dynamic-system
// Owns pushpin/oshibyo item motion, attachment, lodged damage, and detach
// behavior. Prototype methods remain as compatibility facades.
// behavior. Prototype methods delegate here.
(function (global) {
function isPin(item) {
return Boolean(item && typeof global.isPinType === "function" && global.isPinType(item.type));

View file

@ -9,10 +9,11 @@
switch (item.type) {
case "firecracker":
case "genkotsu":
case "fan":
case "magnet":
case "rotator":
case "reciprocator":
case "gate_fence":
case "rope":
case "rod":
return global.TarinaiItemDynamicToolSystem?.update(item, dt, worldRef) !== false;
case "ball":
return global.TarinaiItemDynamicBallSystem?.update(item, dt, worldRef) !== false;

View file

@ -1,9 +1,9 @@
"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.
// Owns dynamic tool behavior for rotator, reciprocator, firecracker, genkotsu,
// links, and static gate-fence ticking. Item.prototype methods delegate here for
// direct Item.prototype calls; lifecycle dispatch also calls this boundary directly.
(function (global) {
function support() {
return global.TarinaiItemLifecycleRuntimeSupport || {};
@ -27,87 +27,29 @@
}
}
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;
return Boolean(global.TarinaiMechanicalSystem?.updateRotator?.(item, dt, worldRef));
}
function updateReciprocator(item, dt, worldRef) {
return Boolean(global.TarinaiMechanicalSystem?.updateReciprocator?.(item, dt, worldRef));
}
function resolveMechanicalInteractions(item, dt, worldRef) {
return Boolean(global.TarinaiMechanicalSystem?.resolveInteractions?.(item, dt, worldRef));
}
function updateFlexibleLink(item, dt, worldRef) {
return Boolean(global.TarinaiConstraintSystem?.updateFlexibleLink?.(item, dt, worldRef));
}
function updateRigidLink(item, dt, worldRef) {
return Boolean(global.TarinaiConstraintSystem?.updateRigidLink?.(item, dt, worldRef));
}
function updateGateFence(item) {
if (item) item.amount = 999;
return Boolean(item);
@ -117,10 +59,11 @@
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 === "reciprocator") return updateReciprocator(item, dt, worldRef);
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef);
if (item.type === "rod") return updateRigidLink(item, dt, worldRef);
return false;
}
@ -128,9 +71,11 @@
update,
updateFirecracker,
updateGenkotsu,
updateFan,
updateMagnet,
updateRotator,
updateReciprocator,
updateFlexibleLink,
updateRigidLink,
resolveMechanicalInteractions,
updateGateFence,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -2,12 +2,16 @@
// Layer: entity-runtime/items/dynamic-system
// Owns zunchi item motion, high-speed collision, and lifecycle stage ticking.
// Prototype methods remain as compatibility facades.
// Prototype methods delegate here.
(function (global) {
function updateMotion(item, dt, worldRef) {
if (!item || item.dead || item.type !== "zunchi") return false;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
if (speed < 0.08) { item.vx = 0; item.vy = 0; return true; }
if (speed < 0.08) {
item.vx = 0; item.vy = 0;
global.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(item, dt, worldRef);
return true;
}
if (global.TarinaiPhysics?.applyKinematicItemMotion) {
global.TarinaiPhysics.applyKinematicItemMotion(item, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
} else {
@ -25,6 +29,7 @@
item.vy *= slow;
worldRef.drawListDirty = true;
}
global.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(item, dt, worldRef);
resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed);
item.spin = (item.spin || 0) + speed * dt / Math.max(8, item.r || 14);
return true;
@ -46,6 +51,10 @@
const impulse = clamp(speed * 1.15, 150, 620);
t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20);
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
if (speed >= 185) {
const damage = Math.min(18, Math.max(1, (speed - 185) / 22));
worldRef.applyImpactDamage?.(t, damage, "衝突", { source: item }) || t.damage?.(damage, "衝突");
}
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
t.fallDir = nx < 0 ? -1 : 1;
@ -68,15 +77,18 @@
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;
const groundId = worldRef?.groundType || "soil";
const stageMul = Math.max(0.1, Number(global.TarinaiGround?.zunchiStageMultiplier?.(groundId) || 1) || 1);
const groundDecayMul = Math.max(0.1, Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1);
const decayBoost = (rainy ? 1.35 : 1) * groundDecayMul;
let waterBoost = 0;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(item.x, item.y, 84) : (worldRef.items || []);
for (const it of nearby) {
if (it.type !== "water") continue;
waterBoost += clamp(1 - distXY(item.x, item.y, it.x, it.y) / 80, 0, 1);
}
item.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65);
const decayDt = (item.spawnGrace || 0) > 0 ? 0 : dt;
item.stageTimer += dt * 0.70 * stageMul * (rainBoost + waterBoost * 0.65);
const decayDt = ((item.spawnGrace || 0) > 0 && groundDecayMul <= 1.01) ? 0 : dt;
if (item.stage === "fresh") {
item.freshness = clamp(1 - item.stageTimer / 110, 0, 1);
item.amount -= decayDt * 0.018 * decayBoost;

View file

@ -70,7 +70,6 @@
}
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);

View file

@ -1,10 +1,8 @@
"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.
// Owns the ordered item lifecycle pipeline. There is no monolithic fallback path:
// every item update must be handled by the explicit lifecycle steps below.
(function (global) {
const CONCRETE_STEP_NAMES = Object.freeze([
"TarinaiItemFrameLifecycleStep",
@ -21,7 +19,9 @@
return concreteSteps().length === CONCRETE_STEP_NAMES.length;
}
function runConcrete(item, dt, worldRef, context) {
function updateOne(item, dt, worldRef, options = {}) {
if (!item || item.dead || !canRunConcretePipeline()) return false;
const context = options.context || {};
for (const step of concreteSteps()) {
const result = step.update(item, dt, worldRef, context) || {};
if (result.done) return result.ok !== false;
@ -29,21 +29,6 @@
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,

View file

@ -1,15 +1,12 @@
"use strict";
// Layer: entity-runtime/items/compatibility
// Keeps legacy Item.prototype methods available while implementation ownership
// Layer: entity-runtime/items/prototype-boundary
// Keeps Item.prototype methods available while implementation ownership
// lives in lifecycle/dynamic systems.
Object.assign(Item.prototype, {
update(dt, worldRef = world) { return window.TarinaiItemLifecycleLegacySystem?.updateOne?.(this, dt, worldRef) ?? false; },
update(dt, worldRef = world) { return window.TarinaiItemRuntime?.updateOne?.(this, dt, worldRef) ?? window.TarinaiItemLifecyclePipeline?.updateOne?.(this, dt, worldRef) ?? false; },
updateFan(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateFan?.(this, dt, worldRef); },
updateMagnet(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateMagnet?.(this, dt, worldRef); },
updateRotator(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateRotator?.(this, dt, worldRef); },

View file

@ -2,7 +2,7 @@
// 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
// placement/tool commands. The runtime imports these via
// globals instead of carrying helper implementations inline.
(function (global) {
const DUPLICATOR_STORABLE_TYPES = new Set([
@ -33,86 +33,6 @@ 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;
@ -226,11 +146,6 @@ function duplicatorApplyStoredPin(duplicator, worldRef) {
global.TarinaiItemLifecycleRuntimeSupport = Object.freeze({
itemAngleForRuntime,
fanAffectsPoint,
fanApplyToBody,
isMagnetTargetItem,
magnetPolePoint,
magnetApplyToBody,
duplicatorLoadTypeForItem,
duplicatorLoadPoint,
duplicatorCandidateReach,

View file

@ -7,10 +7,14 @@ const TOOL_DEFINITIONS = Object.freeze({
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
area_delete: { id: "area_delete", label: "範囲削除", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "カーソル周囲の広い範囲にある置きものや汚れをまとめて消す。" },
copy: { id: "copy", label: "コピー", placeable: false, scalable: false, iconText: "⧉", tooltip: "置いたアイテムをクリックしてコピーし、次のクリックで設定ごと貼り付ける。" },
undo: { id: "undo", label: "戻す", placeable: false, scalable: false, iconText: "↶", tooltip: "直前の操作を取り消す。" },
redo: { id: "redo", label: "進む", placeable: false, scalable: false, iconText: "↷", tooltip: "取り消した操作をやり直す。" },
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" },
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
rope: { id: "rope", label: "ヒモ", placeable: false, scalable: false, iconText: "╰", tooltip: "2つの対象を順にクリックして、伸び縮みしないヒモで接続する。機械体・柵・ヒモ・棒はクリック地点、それ以外は中心につなぐ。" },
rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。距離を常に一定に保つ。" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
@ -42,10 +46,9 @@ const TOOL_DEFINITIONS = Object.freeze({
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\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: "設置後に観察でクリックすると、回転速度と輪郭を編集できる。直線・自由描画で大きな回転物も作れる。" },
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
rotator: { id: "rotator", itemType: "rotator", label: "回転体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "↻", collisionShape: "custom_rotator", tooltip: "回転する機械。クリックすると形状や速度を設定できる。" },
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "往復体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "⇄", collisionShape: "custom_reciprocator", tooltip: "往復する機械。クリックすると形状や速度を設定できる。" },
water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" },
trace: { id: "trace", itemType: "trace", label: "死骸", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
@ -55,17 +58,25 @@ 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", "rotator"]),
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]),
});
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]));
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]));
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "reciprocator"]));
const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"]));
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "reciprocator"]);
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES]));
function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); }
function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); }
function isLinkItemType(type = "") { return LINK_ITEM_TYPES.has(String(type || "")); }
function isPhysicsToolId(id = "") { return PHYSICS_TOOL_IDS.includes(String(id || "")); }
function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); }
function defaultItemAngle(type = "") {
const t = String(type || "");
@ -118,10 +129,11 @@ let EFFECT_REGISTRY = null;
let EFFECT_DEFINITIONS = null;
const TOOL_CATEGORIES = Object.freeze([
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "delete", "area_delete", "water_hose"] },
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] },
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_h", "bounce_fence", "gate_fence", "fan", "magnet", "rotator"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard"] },
{ id: "physics", label: "物理", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
]);
@ -705,12 +717,20 @@ function toolTipsFromDefinitions() {
radius: itemRadiusFor,
amount: itemAmountFor,
categories: toolCategories,
physicsToolIds: () => [...PHYSICS_TOOL_IDS],
isFence: isFenceItemType,
isMechanical: isMechanicalItemType,
isLink: isLinkItemType,
scalableToolIds,
pinBehavior: pinBehaviorFor,
});
})(typeof window !== "undefined" ? window : globalThis);
if (typeof window !== "undefined") {
window.isFenceItemType = isFenceItemType;
window.isMechanicalItemType = isMechanicalItemType;
window.isLinkItemType = isLinkItemType;
window.isPhysicsToolId = isPhysicsToolId;
window.isRotatableItemType = isRotatableItemType;
window.defaultItemAngle = defaultItemAngle;
window.normalizedItemAngle = normalizedItemAngle;

View file

@ -3,60 +3,6 @@
// Item field rendering. Kept separate from construction and lifecycle logic.
function drawFanInfluenceLocal(ctx, r = 32, styleNight = false) {
const range = Math.max(170, r * 7.2);
const nearW = Math.max(42, r * 1.0);
const farW = Math.max(42, r * 4.1);
ctx.save();
ctx.globalAlpha *= styleNight ? 0.18 : 0.22;
ctx.fillStyle = styleNight ? "rgba(120,190,235,0.22)" : "rgba(110,190,240,0.24)";
ctx.strokeStyle = styleNight ? "rgba(160,220,255,0.48)" : "rgba(66,150,215,0.52)";
ctx.lineWidth = 1.4;
ctx.setLineDash([9, 7]);
ctx.beginPath();
ctx.moveTo(0, -nearW * 0.55);
ctx.lineTo(range, -farW * 0.5);
ctx.quadraticCurveTo(range + Math.max(10, farW * 0.08), 0, range, farW * 0.5);
ctx.lineTo(0, nearW * 0.55);
ctx.quadraticCurveTo(-Math.max(4, nearW * 0.10), 0, 0, -nearW * 0.55);
ctx.closePath();
ctx.fill();
ctx.globalAlpha *= 1.9;
ctx.stroke();
ctx.setLineDash([3, 8]);
for (const yy of [-0.34, 0, 0.34]) {
ctx.beginPath();
ctx.moveTo(Math.max(12, nearW * 0.35), yy * nearW);
ctx.quadraticCurveTo(range * 0.46, yy * farW * 0.35, range * 0.92, yy * farW);
ctx.stroke();
}
ctx.restore();
}
function drawMagnetInfluenceLocal(ctx, r = 30, styleNight = false) {
const range = Math.max(150, r * 7.2);
const poleX = r * 1.10;
ctx.save();
ctx.translate(poleX, 0);
ctx.globalAlpha *= styleNight ? 0.15 : 0.18;
ctx.fillStyle = styleNight ? "rgba(138,126,232,0.20)" : "rgba(132,118,232,0.20)";
ctx.strokeStyle = styleNight ? "rgba(174,170,255,0.48)" : "rgba(86,88,205,0.50)";
ctx.lineWidth = 1.4;
ctx.setLineDash([10, 7]);
ctx.beginPath();
ctx.arc(0, 0, range, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha *= 2.0;
ctx.stroke();
ctx.setLineDash([3, 9]);
for (const scale of [0.38, 0.68]) {
ctx.beginPath();
ctx.arc(0, 0, range * scale, -Math.PI * 0.72, Math.PI * 0.72);
ctx.stroke();
}
ctx.restore();
}
Object.assign(Item.prototype, {
draw(ctx, t, lighting = null) {
const lightState = lighting || getLightingState(world);
@ -601,102 +547,20 @@ draw(ctx, t, lighting = null) {
}
}
ctx.restore();
} else if (this.type === "fan") {
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const spin = (t * 12 + (this.seed || 0));
ctx.save();
ctx.rotate(angle);
drawFanInfluenceLocal(ctx, this.r || 32, styleNight);
ctx.save();
ctx.globalAlpha *= styleNight ? 0.20 : 0.24;
ctx.strokeStyle = styleNight ? "rgba(170,220,255,0.50)" : "rgba(66,150,215,0.42)";
ctx.fillStyle = styleNight ? "rgba(90,150,190,0.08)" : "rgba(110,190,240,0.08)";
ctx.lineWidth = 1.25;
ctx.setLineDash([5, 5]);
roundedRect(ctx, -Math.max(22, this.r * 0.92), -Math.max(18, this.r * 0.72), Math.max(44, this.r * 1.84), Math.max(36, this.r * 1.44), 7);
ctx.fill(); ctx.stroke();
ctx.restore();
ctx.fillStyle = styleNight ? "#53606b" : "#b8c7d2";
ctx.strokeStyle = styleNight ? "#2f3842" : "#667684";
ctx.lineWidth = 2.2;
roundedRect(ctx, -this.r * 0.90, -this.r * 0.70, this.r * 1.32, this.r * 1.40, this.r * 0.25);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#34414c" : "#eaf2f6";
ctx.strokeStyle = styleNight ? "#9db2c1" : "#627585";
ctx.beginPath();
ctx.arc(-this.r * 0.20, 0, this.r * 0.68, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.save();
ctx.translate(-this.r * 0.20, 0);
ctx.rotate(spin);
ctx.fillStyle = styleNight ? "rgba(166,205,230,0.72)" : "rgba(88,139,170,0.66)";
for (let i = 0; i < 3; i += 1) {
ctx.rotate(Math.PI * 2 / 3);
ctx.beginPath();
ctx.ellipse(this.r * 0.30, 0, this.r * 0.42, this.r * 0.15, 0.22, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.fillStyle = styleNight ? "rgba(170,220,250,0.34)" : "rgba(98,170,210,0.26)";
for (const off of [-0.42, 0, 0.42]) {
ctx.beginPath();
ctx.ellipse(this.r * 1.10, off * this.r, this.r * 0.62, this.r * 0.10, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "magnet") {
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
ctx.save();
ctx.rotate(angle);
drawMagnetInfluenceLocal(ctx, this.r || 30, styleNight);
// Hitbox guide: same oriented body footprint used by collision/placement.
ctx.save();
ctx.globalAlpha *= styleNight ? 0.22 : 0.26;
ctx.strokeStyle = styleNight ? "rgba(190,195,255,0.50)" : "rgba(82,88,210,0.42)";
ctx.fillStyle = styleNight ? "rgba(128,120,220,0.08)" : "rgba(132,118,232,0.08)";
ctx.lineWidth = 1.25;
ctx.setLineDash([5, 5]);
roundedRect(ctx, -Math.max(22, this.r * 0.88), -Math.max(18, this.r * 0.78), Math.max(44, this.r * 1.76), Math.max(36, this.r * 1.56), 7);
ctx.fill(); ctx.stroke();
ctx.restore();
// The U-shape is flipped horizontally so its open poles face the magnetic range.
ctx.save();
ctx.scale(-1, 1);
ctx.strokeStyle = styleNight ? "#26303a" : "#5a3d40";
ctx.lineWidth = Math.max(5, this.r * 0.30);
ctx.lineCap = "round";
ctx.beginPath();
ctx.arc(-this.r * 0.05, 0, this.r * 0.75, -Math.PI * 0.62, Math.PI * 0.62);
ctx.stroke();
ctx.lineWidth = Math.max(7, this.r * 0.40);
ctx.strokeStyle = styleNight ? "#a64755" : "#d9555f";
ctx.beginPath();
ctx.arc(-this.r * 0.05, 0, this.r * 0.75, -Math.PI * 0.62, -Math.PI * 0.10);
ctx.stroke();
ctx.strokeStyle = styleNight ? "#4d7fbe" : "#4d86d9";
ctx.beginPath();
ctx.arc(-this.r * 0.05, 0, this.r * 0.75, Math.PI * 0.10, Math.PI * 0.62);
ctx.stroke();
ctx.restore();
ctx.fillStyle = styleNight ? "#ffd7de" : "#fff2f4";
ctx.font = `bold ${Math.max(10, this.r * 0.54)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
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 powered = this.rotatorPowered !== false;
const speed = Number((powered ? this.rotatorSpeed : this.rotatorAngularVelocity) || 0) || 0;
const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]]));
const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12));
ctx.save();
ctx.rotate(angle);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha *= powered ? 1 : 0.72;
ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5";
ctx.lineWidth = thick;
ctx.shadowColor = styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)";
ctx.shadowColor = powered ? (styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)") : "rgba(88,80,96,0.20)";
ctx.shadowBlur = Math.max(4, thick * 0.28);
ctx.beginPath();
for (const seg of segments) {
@ -706,7 +570,7 @@ draw(ctx, t, lighting = null) {
}
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)";
ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)";
ctx.lineWidth = Math.max(1.4, thick * 0.22);
ctx.beginPath();
for (const seg of segments) {
@ -716,17 +580,139 @@ draw(ctx, t, lighting = null) {
}
ctx.stroke();
ctx.restore();
ctx.fillStyle = styleNight ? "#3c324e" : "#f4eaff";
ctx.strokeStyle = styleNight ? "#a28cc8" : "#6e4fa4";
ctx.fillStyle = powered ? (styleNight ? "#3c324e" : "#f4eaff") : (styleNight ? "#34343a" : "#ece8ef");
ctx.strokeStyle = powered ? (styleNight ? "#a28cc8" : "#6e4fa4") : "rgba(92,88,98,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(0, 0, Math.max(8, thick * 0.72), 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#d8c5ff" : "#6e4fa4";
ctx.fillStyle = powered ? (styleNight ? "#d8c5ff" : "#6e4fa4") : "rgba(70,68,76,0.82)";
ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(speed >= 0 ? "↻" : "↺", 0, 0);
ctx.fillText(powered ? (speed >= 0 ? "↻" : "↺") : "○", 0, 0);
} else if (this.type === "reciprocator") {
const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const axisAngle = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(this) ?? (Number.isFinite(Number(this.reciprocatorAxisAngle)) ? Number(this.reciprocatorAxisAngle) : bodyAngle);
const powered = this.reciprocatorPowered !== false;
const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12));
const travel = Math.max(24, Number(this.reciprocatorTravel || 150) || 150);
const halfTravel = travel * 0.5;
const phase = Math.max(-1, Math.min(1, Number(this.reciprocatorPhase || 0) || 0));
const reciprocatorDir = Math.sign(Number(this.reciprocatorDirection || 1) || 1) || 1;
const passiveVelocity = Number(this.reciprocatorVelocity || 0) || 0;
const motorVelocity = powered ? reciprocatorDir * Math.max(0, Number(this.reciprocatorSpeed || 0) || 0) : 0;
const visualVelocity = motorVelocity + passiveVelocity;
const visualDirection = Math.abs(visualVelocity) > 0.8 ? Math.sign(visualVelocity) : reciprocatorDir;
const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0]]));
ctx.save();
ctx.rotate(axisAngle);
ctx.save();
ctx.globalAlpha *= styleNight ? 0.24 : 0.30;
ctx.strokeStyle = styleNight ? "rgba(130,174,220,0.50)" : "rgba(70,120,170,0.42)";
ctx.lineWidth = 2;
ctx.setLineDash([7, 6]);
const trackCenterX = -phase * halfTravel;
ctx.beginPath();
ctx.moveTo(trackCenterX - halfTravel, 0);
ctx.lineTo(trackCenterX + halfTravel, 0);
ctx.stroke();
ctx.setLineDash([]);
for (const xx of [trackCenterX - halfTravel, trackCenterX + halfTravel]) {
ctx.beginPath();
ctx.arc(xx, 0, 4.5, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
ctx.rotate(bodyAngle - axisAngle);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha *= powered ? 1 : 0.72;
ctx.strokeStyle = styleNight ? "#4f7192" : "#6da6cf";
ctx.lineWidth = thick;
ctx.shadowColor = powered ? (styleNight ? "rgba(86,130,180,0.34)" : "rgba(80,150,205,0.28)") : "rgba(74,82,90,0.20)";
ctx.shadowBlur = Math.max(4, thick * 0.28);
ctx.beginPath();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
ctx.shadowBlur = 0;
ctx.strokeStyle = powered ? (styleNight ? "rgba(220,238,255,0.44)" : "rgba(255,255,255,0.48)") : "rgba(255,255,255,0.28)";
ctx.lineWidth = Math.max(1.4, thick * 0.22);
ctx.beginPath();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
ctx.restore();
ctx.fillStyle = powered ? (styleNight ? "#2d3c4d" : "#eaf6ff") : (styleNight ? "#34383e" : "#e9edf0");
ctx.strokeStyle = powered ? (styleNight ? "#7faed0" : "#4d8cba") : "rgba(82,88,94,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(0, 0, Math.max(8, thick * 0.72), 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = powered ? (styleNight ? "#d8ecff" : "#235f91") : "rgba(72,76,82,0.82)";
ctx.font = `bold ${Math.max(10, thick * 0.78)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (powered || Math.abs(visualVelocity) > 0.8) {
ctx.save();
ctx.rotate(axisAngle);
ctx.fillText(visualDirection >= 0 ? "→" : "←", 0, 0);
ctx.restore();
} else {
ctx.fillText("○", 0, 0);
}
} else if (this.type === "rope" || this.type === "rod") {
const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(this.linkA, world);
const b = runtime?.endpointWorld?.(this.linkB, world);
if (a && b) {
const ax = a.x - this.x, ay = a.y - this.y;
const bx = b.x - this.x, by = b.y - this.y;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (this.type === "rod") {
ctx.strokeStyle = styleNight ? "rgba(176,166,150,0.96)" : "rgba(92,82,68,0.96)";
ctx.lineWidth = 7.2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(236,232,220,0.60)" : "rgba(255,248,228,0.56)";
ctx.lineWidth = 2.0;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
} else {
const mx = Number.isFinite(Number(this.linkMidX)) ? Number(this.linkMidX) - this.x : (ax + bx) * 0.5;
const my = Number.isFinite(Number(this.linkMidY)) ? Number(this.linkMidY) - this.y : (ay + by) * 0.5;
ctx.strokeStyle = styleNight ? "rgba(180,150,116,0.88)" : "rgba(132,94,54,0.92)";
ctx.lineWidth = 4.2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(mx, my, bx, by);
ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(236,214,180,0.56)" : "rgba(255,236,190,0.52)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(mx, my, bx, by);
ctx.stroke();
}
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)");
for (const [kx, ky] of [[ax, ay], [bx, by]]) {
ctx.beginPath(); ctx.arc(kx, ky, this.type === "rod" ? 4.8 : 4.2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
} else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "zunchi";
const img = images.get(zunchiId) || images.get("zunchi");

View file

@ -1,23 +1,12 @@
"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.
// Owns the public item update boundary. Item.prototype.update routes directly
// through the explicit lifecycle pipeline.
(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);
};
@ -29,18 +18,9 @@
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;
if (!item || item.dead || !global.TarinaiItemLifecyclePipeline?.updateOne) return false;
return global.TarinaiItemLifecyclePipeline.updateOne(item, dt, worldRef) !== false;
}
function updateScheduled(worldRef, dt) {
@ -68,13 +48,7 @@
return global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef) || worldRef?._itemUpdateRealtime || [];
}
const api = Object.freeze({
installPrototypeBoundary,
updateOne,
updateScheduled,
trackedDynamicItems,
});
const api = Object.freeze({ installPrototypeBoundary, updateOne, updateScheduled, trackedDynamicItems });
global.TarinaiItemRuntime = api;
installPrototypeBoundary();
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -110,20 +110,42 @@ function initializeItemTypeState(item, type, x, y) {
item.gateOpen = false;
item.gateLastToggleAt = -999;
}
if (type === "fan") {
item.fanPhase = rand(0, Math.PI * 2);
item.fanPulseAt = rand(0, 0.3);
}
if (type === "magnet") {
item.magnetPulseAt = rand(0, 0.4);
}
if (type === "rotator") {
item.rotatorPowered = true;
item.rotatorSpeed = Math.PI * 0.65;
item.rotatorAngularVelocity = 0;
item.rotatorThickness = 12;
item.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]];
item.rotatorEditorOpenAt = -999;
item.amount = 999;
}
if (type === "rope" || type === "rod") {
item.amount = 999;
item.r = 48;
item.deletable = true;
item.linkA = null;
item.linkB = null;
item.linkLength = 80;
item.linkMidX = x;
item.linkMidY = y + 10;
item.linkMidVX = 0;
item.linkMidVY = 0;
}
if (type === "reciprocator") {
item.reciprocatorPowered = true;
item.reciprocatorSpeed = 92;
item.reciprocatorTravel = 150;
item.reciprocatorPhase = 0;
item.reciprocatorDirection = 1;
item.reciprocatorAxisAngle = Number.isFinite(Number(item.angle)) ? item.angle : 0;
item.reciprocatorVelocity = 0;
item.reciprocatorAnchorX = x;
item.reciprocatorAnchorY = y;
item.rotatorThickness = 12;
item.rotatorSegments = [[-78, 0, 78, 0]];
item.amount = 999;
}
if (isServingFoodType(type)) {
item.toolSize = "medium";

View file

@ -5,13 +5,13 @@
// 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 REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "rotator", "reciprocator", "rope", "rod"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = Object.freeze([
"ball", "genkotsu", "firecracker", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator", "water_bowl",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "water_bowl",
]);
function itemUpdateInterval(item) {

View file

@ -426,8 +426,8 @@ function previewItemRadiusFor(type = "") {
const base = Number(def?.radius || itemRadiusFor?.(type, 16) || 16);
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 === "reciprocator") return 26;
if (type === "nest_box") return 26;
if (type === "duplicator") return 24;
if (type === "signboard") return 22;
@ -561,8 +561,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
bounce_fence: 0.74,
bounce_fence_v: 0.74,
gate_fence: 0.74,
fan: 0.86,
magnet: 0.88,
reciprocator: 0.76,
ant_nest: 0.92,
ball: 0.92,
pushpin: 0.92,
@ -575,7 +574,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
if (isConsumableSpriteType(type)) scale *= 1.95;
else if (["grass", "water", "water_bowl", "zunda_juice"].includes(type)) scale *= 1.85;
else if (type === "duplicator") scale *= 1.55;
else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "firecracker", "genkotsu"].includes(type)) scale *= 1.08;
else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "genkotsu"].includes(type)) scale *= 1.08;
else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) scale *= 1.35;
if (watermark) scale *= 1.42;
ctx.scale(scale, scale);

499
js/mechanical_system.js Normal file
View file

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

View file

@ -10,6 +10,7 @@
return { x: dx * c + dy * ss, y: -dx * ss + dy * c };
}
function rectWorldVector(rect, x, y) {
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(rect.angle || 0);
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(rect.angle || 0);
@ -84,16 +85,16 @@
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);
if (rect.mechanical) {
global.TarinaiMechanicalSystem?.applySurfaceVelocityToCircle?.(obj, rect, nx, ny, vx, vy, {
damping: 1,
surfaceScale: rect.rotator ? 0.28 : 0.30,
normalBoost: rect.rotator ? 20 : 14,
maxSpeed: 900,
passiveImpulseScale: 1.0,
spinKick: 7.0,
});
} else if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
return true;
}

View file

@ -1,4 +1,5 @@
"use strict";
const TARINAI_RENDER_GLOBAL = typeof globalThis !== "undefined" ? globalThis : (typeof window !== "undefined" ? window : {});
function randSeed(seed, min, max) {
const s = Math.sin(seed * 12.9898) * 43758.5453;
return min + (s - Math.floor(s)) * (max - min);
@ -130,54 +131,6 @@ function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale =
ctx.save();
ctx.setLineDash([10, 7]);
ctx.lineWidth = 1.6;
if (influence.type === "fan") {
const x = influence.x || 0;
const y = influence.y || 0;
const angle = influence.angle || 0;
const range = Math.max(1, influence.range || 170);
const nearW = Math.max(1, influence.nearWidth || 42);
const farW = Math.max(nearW, influence.farWidth || nearW * 3.4);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.globalAlpha = (blocked ? 0.18 : 0.30) * alphaScale;
ctx.fillStyle = blocked ? "rgba(232,84,72,0.18)" : "rgba(115,190,235,0.22)";
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.48)" : "rgba(78,158,220,0.56)";
ctx.beginPath();
ctx.moveTo(0, -nearW * 0.55);
ctx.lineTo(range, -farW * 0.5);
ctx.quadraticCurveTo(range + Math.max(10, farW * 0.08), 0, range, farW * 0.5);
ctx.lineTo(0, nearW * 0.55);
ctx.quadraticCurveTo(-Math.max(4, nearW * 0.10), 0, 0, -nearW * 0.55);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = (blocked ? 0.46 : 0.64) * alphaScale;
ctx.stroke();
ctx.setLineDash([4, 8]);
ctx.beginPath();
for (const yy of [-0.34, 0, 0.34]) {
ctx.moveTo(Math.max(12, nearW * 0.35), yy * nearW);
ctx.quadraticCurveTo(range * 0.46, yy * farW * 0.35, range * 0.92, yy * farW);
}
ctx.stroke();
} else if (influence.type === "magnet") {
const range = Math.max(1, influence.range || 150);
ctx.translate(influence.x || 0, influence.y || 0);
ctx.rotate(influence.angle || 0);
ctx.globalAlpha = (blocked ? 0.16 : 0.26) * alphaScale;
ctx.fillStyle = blocked ? "rgba(232,84,72,0.16)" : "rgba(143,122,235,0.18)";
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.46)" : "rgba(94,94,215,0.52)";
ctx.beginPath();
ctx.arc(0, 0, range, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = (blocked ? 0.42 : 0.58) * alphaScale;
ctx.stroke();
ctx.setLineDash([3, 9]);
for (const scale of [0.38, 0.68]) {
ctx.beginPath();
ctx.arc(0, 0, range * scale, -Math.PI * 0.72, Math.PI * 0.72);
ctx.stroke();
}
}
ctx.restore();
}
@ -196,21 +149,34 @@ function placementPreviewFor(world) {
let blocked = false;
let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false };
if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0);
if (type === "reciprocator") {
tmp.reciprocatorAxisAngle = tmp.angle;
tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0;
}
if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) {
const rawRect = world.fenceRect(tmp);
return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) };
}
if ((type === "fan" || type === "magnet") && world.deviceRect) {
const rawRect = world.deviceRect(tmp);
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) {
if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
tmp.rotatorSpeed = Math.PI * 0.65;
tmp.rotatorPowered = true;
tmp.rotatorThickness = 12;
tmp.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]];
const rects = world.rotatorObstacleRects(tmp) || [];
const bounds = world.rotatorBoundsAabb?.(tmp) || null;
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
tmp.reciprocatorPowered = true;
tmp.reciprocatorSpeed = 92;
tmp.reciprocatorTravel = 150;
tmp.reciprocatorPhase = 0;
tmp.reciprocatorAnchorX = x;
tmp.reciprocatorAnchorY = y;
tmp.rotatorThickness = 12;
tmp.rotatorSegments = [[-78, 0, 78, 0]];
const rects = world.reciprocatorObstacleRects(tmp) || [];
const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "nest_box" && world.nestBoxBaseRect) {
@ -292,6 +258,26 @@ function drawOperationToolPreview(ctx, world) {
const type = world.copyBuffer.type || "";
const item = world.copyPreviewItemAt?.(p.x, p.y);
if (item) {
let copyPreviewDrew = false;
if (typeof item.draw === "function") {
ctx.save();
ctx.globalAlpha = 0.74;
try { item.draw(ctx, world.time || 0, typeof getLightingState === "function" ? getLightingState(world) : null); copyPreviewDrew = true; } catch (_) { copyPreviewDrew = false; }
ctx.restore();
}
if (!copyPreviewDrew && typeof globalThis.drawToolItemPreview === "function") {
const previewSize = Math.max(48, Math.min(132, (item.r || itemRadiusFor?.(type, 18) || 18) * 3.4));
const buffer = document.createElement("canvas");
buffer.width = Math.ceil(previewSize);
buffer.height = Math.ceil(previewSize);
const bctx = buffer.getContext("2d");
if (bctx && globalThis.drawToolItemPreview(bctx, type, { width: buffer.width, height: buffer.height, compact: true }) !== false) {
ctx.save();
ctx.globalAlpha = 0.74;
ctx.drawImage(buffer, p.x - previewSize / 2, p.y - previewSize / 2, previewSize, previewSize);
ctx.restore();
}
}
ctx.save();
ctx.globalAlpha = 0.50;
ctx.setLineDash([7, 5]);
@ -317,6 +303,28 @@ function drawOperationToolPreview(ctx, world) {
}
return;
}
if ((world?.tool === "rope" || world?.tool === "rod") && world.pendingLinkEndpoint) {
const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(world.pendingLinkEndpoint, world) || world.pendingLinkEndpoint;
ctx.save();
ctx.globalAlpha = 0.72;
ctx.lineWidth = 3.2;
ctx.lineCap = "round";
if (world?.tool === "rope") ctx.setLineDash([8, 6]);
else ctx.setLineDash([]);
ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : "rgba(142,94,45,0.88)";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "rgba(142,94,45,0.85)";
ctx.beginPath(); ctx.arc(a.x, a.y, 5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
return;
}
if (world?.tool !== "area_delete") return;
const radius = world.areaDeleteRadius?.() || 138;
ctx.save();
@ -582,7 +590,7 @@ function renderRadiusForEntity(entity) {
if (!entity) return 40;
if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2);
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2);
if (entity.type === "fan" || entity.type === "magnet") return Math.max(86, (entity.r || 28) * 3.4);
if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || ((entity.r || 64) * 2.8 + (entity.reciprocatorTravel || 150)));
if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2);
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
if (entity.kind === "queen") return 42;
@ -609,6 +617,18 @@ function renderStackSignature(worldRef) {
].join(":");
}
function syncCarriedPlushieToOwner(it, worldRef) {
if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return false;
const owner = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById);
if (!owner) return false;
const ox = it.x, oy = it.y;
it.ownerId = it.ownerId || owner.id;
it.onHead = true;
it.x = owner.x;
it.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
return Math.hypot((it.x || 0) - (ox || 0), (it.y || 0) - (oy || 0)) > 0.25;
}
function ensureWorldRenderStack(worldRef) {
if (!worldRef._renderStack) {
worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
@ -661,6 +681,7 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
stack.lodgedPins.length = 0;
const seen = new Set();
const addItem = (it) => {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it) || !isEntityVisibleInRect(it, visibleRect)) return;
seen.add(it);
if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") {
@ -694,6 +715,9 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
for (const a of antScratch) addLayered(a);
for (const it of worldRef.items || []) {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
}
} else {
for (const it of worldRef.items || []) addItem(it);
for (const t of worldRef.tarinai || []) addLayered(t);
@ -797,8 +821,8 @@ function drawGardenBed(w, h, lighting) {
const groundType = world.groundType || "soil";
const x = 18, y = 22, bw = w - 36, bh = h - 44;
const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
const borderTop = groundType === "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");
const borderTop = groundType === "laboratory" ? "#cfd8e6" : groundType === "ice" ? "#bfe6f4" : groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#79a9cf" : groundType === "dirt" ? "#8fb06a" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = groundType === "laboratory" ? "#8fa1ba" : groundType === "ice" ? "#77b7d2" : groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#4f79a3" : groundType === "dirt" ? "#5f7c43" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72");
border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68");
activeCtx.fillStyle = border;
@ -841,7 +865,7 @@ 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 : groundType === "dirt" ? 96 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
const speckCount = groundType === "laboratory" ? 18 : groundType === "ice" ? 54 : groundType === "blanket" ? 28 : groundType === "foot_massage" ? 180 : groundType === "dirt" ? 96 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
for (let i = 0; i < speckCount; i++) {
const px = randSeed(900 + i, x + 24, x + bw - 24);
const py = randSeed(1200 + i, y + 22, y + bh - 24);
@ -948,7 +972,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const patchCount = groundType === "concrete" ? 3 : groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
const patchCount = groundType === "laboratory" ? 0 : groundType === "concrete" ? 3 : groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
for (let i = 0; i < patchCount; i++) {
const px = randSeed(3300 + i, x + 40, x + bw - 40);
const py = randSeed(3600 + i, y + 44, y + bh - 46);
@ -961,7 +985,7 @@ function drawGardenBed(w, h, lighting) {
}
// edge grasses and tiny flowers
const edgeGrassCount = groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
const edgeGrassCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
for (let i = 0; i < edgeGrassCount; i++) {
const side = i % 2;
const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
@ -981,7 +1005,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const flowerCount = groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" || groundType === "ice" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
const flowerCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" || groundType === "ice" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
for (let i = 0; i < flowerCount; i++) {
const px = randSeed(7000 + i, x + 44, x + bw - 44);
const py = randSeed(7400 + i, y + 46, y + bh - 44);
@ -1000,7 +1024,7 @@ function drawGardenBed(w, h, lighting) {
}
}
const leafCount = groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
const leafCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
for (let i = 0; i < leafCount; i++) {
const px = randSeed(7800 + i, x + 36, x + bw - 36);
const py = randSeed(8200 + i, y + 34, y + bh - 34);
@ -1209,6 +1233,7 @@ function render() {
}
for (const it of renderStack.carriedPlushies) {
syncCarriedPlushieToOwner(it, world);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const it of renderStack.lodgedPins) {
@ -1268,17 +1293,6 @@ function render() {
ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12);
ctx.restore();
if (world.paused) {
ctx.save();
ctx.fillStyle = "rgba(255,255,255,0.50)";
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "rgba(42,36,29,0.70)";
ctx.textAlign = "center";
ctx.font = "bold 28px ui-rounded, sans-serif";
ctx.fillText("\u4e00\u6642\u505c\u6b62\u4e2d", w / 2, h / 2);
ctx.restore();
}
}
function drawPointerItemTooltip(ctx, world) {

View file

@ -10,7 +10,6 @@
const DEFLATE_CODEC = "縮";
const {
BINARY_SCHEMA_VERSION,
MIN_BINARY_SCHEMA_VERSION,
FIELD_IDS,
GROUND_IDS,
ITEM_TYPE_IDS,
@ -18,6 +17,7 @@
SERVING_FOOD_TYPE_IDS,
PIN_TYPE_IDS,
ROTATABLE_ITEM_TYPE_IDS,
JSON_EXTRA_ITEM_TYPE_IDS,
} = SaveSchema;
const textEncoder = new TextEncoder();
@ -462,12 +462,7 @@
writer.u(w[9] || 0); // birthSerial
}
function readWorld(reader) {
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);
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
return [field, ground, mood, reader.u(), weather, reader.s(), reader.u(), reader.u(), reader.str(), reader.u()];
}
@ -549,7 +544,7 @@
writer.str(extra[0] || "");
} else if (typeId === 6) { // duplicator
writer.s(extra[0] ?? -1);
} else if (typeId === 39) { // rotator
} else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
writer.str(JSON.stringify(extra || []));
} else if (typeId === 21) { // ball
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
@ -573,13 +568,20 @@
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 (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
try {
const parsed = JSON.parse(reader.str() || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
if (typeId === 21) return [reader.s(), reader.s()];
if (typeId === 23) return [reader.s(), reader.s()];
if (typeId === 24) return [reader.s()];
if (typeId === 25) return [reader.s()];
if (PIN_TYPE_IDS.has(typeId)) return [reader.u(), reader.s(), reader.s(), reader.s(), reader.s()];
if (ROTATABLE_ITEM_TYPE_IDS.has(typeId)) return (reader.schemaVersion || BINARY_SCHEMA_VERSION) >= 5 ? [reader.s(), reader.u()] : [];
if (ROTATABLE_ITEM_TYPE_IDS.has(typeId)) return [reader.s(), reader.u()];
if (SERVING_FOOD_TYPE_IDS.has(typeId)) return [reader.s(), reader.s()];
return [];
}
@ -691,7 +693,7 @@
function decodeBinarySnapshot(bytes) {
const reader = new BinaryReader(bytes);
const schema = reader.b();
if (schema < MIN_BINARY_SCHEMA_VERSION || schema > BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
reader.schemaVersion = schema;
const snapshotVersion = reader.u();
if (snapshotVersion !== Snapshot.version) throw new Error("unsupported binary save version");

View file

@ -2,14 +2,13 @@
// 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.
// Current-format save identifiers only.
(function (global) {
const SNAPSHOT_VERSION = 5;
const BINARY_SCHEMA_VERSION = 7;
const MIN_BINARY_SCHEMA_VERSION = 4;
const SNAPSHOT_VERSION = 7;
const BINARY_SCHEMA_VERSION = 10;
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice"]);
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]);
const MOOD_IDS = Object.freeze(["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]);
const STATE_IDS = Object.freeze(["idle", "sleep", "panic", "sunbath", "fight", "seek_food", "seek_bed", "wander"]);
@ -22,7 +21,7 @@
"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"
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod"
]);
function typeIds(names) {
@ -32,7 +31,11 @@
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"]);
const FENCE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]);
const MECHANICAL_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator"]);
const LINK_ITEM_TYPE_IDS = typeIds(["rope", "rod"]);
const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator", "rope", "rod"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]);
function enumIndex(list, value, fallback = 0) {
const i = list.indexOf(String(value ?? ""));
@ -55,7 +58,6 @@
global.TarinaiSaveSchema = Object.freeze({
SNAPSHOT_VERSION,
BINARY_SCHEMA_VERSION,
MIN_BINARY_SCHEMA_VERSION,
FIELD_IDS,
GROUND_IDS,
WEATHER_IDS,
@ -70,6 +72,10 @@
STRUCTURE_TYPE_IDS,
SERVING_FOOD_TYPE_IDS,
PIN_TYPE_IDS,
FENCE_ITEM_TYPE_IDS,
MECHANICAL_ITEM_TYPE_IDS,
LINK_ITEM_TYPE_IDS,
JSON_EXTRA_ITEM_TYPE_IDS,
ROTATABLE_ITEM_TYPE_IDS,
enumIndex,
enumValue,

View file

@ -3,7 +3,7 @@
// 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.
// public TarinaiSimulationSystems contract for system_order.js and tests.
(function (global) {
function phaseFrom(moduleName, phaseName) {
const fn = global[moduleName]?.phases?.[phaseName];

View file

@ -3,8 +3,7 @@
(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 SNAPSHOT_VERSION = SaveSchema.SNAPSHOT_VERSION;
const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]);
const {
@ -327,8 +326,29 @@
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)])
(Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)]),
item.rotatorPowered === false ? 0 : 1,
q(item.rotatorAngularVelocity || 0, 1000)
];
if (type === "reciprocator") return [
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
q(Number.isFinite(Number(item.reciprocatorAxisAngle)) ? item.reciprocatorAxisAngle : item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
item.reciprocatorPowered === false ? 0 : 1,
q(item.reciprocatorSpeed || 92, 10),
q(item.reciprocatorTravel || 150, 10),
q(item.reciprocatorPhase || 0, 1000),
q(item.reciprocatorVelocity || 0, 10),
q(item.reciprocatorAnchorX || item.x || 0, 1),
q(item.reciprocatorAnchorY || item.y || 0, 1),
q(item.reciprocatorDirection || 1, 1),
q(item.rotatorThickness || 12, 10),
(Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)])
];
if (type === "rope" || type === "rod") {
const cloneEndpoint = ep => ep ? { kind: ep.kind || "item", id: ep.id || "", type: ep.type || "", label: ep.label || "", localX: q(ep.localX || 0, 1), localY: q(ep.localY || 0, 1), center: ep.center ? 1 : 0, x: q(ep.x || 0, 1), y: q(ep.y || 0, 1), attachT: Number.isFinite(Number(ep.attachT)) ? q(ep.attachT, 1000) : null } : null;
return [cloneEndpoint(item.linkA), cloneEndpoint(item.linkB), q(item.linkLength || item.r * 2 || 80, 1), q(item.linkMidX || item.x || 0, 1), q(item.linkMidY || item.y || 0, 1), q(item.linkMidVX || 0, 10), q(item.linkMidVY || 0, 10)];
}
if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)];
if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)];
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
@ -375,18 +395,46 @@
} 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 e = Array.isArray(extra) ? extra : [];
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback);
item.rotatorSpeed = u(e[1], 1000, item.rotatorSpeed || 0);
item.rotatorThickness = Math.max(4, Math.min(34, u(e[2], 10, item.rotatorThickness || 12)));
item.rotatorSegments = (Array.isArray(e[3]) ? e[3] : [[-78,0,78,0],[0,-52,0,52]]).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);
item.rotatorSegments = (Array.isArray(e[3]) ? e[3] : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4);
item.rotatorPowered = e.length >= 5 ? !!e[4] : item.rotatorPowered !== false;
item.rotatorAngularVelocity = u(e[5], 1000, item.rotatorAngularVelocity || 0);
if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]];
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8;
item.r = Math.max(item.r || 64, Math.min(460, extent));
} else if (type === "reciprocator") {
const e = Array.isArray(extra) ? extra : [];
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback);
item.reciprocatorAxisAngle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[1], 1000, item.angle), item.angle) : u(e[1], 1000, item.angle);
item.reciprocatorPowered = e.length >= 3 ? !!e[2] : item.reciprocatorPowered !== false;
item.reciprocatorSpeed = Math.max(0, u(e[3], 10, item.reciprocatorSpeed || 92));
item.reciprocatorTravel = Math.max(24, u(e[4], 10, item.reciprocatorTravel || 150));
item.reciprocatorPhase = clamp(u(e[5], 1000, item.reciprocatorPhase || 0), -1, 1);
item.reciprocatorVelocity = u(e[6], 10, item.reciprocatorVelocity || 0);
item.reciprocatorAnchorX = u(e[7], 1, item.reciprocatorAnchorX || item.x || 0);
item.reciprocatorAnchorY = u(e[8], 1, item.reciprocatorAnchorY || item.y || 0);
item.reciprocatorDirection = u(e[9], 1, item.reciprocatorDirection || 1) || 1;
item.rotatorThickness = Math.max(4, Math.min(34, u(e[10], 10, item.rotatorThickness || 12)));
item.rotatorSegments = (Array.isArray(e[11]) ? e[11] : (Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]])).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4);
if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]];
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8;
item.r = Math.max(item.r || 64, Math.min(460, extent));
} else if (type === "rope" || type === "rod") {
const e = Array.isArray(extra) ? extra : [];
const normalizeEndpoint = ep => ep ? { kind: ep.kind || "item", id: ep.id || "", type: ep.type || "", label: ep.label || "", localX: u(ep.localX, 1, 0), localY: u(ep.localY, 1, 0), center: !!ep.center, x: u(ep.x, 1, item.x || 0), y: u(ep.y, 1, item.y || 0), attachT: ep.attachT == null ? null : clamp(u(ep.attachT, 1000, 0.5), 0, 1) } : null;
item.linkA = normalizeEndpoint(e[0]);
item.linkB = normalizeEndpoint(e[1]);
item.linkLength = Math.max(24, u(e[2], 1, item.linkLength || 80));
item.linkMidX = u(e[3], 1, item.linkMidX || item.x || 0);
item.linkMidY = u(e[4], 1, item.linkMidY || item.y || 0);
item.linkMidVX = u(e[5], 10, item.linkMidVX || 0);
item.linkMidVY = u(e[6], 10, item.linkMidVY || 0);
item.r = Math.max(18, item.linkLength * 0.5);
} else if (type === "duplicator") {
item.storedFoodType = 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") {

View file

@ -75,7 +75,8 @@
if (!item || item.dead) return false;
if (item.isStructure) return deleteStructure(worldRef, item, { reason: opts.reason || "delete-tool", userReason: opts.userReason, wake: opts.wake });
item.amount = 0;
if (item.type !== "grass") item.dead = Boolean(opts.killNonStructure);
item.hp = 0;
item.removedReason = String(opts.reason || "delete-tool");
markWorldAfterStructureChange(worldRef, opts.reason || "delete-tool");
return true;
}

View file

@ -49,11 +49,21 @@
if (this.type === "plushie") {
const owner = this.owner(worldRef);
if (!owner) {
this.damage(1, worldRef, null);
this.dead = true;
this.hp = 0;
this.amount = 0;
this.carriedById = "";
this.onHead = false;
worldRef && (worldRef.drawListDirty = true);
worldRef?.markItemBucketsDirty?.("plushie-owner-gone");
worldRef?.markSpatialDirty?.("plushie-owner-gone");
return;
}
this.ownerId = owner.id || this.ownerId;
this.carriedById = owner.id || this.ownerId || this.carriedById;
this.onHead = true;
this.x = owner.x;
this.y = owner.y - Math.max(15, (owner.radius || 24) * 0.70);
this.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8);
}
if (this.type === "grass_bed") {
@ -250,13 +260,19 @@
roles: { carriedObject: true, ownedStructure: true, fulfillObject: true },
needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 },
create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); },
onUse(user, structure) {
onUse(user, structure, worldRef) {
if (user?.id === structure.ownerId) structure.attachment = Math.min(100, (structure.attachment || 0) + 1);
structure.usedCount = (structure.usedCount || 0) + 1;
if (user?.id === structure.ownerId) {
structure.carriedById = user.id;
structure.onHead = true;
structure.x = user.x;
structure.y = user.y - Math.max(28, (user.radius || 24) * 1.28);
user.needShock = user.needShock || {};
user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - 24);
user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - 4);
worldRef?.markSpatialDirty?.("plushie-use-head");
worldRef && (worldRef.drawListDirty = true);
}
return true;
},

View file

@ -2,7 +2,7 @@
// 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.
// with the phase facade kept as a stable named entry point.
(function (global) {
function phase(name) {
return global.TarinaiSimulationSystems?.phases?.[name] || global.TarinaiWorldUpdatePhases?.phases?.[name] || null;

View file

@ -1,13 +1,13 @@
"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
// delegates to the extracted planner body, and callers no longer depend on the
// Tarinai prototype owning that body.
(function (global) {
function updateOne(tarinai, dt = 0) {
if (!tarinai || tarinai.dead) return false;
return Boolean(global.TarinaiNeedPlannerLegacySystem?.updateOne?.(tarinai, dt));
return Boolean(global.TarinaiNeedPlannerCoreSystem?.updateOne?.(tarinai, dt));
}
function updateWithThis(dt = 0) {

View file

@ -101,11 +101,19 @@ function continueBuildPlan(t, world, dt) {
t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false });
if (plan.timer > 0) return true;
consumeGrassMaterial(material, plan.type === "grass_bed" ? 4 : 1);
const spot = findStructureBuildSpot(t, world, plan.type);
const spot = plan.type === "plushie"
? { x: t.x, y: t.y - Math.max(28, (t.radius || 24) * 1.28) }
: findStructureBuildSpot(t, world, plan.type);
const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world);
if (!structure) {
return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}
if (plan.type === "plushie") {
structure.carriedById = t.id;
structure.onHead = true;
structure.x = t.x;
structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28);
}
world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure);
world.drawListDirty = true;
structure.use?.(t, world);

View file

@ -3,7 +3,7 @@
// Layer: entity-runtime/tarinai/update-system
// Owns pointer proximity care and cursor-heart effects.
(function (global) {
function legacyMethod(dt) {
function updateBody(dt) {
const pointer = this.world?.pointer;
if (!pointer?.inside || !this.isAwakeCursorFriendly()) return;
const d = distXY(this.x, this.y, pointer.x, pointer.y);
@ -29,12 +29,12 @@
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead) return false;
legacyMethod.call(tarinai, dt);
updateBody.call(tarinai, dt);
return true;
}
global.TarinaiCursorCareSystem = Object.freeze({
legacyMethod,
updateBody,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -369,7 +369,7 @@
this.targetKey = key;
this.targetSince = this.world.time;
this.targetBestDist = d;
} else if (d < (this.targetBestDist || Infinity) - 8) {
} else if (d < (this.targetBestDist || Infinity) - 6) {
this.targetBestDist = d;
this.targetSince = this.world.time;
}
@ -381,7 +381,7 @@
this.wanderAngle += rand(-1.4, 1.4);
return;
}
if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) {
if (this.world.time - (this.targetSince || this.world.time) > 10.5 && d > this.radius + 24) {
this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 12;
this.target = null;

View file

@ -2,7 +2,7 @@
// 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
// The candidate loop intentionally preserves the established order: for each nearby
// candidate, try eating first; if nothing was consumed, apply contact effects
// for that same candidate before advancing to the next one.

View file

@ -3,7 +3,7 @@
// Layer: entity-runtime/tarinai/update-system
// Owns local family/friend/grass/nest comfort environment effects.
(function (global) {
function legacyMethod(dt) {
function updateBody(dt) {
const parent = this.parentToFollow ? this.parentToFollow() : null;
if (parent && dist(this, parent) < 96) {
applyNeedRelief(this, { social: -dt * 4, safety: -dt * 2 });
@ -41,12 +41,12 @@
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead) return false;
legacyMethod.call(tarinai, dt);
updateBody.call(tarinai, dt);
return true;
}
global.TarinaiLocalEnvironmentSystem = Object.freeze({
legacyMethod,
updateBody,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -8,7 +8,7 @@
if (!tarinai || tarinai.dead) return false;
global.TarinaiForcedActionPlannerSystem?.prepare?.(tarinai, dt);
return Boolean(global.TarinaiActionPlannerSystem?.updateOne?.(tarinai, dt)
?? global.TarinaiNeedPlannerLegacySystem?.updateOne?.(tarinai, dt));
?? global.TarinaiNeedPlannerCoreSystem?.updateOne?.(tarinai, dt));
}
function updateWithThis(dt = 0) {

View file

@ -551,7 +551,7 @@ function protectSleepSession(tarinai, world, needs) {
return false;
}
function resolveNeedsLegacy(dt) {
function resolveNeedsCore(dt) {
decayActionCooldowns(this, dt);
const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds();
const needs = updateNeedsCached(this, this.world, dt);
@ -705,17 +705,17 @@ function resolveNeedsLegacy(dt) {
(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({
if (!global.TarinaiNeedPlannerCoreSystem) {
global.TarinaiNeedPlannerCoreSystem = Object.freeze({
updateOne(tarinai, dt = 0) {
if (!tarinai || tarinai.dead) return false;
resolveNeedsLegacy.call(tarinai, dt);
resolveNeedsCore.call(tarinai, dt);
return true;
},
updateWithThis(dt = 0) {
return this && !this.dead ? (resolveNeedsLegacy.call(this, dt), true) : false;
return this && !this.dead ? (resolveNeedsCore.call(this, dt), true) : false;
},
resolveNeedsLegacy,
resolveNeedsCore,
});
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
@ -1012,14 +1012,7 @@ function resolveNeedsLegacy(dt) {
},
update(dt) {
const legacyUpdate = globalThis.TarinaiLegacyUpdateSystem?.updateWithThis;
if (globalThis.TarinaiUpdatePipeline?.updateOne) {
return globalThis.TarinaiUpdatePipeline.updateOne(this, dt, { legacyUpdate });
}
if (globalThis.TarinaiLegacyUpdateSystem?.updateOne) {
return globalThis.TarinaiLegacyUpdateSystem.updateOne(this, dt);
}
return false;
return globalThis.TarinaiUpdatePipeline?.updateOne?.(this, dt) || false;
},
applyEnvironment(dt) {
@ -1150,7 +1143,7 @@ function resolveNeedsLegacy(dt) {
},
resolveNeeds(dt) {
return global.TarinaiNeedPlannerSystem?.updateOne?.(this, dt) ?? global.TarinaiNeedPlannerLegacySystem?.updateOne?.(this, dt);
return global.TarinaiNeedPlannerSystem?.updateOne?.(this, dt) ?? global.TarinaiNeedPlannerCoreSystem?.updateOne?.(this, dt);
},
makePoop(force = 1) {

View file

@ -70,22 +70,18 @@
const pad = Math.max(28, CONFIG.worldPadding || 30);
const lastX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : NaN);
const lastY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : NaN);
if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = Number.isFinite(lastX) ? lastX : (Number.isFinite(this.x) ? this.x : pad);
if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = Number.isFinite(lastY) ? lastY : (Number.isFinite(this.y) ? this.y : pad);
let drawX = this.sunbathAnchorX;
let drawY = this.sunbathAnchorY;
if (!Number.isFinite(drawX)) drawX = Number.isFinite(lastX) ? lastX : pad;
if (!Number.isFinite(drawY)) drawY = Number.isFinite(lastY) ? lastY : pad;
let drawX = Number.isFinite(this.x) ? this.x : (Number.isFinite(this.sunbathAnchorX) ? this.sunbathAnchorX : (Number.isFinite(lastX) ? lastX : pad));
let drawY = Number.isFinite(this.y) ? this.y : (Number.isFinite(this.sunbathAnchorY) ? this.sunbathAnchorY : (Number.isFinite(lastY) ? lastY : pad));
drawX = clamp(drawX, pad, Math.max(pad, worldW - pad));
drawY = clamp(drawY, pad, Math.max(pad, worldH - pad));
this.x = drawX;
this.y = drawY;
this.sunbathAnchorX = drawX;
this.sunbathAnchorY = drawY;
this._lastSunbathDrawX = drawX;
this._lastSunbathDrawY = drawY;
this._lastValidX = drawX;
this._lastValidY = drawY;
if (!Number.isFinite(this.x) || Math.abs(this.x - drawX) > 1.5) this.x = drawX;
if (!Number.isFinite(this.y) || Math.abs(this.y - drawY) > 1.5) this.y = drawY;
const metrics = getImageMetrics(renderSprite) || getImageMetrics(img) || getImageMetrics("smile") || {};
const w = metrics?.w || img?.naturalWidth || img?.width || 512;

View file

@ -1,29 +1,12 @@
"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.
// Owns the public Tarinai update boundary. Tarinai.prototype.update routes
// directly through the explicit creature update pipeline.
(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);
};
@ -36,13 +19,8 @@
}
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;
if (!tarinai || tarinai.dead || !global.TarinaiUpdatePipeline?.updateOne) return false;
return global.TarinaiUpdatePipeline.updateOne(tarinai, dt) !== false;
}
function updateBatch(tarinaiList, dt) {
@ -52,10 +30,6 @@
return ran;
}
global.TarinaiCreatureRuntime = Object.freeze({
installPrototypeBoundary,
updateOne,
updateBatch,
});
global.TarinaiCreatureRuntime = Object.freeze({ installPrototypeBoundary, updateOne, updateBatch });
installPrototypeBoundary();
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -7,14 +7,18 @@
interactWithOthers(dt) {
let closeCount = 0;
const scanLimit = 8;
const selfRadius = Math.max(12, Number(this.radius || 22) || 22);
const scanRadius = Math.max(96, selfRadius * 2.4 + 88);
let scanned = 0;
for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) {
for (const o of this.world.nearbyTarinai(this.x, this.y, scanRadius, true)) {
if (o === this || o.dead) continue;
if (scanned >= scanLimit && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04) break;
scanned += 1;
const dx = o.x - this.x, dy = o.y - this.y;
const d = Math.hypot(dx, dy);
if (d < 0.01 || d > 74) continue;
const otherRadius = Math.max(12, Number(o.radius || 22) || 22);
const bodyContactRange = Math.max(74, selfRadius + otherRadius + 18);
if (d < 0.01 || d > bodyContactRange) continue;
closeCount += 1;
const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
@ -69,7 +73,7 @@
this.vy += (dy / d) * dt * 4.8;
}
if (d < 60) {
if (d < Math.max(60, selfRadius + otherRadius + 8)) {
const rel = this.relationTo(o.id);
const pdef = this.personalityProfile();
this.loneliness -= dt * 1.65 * this.trait.social;
@ -192,19 +196,13 @@
if (this.state === "sunbath") {
this.target = null;
this.vx = 0;
this.vy = 0;
const pad = Math.max(28, CONFIG.worldPadding || 30);
const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000);
const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720);
const fallbackX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : (Number.isFinite(this.x) ? this.x : pad));
const fallbackY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : (Number.isFinite(this.y) ? this.y : pad));
if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = fallbackX;
if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = fallbackY;
this.sunbathAnchorX = clamp(this.sunbathAnchorX, pad, safeW - pad);
this.sunbathAnchorY = clamp(this.sunbathAnchorY, pad, safeH - pad);
this.x = this.sunbathAnchorX;
this.y = this.sunbathAnchorY;
this.x = clamp(Number.isFinite(this.x) ? this.x : (Number.isFinite(this.sunbathAnchorX) ? this.sunbathAnchorX : pad), pad, safeW - pad);
this.y = clamp(Number.isFinite(this.y) ? this.y : (Number.isFinite(this.sunbathAnchorY) ? this.sunbathAnchorY : pad), pad, safeH - pad);
this.sunbathAnchorX = this.x;
this.sunbathAnchorY = this.y;
this._lastSunbathDrawX = this.x;
this._lastSunbathDrawY = this.y;
this._lastValidX = this.x;
@ -430,6 +428,17 @@
this.dead = true;
this.deathReason = finalReason;
this.world.deadCount += 1;
for (const item of this.world.items || []) {
if (!item || item.dead || !item.isStructure || item.type !== "plushie" || item.ownerId !== this.id) continue;
item.dead = true;
item.hp = 0;
item.amount = 0;
item.carriedById = "";
item.onHead = false;
}
this.world.drawListDirty = true;
this.world.markItemBucketsDirty?.("tarinai-death-plushie-cleanup");
this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup");
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y));
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y));
if (Math.random() < 0.55) {

View file

@ -3,12 +3,10 @@
// Layer: entity-runtime/tarinai/update-system
// Owns active sunbath timer, sprite frame cycling, and recovery finish logic.
(function (global) {
function legacyMethod(dt) {
function updateBody(dt) {
if ((this.sunbathTimer || 0) <= 0) return;
this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt);
this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt);
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)}`;
@ -40,12 +38,12 @@
function updateOne(tarinai, dt) {
if (!tarinai || tarinai.dead) return false;
legacyMethod.call(tarinai, dt);
updateBody.call(tarinai, dt);
return true;
}
global.TarinaiSunbathSystem = Object.freeze({
legacyMethod,
updateBody,
updateOne,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,9 +1,8 @@
"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.
// Owns the ordered Tarinai update pipeline. There is no monolithic fallback path:
// creature behavior must be handled by the explicit update steps below.
(function (global) {
const CONCRETE_STEP_NAMES = Object.freeze([
"TarinaiFrameUpdateStep",
@ -21,7 +20,12 @@
return concreteSteps().length === CONCRETE_STEP_NAMES.length;
}
function runConcrete(tarinai, dt, context) {
function updateOne(tarinai, dt, options = {}) {
if (!tarinai || tarinai.dead || !canRunConcretePipeline()) return false;
const context = {
...(options.context || {}),
wasSleepingAtFrameStart: false,
};
for (const step of concreteSteps()) {
const result = step.update(tarinai, dt, context) || {};
if (result.done) return result.ok !== false;
@ -29,24 +33,6 @@
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,

View file

@ -2,7 +2,7 @@
// 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.
// and facing refresh. Extracted from Tarinai.prototype.update.
(function (global) {
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };

View file

@ -2,7 +2,7 @@
// 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
// pressure, and early freeze exits. This is extracted from the former
// Tarinai.prototype.update body without changing behavior.
(function (global) {
function update(t, dt, context = {}) {

View file

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

View file

@ -11,9 +11,8 @@ function bindUI() {
window.TarinaiSaveSystem?.bindSaveSystem?.();
window.TarinaiFreezeSystem?.bindFreezeSystem?.();
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
function isActivePlacementTool() {
const def = typeof toolDefinition === "function" ? toolDefinition(world.tool) : null;
return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(world.tool));
function isActiveToolMode() {
return Boolean(world?.tool && world.tool !== "observe");
}
function clickedInsideGameOrToolUi(target) {
return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target) || ui.panelQuickTabs?.contains?.(target));
@ -270,7 +269,7 @@ function bindUI() {
closeSoundPanel();
});
document.addEventListener("click", (e) => {
if (!isActivePlacementTool()) return;
if (!isActiveToolMode()) return;
if (clickedInsideGameOrToolUi(e.target)) return;
selectTool("observe"); // \u9053\u5177\u8a2d\u7f6e\u30e2\u30fc\u30c9\u3092\u89e3\u9664
});
@ -324,6 +323,12 @@ function bindUI() {
if (!btn) return;
const tool = btn.dataset.tool;
audio.uiClick?.();
if (tool === "undo" || tool === "redo") {
const result = window.TarinaiCommands?.dispatch?.(world, { type: tool === "undo" ? "history.undo" : "history.redo" });
showToast(result?.ok ? (tool === "undo" ? "戻しました。" : "進めました。") : (tool === "undo" ? "戻せる操作がありません。" : "進める操作がありません。"));
renderStats();
return;
}
if (world.tool === tool && cycleToolSize(tool)) return;
selectTool(tool);
});

View file

@ -7,11 +7,26 @@
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
canvas.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "area_delete") {
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
e.preventDefault();
ctx.beginAreaDelete(p, e.clientX, e.clientY);
return;
}
if (e.button === 0 && world.tool === "water_hose") {
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
e.preventDefault();
ctx.beginHose(p, e.clientX, e.clientY);
if (uiCache.hoseHoldTimer) clearInterval(uiCache.hoseHoldTimer);
uiCache.hoseHoldTimer = setInterval(() => {
if (!uiCache.hosing) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; return; }
const cx = uiCache.hoseLastClientX || e.clientX;
const cy = uiCache.hoseLastClientY || e.clientY;
const hp = ctx.screenToWorldClient(cx, cy);
ctx.moveHose(hp, cx, cy);
}, 90);
return;
}
if (e.button === 0 && world.tool === "pinch") {
@ -28,12 +43,18 @@
});
window.addEventListener("mouseup", (e) => {
if (ctx.endHose()) return;
if (ctx.endAreaDelete()) return;
if (ctx.endHose()) { if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; } return; }
if (ctx.endGrab(e.clientX, e.clientY)) return;
ctx.endPan(e.button);
});
window.addEventListener("mousemove", (e) => {
if (uiCache.areaDeleting) {
const p = ctx.screenToWorldEvent(e);
ctx.moveAreaDelete(p, e.clientX, e.clientY);
return;
}
if (uiCache.hosing) {
const p = ctx.screenToWorldEvent(e);
ctx.moveHose(p, e.clientX, e.clientY);
@ -50,6 +71,10 @@
});
canvas.addEventListener("click", (e) => {
if (uiCache.areaDeleteMoved) {
uiCache.areaDeleteMoved = false;
return;
}
if (uiCache.hoseMoved) {
uiCache.hoseMoved = false;
return;

View file

@ -107,13 +107,6 @@
return true;
}
function isClientOverFrozenPanel(clientX, clientY) {
const panel = document.getElementById("frozenPanel");
if (!panel) return false;
const r = panel.getBoundingClientRect();
return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
}
function rememberGrabOrigin(target) {
if (!target || uiCache.grabKind !== "tarinai") return;
uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 };
@ -121,11 +114,7 @@
function finishTarinaiGrab(target, clientX, clientY) {
if (!target || uiCache.grabKind !== "tarinai") return false;
if (isClientOverFrozenPanel(clientX, clientY) && global.TarinaiFreezeSystem?.freezeTarinai) {
global.TarinaiFreezeSystem.freezeTarinai(target, world);
uiCache.grabOrigin = null;
return true;
}
const p = screenToWorldClient(clientX, clientY);
if (!p.inside && uiCache.grabOrigin) {
target.x = uiCache.grabOrigin.x;
@ -165,6 +154,7 @@
if (options.notifyNoTarget) showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return false;
}
global.TarinaiHistory?.capture?.(world, "pinch");
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
@ -202,6 +192,8 @@
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
const beforeX = Number(target.x || 0) || 0;
const beforeY = Number(target.y || 0) || 0;
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(clientX, clientY);
@ -211,6 +203,14 @@
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
const movedX = (Number(target.x || 0) || 0) - beforeX;
const movedY = (Number(target.y || 0) || 0) - beforeY;
if (uiCache.grabKind === "item" && target.type === "reciprocator") {
target.reciprocatorAnchorX = (Number(target.reciprocatorAnchorX || beforeX) || beforeX) + movedX;
target.reciprocatorAnchorY = (Number(target.reciprocatorAnchorY || beforeY) || beforeY) + movedY;
target.prevX = target.x;
target.prevY = target.y;
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
@ -266,6 +266,7 @@
}
function beginHose(p, clientX, clientY, options = {}) {
global.TarinaiHistory?.capture?.(world, "water-hose");
audio.waterHose?.();
uiCache.hosing = true;
uiCache.hoseMoved = false;
@ -273,13 +274,16 @@
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = clientX;
uiCache.hoseStartY = clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
uiCache.hoseLastApplyAt = global.performance?.now?.() || Date.now();
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.18);
if (options.touchMode) uiCache.touchMode = options.touchMode;
render();
return true;
}
function moveHose(p, clientX, clientY, options = {}) {
uiCache.hoseLastClientX = clientX;
uiCache.hoseLastClientY = clientY;
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
@ -289,7 +293,12 @@
uiCache.hoseMoved = true;
if (options.touch) uiCache.touchMoved = true;
}
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
if (p.inside) {
const now = global.performance?.now?.() || Date.now();
const elapsed = Math.max(0.06, Math.min(0.22, (now - (uiCache.hoseLastApplyAt || now)) / 1000));
uiCache.hoseLastApplyAt = now;
world.applyWaterHose?.(p.x, p.y, dx, dy, elapsed);
}
canvas.style.cursor = "crosshair";
render();
}
@ -297,12 +306,57 @@
function endHose() {
if (!uiCache.hosing) return false;
uiCache.hosing = false;
uiCache.hoseLastApplyAt = 0;
canvas.style.cursor = cursorForTool(world.tool);
world.rebuildSpatial?.();
renderStats();
return true;
}
function beginAreaDelete(p, clientX, clientY, options = {}) {
global.TarinaiHistory?.capture?.(world, "area-delete");
uiCache.areaDeleting = true;
uiCache.areaDeleteMoved = false;
uiCache.areaDeleteStartX = clientX;
uiCache.areaDeleteStartY = clientY;
uiCache.areaDeleteLastWorldX = p.x;
uiCache.areaDeleteLastWorldY = p.y;
world._areaDeleteDragRemoved = 0;
if (p.inside) world.deleteItemsInArea?.(p.x, p.y, { silentEmpty: true, silentLog: true, drag: true });
if (options.touchMode) uiCache.touchMode = options.touchMode;
render();
return true;
}
function moveAreaDelete(p, clientX, clientY, options = {}) {
const totalMove = Math.hypot(clientX - (uiCache.areaDeleteStartX || clientX), clientY - (uiCache.areaDeleteStartY || clientY));
if (totalMove > 3) {
uiCache.areaDeleteMoved = true;
if (options.touch) uiCache.touchMoved = true;
}
if (p.inside) world.deleteItemsInArea?.(p.x, p.y, { silentEmpty: true, silentLog: true, drag: true });
canvas.style.cursor = "crosshair";
render();
return true;
}
function endAreaDelete() {
if (!uiCache.areaDeleting) return false;
uiCache.areaDeleting = false;
const removed = world._areaDeleteDragRemoved || 0;
world._areaDeleteDragRemoved = 0;
canvas.style.cursor = cursorForTool(world.tool);
world.rebuildSpatial?.();
renderStats();
if (removed > 0) {
world.log?.(`範囲削除で${removed}個消した。`, "observe");
showToast(`${removed}個消しました。`);
}
return true;
}
function beginPan(clientX, clientY, button = null) {
uiCache.panning = true;
uiCache.panMoved = false;
@ -354,6 +408,8 @@
uiCache.touchMode = "";
uiCache.touchMoved = false;
uiCache.hosing = false;
if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; }
uiCache.areaDeleting = false;
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
@ -382,6 +438,9 @@
beginHose,
moveHose,
endHose,
beginAreaDelete,
moveAreaDelete,
endAreaDelete,
beginPan,
movePan,
endPan,

View file

@ -41,8 +41,20 @@
uiCache.touchMode = "toolTapOnly";
return;
}
if (world.tool === "area_delete" && p.inside) {
ctx.beginAreaDelete(p, t.clientX, t.clientY, { touchMode: "areaDelete" });
return;
}
if (world.tool === "water_hose" && p.inside) {
ctx.beginHose(p, t.clientX, t.clientY, { touchMode: "hose" });
if (uiCache.hoseHoldTimer) clearInterval(uiCache.hoseHoldTimer);
uiCache.hoseHoldTimer = setInterval(() => {
if (!uiCache.hosing) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; return; }
const cx = uiCache.hoseLastClientX || uiCache.touchLastX || t.clientX;
const cy = uiCache.hoseLastClientY || uiCache.touchLastY || t.clientY;
const hp = ctx.screenToWorldClient(cx, cy);
ctx.moveHose(hp, cx, cy, { touch: true });
}, 90);
return;
}
if (world.tool === "pinch" && p.inside && ctx.beginGrab(p, t.clientX, t.clientY, { touchMode: "grab" })) return;
@ -83,6 +95,10 @@
if (totalMove > 8) uiCache.touchMoved = true;
return;
}
if (uiCache.touchMode === "areaDelete" && uiCache.areaDeleting) {
ctx.moveAreaDelete(p, t.clientX, t.clientY, { touch: true });
return;
}
if (uiCache.touchMode === "hose" && uiCache.hosing) {
ctx.moveHose(p, t.clientX, t.clientY, { touch: true });
return;
@ -103,7 +119,8 @@
canvas.addEventListener("touchend", (e) => {
e.preventDefault();
if (uiCache.hosing) ctx.endHose();
if (uiCache.areaDeleting) ctx.endAreaDelete();
if (uiCache.hosing) { ctx.endHose(); if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; } }
if (uiCache.grabbing) {
ctx.endGrab(e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0);
} else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) {

View file

@ -127,6 +127,7 @@ function resizeFieldFromWheel(e) {
function selectTool(tool) {
const result = window.TarinaiCommands?.dispatch?.(world, { type: "tool.select", toolId: tool });
const activeTool = result?.toolId || world.tool || tool;
if (activeTool !== "rope" && activeTool !== "rod") world.pendingLinkEndpoint = null;
for (const btn of uiCache.toolButtons) {
btn.classList.toggle("selected", btn.dataset.tool === activeTool);
}

View file

@ -1,7 +1,7 @@
"use strict";
const SCALABLE_TOOLS = new Set(scalableToolIds());
const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose"]);
const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose", "rope", "rod", "reciprocator", "copy", "undo", "redo", "area_delete"]);
const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"];
@ -78,6 +78,7 @@ function createToolPreviewCanvas(className, size = 96) {
function attachToolSpritePreview(btn, def, toolId) {
const itemType = def?.itemType || toolId;
if (!btn || !itemType || !def?.placeable) return;
if (toolId === "reciprocator") return;
if (toolId === "ball" || itemType === "ball") return;
if (typeof window.drawToolItemPreview !== "function" && typeof window.drawConsumableFieldSprite !== "function") return;

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "16.07.50";
const APP_BUILD = "boundary-cleanup-v15";
const APP_VERSION = "38.00.00";
const APP_BUILD = "lab-ground-wash-and-freezer-removal-v38";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -17,6 +17,24 @@
return rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
},
fixedWeatherFor(worldRef) {
const fixed = global.TarinaiGround?.fixedWeather?.(worldRef?.groundType || "soil") || "";
return fixed || null;
},
enforceFixedWeather(worldRef) {
const fixed = this.fixedWeatherFor(worldRef);
if (!fixed || !worldRef) return false;
const previous = worldRef.weather || "sunny";
worldRef.weather = fixed;
worldRef.nextWeatherChange = this.resetTimer();
if (previous !== fixed) {
worldRef.emit?.("weather:changed", { previous, next: fixed });
worldRef.log?.(`天気: ${weatherLabel(fixed)}`);
}
return true;
},
shouldDropRainWater(worldRef, dt = 0) {
return Boolean(
worldRef?.weather === "light_rain" &&
@ -32,6 +50,7 @@
applyNextWeather(worldRef) {
if (!worldRef) return null;
if (this.enforceFixedWeather(worldRef)) return worldRef.weather || "sunny";
const previous = worldRef.weather || "sunny";
const next = this.chooseNext(previous);
worldRef.weather = next;

View file

@ -3,6 +3,14 @@
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_combat_effects.js");
const PHYSICAL_DAMAGE_SPEED_THRESHOLD = 165;
function physicalDamageFromImpactSpeed(speed = 0, { min = 0, max = 58, scale = 8.6 } = {}) {
const v = Math.max(0, Number(speed) || 0);
if (v < PHYSICAL_DAMAGE_SPEED_THRESHOLD) return 0;
const raw = (v - PHYSICAL_DAMAGE_SPEED_THRESHOLD) / Math.max(1, scale);
return clamp(raw, min, max);
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) {
let hit = 0;
@ -224,7 +232,7 @@
},
applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) {
const radius = 96;
const radius = 138;
const speed = Math.hypot(dx, dy) || 1;
const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1);
const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7);
@ -237,6 +245,16 @@
if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") {
it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p);
cleaned += p;
} else if (it.type === "grass") {
it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 135 * (0.30 + p));
it.growth = Math.max(0, (Number(it.growth) || 0) - dt * 0.50 * (0.20 + p));
if (it.amount <= 0.5) { it.amount = 0; }
cleaned += p * 0.72;
} else if (it.type === "grass_bed") {
it.amount = 0;
it.hp = 0;
global.TarinaiStructureLifecycle?.deleteItem?.(this, it, { reason: "water-hose-grass-bed", userReason: "洗浄で流された", wake: true });
cleaned += p * 0.95;
}
}
for (const ef of this.effects || []) {
@ -263,6 +281,9 @@
}
if (cleaned > 0.25) {
this.drawListDirty = true;
this.markItemBucketsDirty?.("water-hose-clean");
this.markTerrainDirtyAt?.(x, y, radius + 84, "water-hose-clean");
this.markSpatialDirty?.("water-hose-clean");
this.updateItemCounts();
}
},
@ -415,6 +436,9 @@
if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false;
const normalized = options.cause || cause || "\u885d\u7a81";
this.emit?.(String(normalized).includes("\u55a7\u5629") ? "fight:hit" : "impact:hit", { target, amount, cause: normalized, source: options.source || null });
if (String(normalized).includes("衝突") || String(normalized).includes("collision") || String(cause || "").includes("急激な速度変化")) {
target.lastPhysicalCollisionDamageAt = this.time || 0;
}
if (target.damage) target.damage(amount, normalized);
else if (Number.isFinite(target.hp)) target.hp -= amount;
return true;
@ -504,10 +528,13 @@
if (!it || it.dead || it.type === "water") return;
const isStone = it.type === "stone";
const isGenkotsu = it.type === "genkotsu";
const radius = isGenkotsu ? Math.max(164, (it.r || 88) * 2.05) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75));
const damage = isGenkotsu ? rand(38, 58) : (isStone ? rand(20, 34) : rand(3, 7));
const push = isGenkotsu ? rand(760, 1040) : (isStone ? rand(145, 210) : rand(54, 92));
const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f");
const dropFallHeight = isGenkotsu ? Math.max(360, (it.r || 88) * 5.8) : 170;
const dropDuration = Math.max(0.08, Number(it.dropMax || 0.42) || 0.42);
const impactSpeed = dropFallHeight / dropDuration;
const radius = Math.max(32, Math.min(220, (it.r || 14) * 1.35 + impactSpeed * 0.055));
const damage = physicalDamageFromImpactSpeed(impactSpeed, { min: 1.2, max: isGenkotsu ? 58 : 34, scale: 8.6 });
const push = clamp((impactSpeed - 120) * 0.62, 42, isGenkotsu ? 1040 : 260);
const reason = impactSpeed >= PHYSICAL_DAMAGE_SPEED_THRESHOLD ? "\u9ad8\u901f\u3067\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u885d\u7a81" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f";
if (isGenkotsu) { it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); this.triggerGroundShake?.(2.0, 10); }
let hit = 0;
for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) {
@ -523,9 +550,9 @@
ny = Math.sin(a);
}
t.vx += nx * push * (0.35 + p);
t.vy += ny * push * (0.35 + p) - (isGenkotsu ? 190 : (isStone ? 42 : 18)) * p;
t.vy += ny * push * (0.35 + p) - Math.min(210, push * 0.22) * p;
const dealtDamage = damage * (0.35 + p * 0.65);
t.damage(dealtDamage, reason);
if (dealtDamage > 0) t.damage(dealtDamage, reason);
if (isGenkotsu) {
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
@ -540,10 +567,10 @@
t.fallDir = nx >= 0 ? 1 : -1;
hit += 1;
}
if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, it.x, it.y, radius, damage, it);
const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
if (damage > 0 && typeof damageNearbyStructures === "function") damageNearbyStructures(this, it.x, it.y, radius, damage, it);
const antHit = damage > 0 ? this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
push: p => push * (0.62 + p * 1.25),
});
}) : 0;
if (isGenkotsu) {
this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75);
for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" }));
@ -747,11 +774,11 @@
}
}
const isDangerous = ballSpeed >= 320;
const isDangerous = ballSpeed >= 285;
if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) {
const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny;
const damage = clamp((ballSpeed - 285) / 18, 3.5, 38);
const damage = physicalDamageFromImpactSpeed(ballSpeed, { min: 3.5, max: 38, scale: 18 });
t.lastBallDamageAt = now;
this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, {
panic: true,
@ -817,7 +844,7 @@
},
resolveTarinaiHighSpeedCollisions(dt) {
const threshold = 150;
const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD;
const now = this.time || 0;
if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map();
for (const a of this.tarinai || []) {
@ -865,7 +892,7 @@
nx = Math.cos(aa);
ny = Math.sin(aa);
}
const damage = clamp((relSpeed - 118) / 26, 1.2, 22);
const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 });
this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, {
panic: true,
target: { x: impactor.x, y: impactor.y },

View file

@ -19,6 +19,7 @@
return normalizedAngleLocal(it?.angle, fallback);
}
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
@ -81,7 +82,7 @@
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,
angularVelocity: (item?.rotatorPowered === false ? 0 : (Number(item?.rotatorSpeed || 0) || 0)) + (Number(item?.rotatorAngularVelocity || 0) || 0),
centerX: Number(item?.x) || 0, centerY: Number(item?.y) || 0,
};
}
@ -93,6 +94,43 @@
return rects;
}
function reciprocatorAxisLocal(item) {
const fallback = angleForItemLocal(item);
const angle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(item?.reciprocatorAxisAngle, fallback) : (Number.isFinite(Number(item?.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : fallback);
return { x: Math.cos(angle), y: Math.sin(angle), angle };
}
function reciprocatorSegmentRectLocal(item, x1, y1, x2, y2) {
const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1));
const segAngle = 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, segAngle);
const axis = reciprocatorAxisLocal(item);
const itemX = Number(item?.x || 0) || 0;
const itemY = Number(item?.y || 0) || 0;
const reciprocatorDirection = Math.sign(Number(item?.reciprocatorDirection || 1) || 1) || 1;
const motorVelocity = item?.reciprocatorPowered === false ? 0 : reciprocatorDirection * Math.max(0, Number(item?.reciprocatorSpeed || 0) || 0);
const totalVelocity = motorVelocity + (Number(item?.reciprocatorVelocity || 0) || 0);
const vx = axis.x * totalVelocity;
const vy = axis.y * totalVelocity;
return {
left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom,
cx, cy, halfW, halfH, angle: segAngle, cos: aabb.cos, sin: aabb.sin, oriented: true,
type: "reciprocator", item, reciprocator: true, restitution: 0.70,
motionVelocityX: vx, motionVelocityY: vy, axisX: axis.x, axisY: axis.y,
};
}
function reciprocatorObstacleRectsLocal(item) {
if (!item || item.dead || item.type !== "reciprocator") return [];
const rects = [];
for (const seg of rotatorWorldSegmentsLocal(item)) rects.push(reciprocatorSegmentRectLocal(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);
@ -185,51 +223,6 @@
},
deviceRect(it) {
const type = String(it?.type || "");
if (type !== "fan" && type !== "magnet") return null;
const r = it.r || (type === "fan" ? 32 : 30);
const angle = angleForItemLocal(it);
const cx = (it.x || 0) - Math.cos(angle) * r * (type === "fan" ? 0.18 : 0.08);
const cy = (it.y || 0) - Math.sin(angle) * r * (type === "fan" ? 0.18 : 0.08);
const halfW = Math.max(22, r * (type === "fan" ? 0.92 : 0.88));
const halfH = Math.max(18, r * (type === "fan" ? 0.72 : 0.78));
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,
device: true, type, item: it, restitution: 0.68,
};
},
fanInfluenceShape(it) {
if (!it || it.type !== "fan") return null;
const angle = angleForItemLocal(it);
const r = it.r || 32;
return {
type: "fan",
x: it.x || 0,
y: it.y || 0,
angle,
range: Math.max(170, r * 7.2),
nearWidth: Math.max(42, r * 1.0),
farWidth: Math.max(42, r * 4.1),
};
},
magnetInfluenceShape(it) {
if (!it || it.type !== "magnet") return null;
const angle = angleForItemLocal(it);
const r = it.r || 30;
return {
type: "magnet",
x: (it.x || 0) + Math.cos(angle) * r * 1.10,
y: (it.y || 0) + Math.sin(angle) * r * 1.10,
angle,
range: Math.max(150, r * 7.2),
};
},
nestBoxCapacity() {
return 5;
},
@ -304,22 +297,30 @@
rotatorSegments(it) {
return sanitizeRotatorSegmentsLocal(it);
return global.TarinaiMechanicalSystem?.sanitizeSegments?.(it) || sanitizeRotatorSegmentsLocal(it);
},
rotatorWorldSegments(it) {
return rotatorWorldSegmentsLocal(it);
return global.TarinaiMechanicalSystem?.worldSegments?.(it) || rotatorWorldSegmentsLocal(it);
},
rotatorExtent(it) {
return rotatorExtentLocal(it);
return global.TarinaiMechanicalSystem?.extent?.(it) || rotatorExtentLocal(it);
},
rotatorObstacleRects(it) {
return rotatorObstacleRectsLocal(it);
if (it?.type === "rotator") return global.TarinaiMechanicalSystem?.obstacleRects?.(it) || rotatorObstacleRectsLocal(it);
return [];
},
reciprocatorObstacleRects(it) {
if (it?.type === "reciprocator") return global.TarinaiMechanicalSystem?.obstacleRects?.(it) || reciprocatorObstacleRectsLocal(it);
return [];
},
rotatorBoundsAabb(it) {
const bounds = global.TarinaiMechanicalSystem?.boundsAabb?.(it);
if (bounds) return bounds;
const rects = rotatorObstacleRectsLocal(it);
if (!rects.length) return null;
return {
@ -338,11 +339,7 @@
const rect = this.fenceRect(it);
return rect ? [{ ...rect, type: it.type, item: it }] : [];
}
if (it.type === "fan" || it.type === "magnet") {
const rect = this.deviceRect?.(it);
return rect ? [{ ...rect, type: it.type, item: it }] : [];
}
if (it.type === "rotator") return this.rotatorObstacleRects?.(it) || [];
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type)) return global.TarinaiMechanicalSystem?.obstacleRects?.(it) || [];
if (it.type === "nest_box") return this.nestBoxSolidRects(it);
return [];
},
@ -383,8 +380,8 @@
// 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 (!it || !global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type) || it.dead || seen.has(it)) continue;
const reach = global.TarinaiMechanicalSystem?.reach?.(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;
@ -394,11 +391,13 @@
},
pointInRect(x, y, r, padding = 0) {
return Boolean(r && x >= r.left - padding && x <= r.right + padding && y >= r.top - padding && y <= r.bottom + padding);
return global.TarinaiCollisionFootprints?.pointInRect?.(x, y, r, padding) ?? false;
},
pushTarinaiOutOfRect(t, r, rr) {
if (!t || !r) return false;
const preVx = Number(t.vx || 0) || 0;
const preVy = Number(t.vy || 0) || 0;
let cx, cy, dx, dy, d, nx, ny;
let insidePenetration = 0;
if (r.oriented) {
@ -485,20 +484,22 @@
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 (r.mechanical) {
global.TarinaiMechanicalSystem?.applySurfaceVelocityToCircle?.(t, r, nx, ny, preVx, preVy, {
damping: r.rotator ? 0.78 : 0.80,
surfaceScale: r.rotator ? 0.52 : 0.64,
normalBoost: r.rotator ? 42 : 36,
maxSpeed: r.rotator ? 680 : 620,
impulseVScale: r.rotator ? 0.10 : 0.08,
impulseMax: r.rotator ? 360 : 320,
passiveImpulseScale: 0.82,
spinKick: 0,
});
t.surpriseTimer = Math.max(t.surpriseTimer || 0, r.rotator ? 0.12 : 0.10);
const field = r.rotator ? "lastRotatorHitEffectAt" : "lastReciprocatorHitEffectAt";
if ((this.time || 0) >= (t[field] || -999) + 0.10) {
t[field] = this.time || 0;
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: r.rotator ? "rgba(175,116,230,0.42)" : "rgba(90,150,214,0.40)" }));
}
} else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
else t.vy *= -0.18;

View file

@ -38,14 +38,6 @@
}
}
function renderFreezePanel(worldRef) {
if (global.TarinaiFreezePanel?.render && global.TarinaiFreezeStore?.frozenList) {
global.TarinaiFreezePanel.render(global.TarinaiFreezeStore.frozenList(worldRef));
return;
}
global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef);
}
events.on("log:entry", (event) => {
const detail = event.detail || {};
@ -61,15 +53,4 @@
const detail = event.detail || {};
if (detail.dropped) global.audio?.place?.(detail.type || detail.item?.type || "");
});
events.on("freeze:changed", (event) => {
const detail = event.detail || {};
const worldRef = detail.world || global.world;
renderFreezePanel(worldRef);
if (detail.renderWorld !== false) {
if (typeof renderSelected === "function") renderSelected();
if (typeof renderStats === "function") renderStats();
if (typeof render === "function") render();
}
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -163,10 +163,12 @@
<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>
<label><input id="rotatorPowered" type="checkbox"> 動力ON</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-mode="erase">消しゴム</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>
@ -174,7 +176,7 @@
<button id="rotatorClear" type="button">全消し</button>
</div>
<canvas id="rotatorCanvas" width="520" height="360" aria-label="回転体の輪郭編集"></canvas>
<p class="rotator-editor-hint">中央が回転軸ドラッグで輪郭を描く大きい図形はキャンバス端まで使用可能</p>
<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>
@ -186,7 +188,7 @@
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]);
return src.filter(seg => Array.isArray(seg) && seg.length >= 4).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]);
}
function openRotatorEditor(item, worldRef) {
@ -198,6 +200,7 @@
const speedNum = dialog.querySelector("#rotatorSpeedNum");
const thick = dialog.querySelector("#rotatorThickness");
const thickNum = dialog.querySelector("#rotatorThicknessNum");
const poweredInput = dialog.querySelector("#rotatorPowered");
const apply = dialog.querySelector("#rotatorApply");
const cancel = dialog.querySelector("#rotatorCancel");
const closeBtn = dialog.querySelector("#rotatorEditorClose");
@ -216,6 +219,7 @@
if (speedNum) speedNum.value = String(deg);
if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness);
if (poweredInput) poweredInput.checked = item.rotatorPowered !== false;
const toLocal = (e) => {
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
@ -275,8 +279,36 @@
ctx.restore();
};
const setMode = (next) => {
mode = next === "free" ? "free" : "line";
mode = next === "free" || next === "erase" ? next : "line";
for (const btn of dialog.querySelectorAll("[data-rotator-mode]")) btn.classList.toggle("active", btn.dataset.rotatorMode === mode);
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
};
const pointSegmentDistance = (p, seg) => {
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
};
const eraseAt = (p) => {
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
const radius = Math.max(24, lw * 1.35);
const before = segments.length;
segments = segments.filter(seg => pointSegmentDistance(p, seg) > radius);
if (segments.length !== before) { draft = null; draw(); return true; }
draw();
ctx.save();
ctx.translate(cx, cy);
ctx.globalAlpha = 0.42;
ctx.strokeStyle = "rgba(210,70,60,0.86)";
ctx.lineWidth = 1.6;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
return false;
};
const setTemplate = (name) => {
if (name === "bar") segments = [[-130, 0, 130, 0]];
@ -301,20 +333,27 @@
canvas.setPointerCapture?.(e.pointerId);
const p = toLocal(e);
drawing = true;
if (mode === "erase") {
draft = null;
eraseAt(p);
return;
}
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;
if (!drawing) return;
const p = toLocal(e);
if (mode === "erase") { eraseAt(p); return; }
if (!draft) return;
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]);
segments.push([p.x, p.y, p.x, p.y]);
}
} else {
draft[2] = p.x; draft[3] = p.y;
@ -322,9 +361,11 @@
draw();
};
canvas.onpointerup = (e) => {
if (!drawing || !draft) return;
if (!drawing) return;
drawing = false;
const p = toLocal(e);
if (mode === "erase") { draft = null; draw(); return; }
if (!draft) return;
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);
@ -337,13 +378,17 @@
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
const close = () => {
dialog.classList.add("hidden");
canvas.style.cursor = "";
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
};
if (apply) apply.onclick = () => {
global.TarinaiHistory?.capture?.(worldRef, "rotator-edit");
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));
item.rotatorPowered = poweredInput ? Boolean(poweredInput.checked) : item.rotatorPowered !== false;
if (item.rotatorPowered) item.rotatorAngularVelocity = 0;
const extent = worldRef?.rotatorExtent?.(item);
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
item.rotatorEditorOpenAt = worldRef?.time || 0;
@ -362,6 +407,286 @@
return true;
}
function ensureReciprocatorEditor() {
let dialog = document.getElementById("reciprocatorEditor");
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.id = "reciprocatorEditor";
dialog.className = "rotator-editor hidden";
dialog.innerHTML = `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="reciprocatorEditorTitle">
<div class="rotator-editor-titlebar">
<h2 id="reciprocatorEditorTitle">往復体</h2>
<button id="reciprocatorEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button>
</div>
<div class="rotator-editor-controls">
<label>方向 <input id="reciprocatorAngle" type="range" min="0" max="355" step="5"><input id="reciprocatorAngleNum" type="number" min="0" max="355" step="5"> </label>
<label>速度 <input id="reciprocatorSpeed" type="range" min="0" max="240" step="5"><input id="reciprocatorSpeedNum" type="number" min="0" max="360" step="5"> px/</label>
<label>往復幅 <input id="reciprocatorTravel" type="range" min="48" max="360" step="4"><input id="reciprocatorTravelNum" type="number" min="24" max="520" step="4"> px</label>
<label>太さ <input id="reciprocatorThickness" type="range" min="4" max="30" step="1"><input id="reciprocatorThicknessNum" type="number" min="4" max="34" step="1"></label>
<label><input id="reciprocatorPowered" type="checkbox"> 動力ON</label>
</div>
<div class="rotator-editor-tools" role="toolbar" aria-label="描画方法">
<button type="button" data-reciprocator-mode="line" class="active">直線</button>
<button type="button" data-reciprocator-mode="free">自由描画</button>
<button type="button" data-reciprocator-mode="erase">消しゴム</button>
<button type="button" data-reciprocator-template="bar"></button>
<button type="button" data-reciprocator-template="cross">十字</button>
<button type="button" data-reciprocator-template="circle">円っぽい</button>
<button id="reciprocatorUndo" type="button">一つ戻す</button>
<button id="reciprocatorClear" type="button">全消し</button>
</div>
<canvas id="reciprocatorCanvas" width="520" height="360" aria-label="往復体の輪郭編集"></canvas>
<p class="rotator-editor-hint">中央の輪郭全体が設定した方向へ往復移動します動力OFFではたりないずんち他の機構から押されたときだけ動きます</p>
<div class="rotator-editor-actions">
<button id="reciprocatorCancel" class="btn" type="button">キャンセル</button>
<button id="reciprocatorApply" class="btn primary" type="button">反映</button>
</div>
</div>`;
document.body.appendChild(dialog);
return dialog;
}
function openReciprocatorEditor(item, worldRef) {
if (!item || item.type !== "reciprocator") return false;
const dialog = ensureReciprocatorEditor();
const canvas = dialog.querySelector("#reciprocatorCanvas");
const ctx = canvas?.getContext?.("2d");
const angle = dialog.querySelector("#reciprocatorAngle");
const angleNum = dialog.querySelector("#reciprocatorAngleNum");
const speed = dialog.querySelector("#reciprocatorSpeed");
const speedNum = dialog.querySelector("#reciprocatorSpeedNum");
const travel = dialog.querySelector("#reciprocatorTravel");
const travelNum = dialog.querySelector("#reciprocatorTravelNum");
const thick = dialog.querySelector("#reciprocatorThickness");
const thickNum = dialog.querySelector("#reciprocatorThicknessNum");
const powered = dialog.querySelector("#reciprocatorPowered");
const apply = dialog.querySelector("#reciprocatorApply");
const cancel = dialog.querySelector("#reciprocatorCancel");
const closeBtn = dialog.querySelector("#reciprocatorEditorClose");
const undo = dialog.querySelector("#reciprocatorUndo");
const clear = dialog.querySelector("#reciprocatorClear");
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 axisRad = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0)));
const deg = Math.round((axisRad * 180 / Math.PI) % 360 + 360) % 360;
const speedVal = Math.round(Number(item.reciprocatorSpeed || 92) || 0);
const travelVal = Math.round(Number(item.reciprocatorTravel || 150) || 150);
const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 12) || 12));
if (angle) angle.value = String(Math.max(0, Math.min(355, deg)));
if (angleNum) angleNum.value = String(deg);
if (speed) speed.value = String(Math.max(0, Math.min(240, speedVal)));
if (speedNum) speedNum.value = String(speedVal);
if (travel) travel.value = String(Math.max(48, Math.min(360, travelVal)));
if (travelNum) travelNum.value = String(travelVal);
if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness);
if (powered) powered.checked = item.reciprocatorPowered !== false;
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(); }
const travelNow = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150));
ctx.save();
ctx.strokeStyle = "rgba(70,120,170,0.42)";
ctx.lineWidth = 2;
ctx.setLineDash([7, 6]);
ctx.beginPath(); ctx.moveTo(-travelNow * 0.5, 22); ctx.lineTo(travelNow * 0.5, 22); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
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(80,150,205,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 = "#eaf6ff";
ctx.strokeStyle = "#4d8cba";
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" || next === "erase" ? next : "line";
for (const btn of dialog.querySelectorAll("[data-reciprocator-mode]")) btn.classList.toggle("active", btn.dataset.reciprocatorMode === mode);
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
};
const pointSegmentDistance = (p, seg) => {
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
};
const eraseAt = (p) => {
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
const radius = Math.max(28, lw * 1.55);
const before = segments.length;
segments = segments.filter(seg => pointSegmentDistance(p, seg) > radius);
if (segments.length !== before) { draft = null; draw(); return true; }
draw();
ctx.save();
ctx.translate(cx, cy);
ctx.globalAlpha = 0.42;
ctx.strokeStyle = "rgba(210,70,60,0.86)";
ctx.lineWidth = 1.6;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
return false;
};
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, redraw = false) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; if (redraw) draw(); }; b.oninput = () => { a.value = b.value; if (redraw) draw(); }; };
syncPair(angle, angleNum, false);
syncPair(speed, speedNum, false);
syncPair(travel, travelNum, true);
syncPair(thick, thickNum, true);
dialog.querySelectorAll("[data-reciprocator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.reciprocatorMode); });
dialog.querySelectorAll("[data-reciprocator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.reciprocatorTemplate); });
canvas.onpointerdown = (e) => {
canvas.setPointerCapture?.(e.pointerId);
const p = toLocal(e);
drawing = true;
if (mode === "erase") { draft = null; eraseAt(p); return; }
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) return;
const p = toLocal(e);
if (mode === "erase") { eraseAt(p); return; }
if (!draft) return;
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;
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) return;
drawing = false;
const p = toLocal(e);
if (mode === "erase") { draft = null; draw(); return; }
if (!draft) return;
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.style.cursor = "";
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
};
if (apply) apply.onclick = () => {
global.TarinaiHistory?.capture?.(worldRef, "reciprocator-edit");
cleanSegments();
const angleDeg = Math.max(0, Math.min(355, Number(angleNum?.value || angle?.value || 0) || 0));
item.reciprocatorAxisAngle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angleDeg * Math.PI / 180, 0) : angleDeg * Math.PI / 180;
item.reciprocatorSpeed = Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0));
item.reciprocatorTravel = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150));
item.reciprocatorPowered = powered ? Boolean(powered.checked) : item.reciprocatorPowered !== false;
if (item.reciprocatorPowered) item.reciprocatorVelocity = 0;
item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
item.rotatorSegments = segments.map(seg => seg.slice(0, 4));
const sa = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0)));
const halfTravel = Math.max(12, item.reciprocatorTravel * 0.5);
item.reciprocatorAnchorX = item.x - Math.cos(sa) * halfTravel * (Number(item.reciprocatorPhase || 0) || 0);
item.reciprocatorAnchorY = item.y - Math.sin(sa) * halfTravel * (Number(item.reciprocatorPhase || 0) || 0);
const extent = worldRef?.rotatorExtent?.(item);
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
worldRef?.markSpatialDirty?.("reciprocator-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;
@ -380,15 +705,37 @@
foodServingScale: Number(item.foodServingScale || 1) || 1,
foodServingsMax: Number(item.foodServingsMax || 0) || 0,
foodServingsRemaining: Number(item.foodServingsRemaining || 0) || 0,
seed: Number.isFinite(Number(item.seed)) ? Number(item.seed) : null,
spin: Number.isFinite(Number(item.spin)) ? Number(item.spin) : 0,
zunchiVariant: item.zunchiVariant || "",
stage: item.stage || "",
freshness: Number.isFinite(Number(item.freshness)) ? Number(item.freshness) : null,
fertility: Number.isFinite(Number(item.fertility)) ? Number(item.fertility) : null,
grassStage: Number.isFinite(Number(item.grassStage)) ? Number(item.grassStage) : null,
growth: Number.isFinite(Number(item.growth)) ? Number(item.growth) : null,
health: Number.isFinite(Number(item.health)) ? Number(item.health) : null,
fuseTimer: Number.isFinite(Number(item.fuseTimer)) ? Number(item.fuseTimer) : null,
fuseMax: Number.isFinite(Number(item.fuseMax)) ? Number(item.fuseMax) : null,
};
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.rotatorPowered = item.rotatorPowered !== false;
data.rotatorSpeed = Number(item.rotatorSpeed || 0) || 0;
data.rotatorThickness = Number(item.rotatorThickness || 12) || 12;
data.rotatorSegments = cloneSegmentsForRotator(item);
}
if (item.type === "reciprocator") {
data.reciprocatorPowered = item.reciprocatorPowered !== false;
data.reciprocatorSpeed = Number(item.reciprocatorSpeed || 92) || 92;
data.reciprocatorTravel = Number(item.reciprocatorTravel || 150) || 150;
data.reciprocatorPhase = Number(item.reciprocatorPhase || 0) || 0;
data.reciprocatorDirection = Number(item.reciprocatorDirection || 1) || 1;
data.reciprocatorAxisAngle = Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : Number(item.angle || 0) || 0;
data.rotatorThickness = Number(item.rotatorThickness || 12) || 12;
data.rotatorSegments = cloneSegmentsForRotator(item);
}
return data;
}
@ -396,6 +743,23 @@
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 (Number.isFinite(Number(data.seed))) item.seed = Number(data.seed);
if (Number.isFinite(Number(data.spin))) item.spin = Number(data.spin);
if (item.type === "zunchi") {
if (data.zunchiVariant) item.zunchiVariant = String(data.zunchiVariant);
if (data.stage) item.stage = String(data.stage);
if (Number.isFinite(Number(data.freshness))) item.freshness = Number(data.freshness);
if (Number.isFinite(Number(data.fertility))) item.fertility = Number(data.fertility);
}
if (item.type === "grass") {
if (Number.isFinite(Number(data.grassStage))) item.grassStage = Number(data.grassStage);
if (Number.isFinite(Number(data.growth))) item.growth = Number(data.growth);
if (Number.isFinite(Number(data.health))) item.health = Number(data.health);
}
if (item.type === "firecracker") {
if (Number.isFinite(Number(data.fuseTimer))) item.fuseTimer = Number(data.fuseTimer);
if (Number.isFinite(Number(data.fuseMax))) item.fuseMax = Number(data.fuseMax);
}
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);
@ -405,11 +769,30 @@
if (item.roles) item.roles.food = Boolean(item.storedFoodType);
}
if (item.type === "rotator") {
item.rotatorPowered = data.rotatorPowered !== false;
item.rotatorSpeed = Number(data.rotatorSpeed || 0) || 0;
item.rotatorAngularVelocity = 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 (item.type === "reciprocator") {
item.reciprocatorPowered = data.reciprocatorPowered !== false;
item.reciprocatorSpeed = Math.max(0, Number(data.reciprocatorSpeed || 92) || 92);
item.reciprocatorTravel = Math.max(24, Number(data.reciprocatorTravel || 150) || 150);
item.reciprocatorPhase = Math.max(-1, Math.min(1, Number(data.reciprocatorPhase || 0) || 0));
item.reciprocatorDirection = Number(data.reciprocatorDirection || 1) || 1;
item.reciprocatorAxisAngle = Number.isFinite(Number(data.reciprocatorAxisAngle)) ? Number(data.reciprocatorAxisAngle) : (Number(item.reciprocatorAxisAngle || item.angle || 0) || 0);
item.reciprocatorVelocity = 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);
const sa = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0)));
const halfTravel = Math.max(12, item.reciprocatorTravel * 0.5);
item.reciprocatorAnchorX = item.x - Math.cos(sa) * halfTravel * item.reciprocatorPhase;
item.reciprocatorAnchorY = item.y - Math.sin(sa) * halfTravel * item.reciprocatorPhase;
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));
}
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) {
item.toolSize = data.toolSize || item.toolSize || "medium";
item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1;
@ -427,7 +810,7 @@
function isEditableItem(item) {
return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator");
return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator" || item.type === "reciprocator");
}
function findEditableItemAt(worldRef, x, y) {
@ -446,6 +829,7 @@
if (!isEditableItem(item)) return false;
if (item.type === "signboard") return openSignboardEditor(item, worldRef);
if (item.type === "rotator") return openRotatorEditor(item, worldRef);
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen;
item.gateLastToggleAt = worldRef?.time || 0;
@ -503,6 +887,7 @@
function clearOverlappingGrassBedsForPlacement(worldRef, item) {
if (!worldRef || !item || item.type === "grass_bed" || item.type === "trace" || item.type === "splat") return 0;
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type))) return 0;
const radius = Math.max(16, item.r || item.radius || 12);
let removed = 0;
for (const bed of worldRef.nearbyItems?.(item.x, item.y, radius + 72) || worldRef.items || []) {
@ -519,6 +904,25 @@
return removed;
}
function forceImmediateToolVisualRefresh(worldRef, reason = "tool-change") {
if (!worldRef) return;
worldRef.drawListDirty = true;
if (worldRef._renderStack) worldRef._renderStack.signature = "";
if (worldRef._visibleRenderStack) {
worldRef._visibleRenderStack.backItems = [];
worldRef._visibleRenderStack.layered = [];
worldRef._visibleRenderStack.carriedPlushies = [];
worldRef._visibleRenderStack.lodgedPins = [];
}
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.ensureSpatial?.(reason);
if (typeof global.render === "function") {
global.render();
global.requestAnimationFrame?.(() => global.render?.());
}
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
findDuplicatorAt(x, y) {
return findDuplicatorAt(this, x, y);
@ -533,6 +937,8 @@
const item = new Item(this.copyBuffer.type, x, y);
applyCopyBufferToItem(item, this.copyBuffer);
item.x = x; item.y = y;
item.world = this;
item.isCopyPreview = true;
return item;
},
@ -547,8 +953,12 @@
}
const item = this.copyPreviewItemAt?.(x, y);
if (item) {
item.isCopyPreview = false;
const placed = this.placeItem(item, false);
if (placed) {
placed.world = this;
if (placed.type === "grass" || placed.type === "trace" || placed.type === "splat") this.markTerrainDirtyAt?.(placed.x, placed.y, Math.max(placed.r || 24, 36), "copy-paste");
forceImmediateToolVisualRefresh(this, "copy-paste");
const label = toolLabel(item.type);
this.log?.(`${label}を貼り付けた。`, "observe");
showToast(`${label}を貼り付けました。`);
@ -574,51 +984,14 @@
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
if (!it || it.dead) continue;
for (const r of this.solidObstacleRects(it)) {
const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom;
if (!separated) return true;
if (global.TarinaiCollisionFootprints?.rectsOverlap?.(rect, r, margin)) return true;
}
}
return false;
},
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", "rotator", "nest_box", "water"]);
if (!important.has(item.type)) return false;
const placingGrass = item.type === "grass";
const itemRects = this.solidObstacleRects(item);
const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback);
const itemRadius = Math.max(14, item.r || radiusFor(item.type, 12) || 12);
const rectCircleOverlap = (rect, cx, cy, radius, margin = 0) => {
if (!rect) return false;
const px = clamp(cx, rect.left - margin, rect.right + margin);
const py = clamp(cy, rect.top - margin, rect.bottom + margin);
return distXY(cx, cy, px, py) < radius + margin;
};
const rectsOverlap = (a, b, margin = 0) => Boolean(a && b && !(a.right + margin < b.left || a.left - margin > b.right || a.bottom + margin < b.top || a.top - margin > b.bottom));
for (const other of this.nearbyItems(item.x, item.y, Math.max(132, itemRadius * 4))) {
if (!other || other === item || other.dead || !important.has(other.type)) continue;
// Grass is a ground layer: objects may be placed on top of existing grass.
// Grass placement itself still avoids objects and other grass via grassSpotOpen().
if (!placingGrass && other.type === "grass") continue;
const otherRects = this.solidObstacleRects(other);
const otherRadius = Math.max(14, other.r || radiusFor(other.type, 12) || 12);
const margin = Math.max(4, Math.min(itemRadius, otherRadius) * 0.18);
if (itemRects.length && otherRects.length) {
if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true;
continue;
}
if (itemRects.length) {
if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true;
continue;
}
if (otherRects.length) {
if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true;
continue;
}
if (distXY(item.x, item.y, other.x, other.y) < itemRadius + otherRadius + margin) return true;
}
return false;
return global.TarinaiCollisionFootprints?.itemPlacementOverlapBlocked?.(this, item) ?? false;
},
placementBlocked(item) {
@ -702,7 +1075,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" || item.type === "rotator";
const solid = this.isFenceType(item.type) || item.type === "nest_box" || item.type === "rotator" || item.type === "reciprocator";
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));
@ -735,9 +1108,18 @@
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type === "trace" || it.type === "splat") continue;
const hit = Math.max(24, (it.r || 12) * (it.type === "ball" ? 4.0 : 2.2));
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; }
let hit = false;
let d = distXY(x, y, it.x, it.y);
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type)) {
const result = global.TarinaiMechanicalSystem.hitTest(this, it, x, y, { padding: 16 });
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
} else {
const result = global.TarinaiCollisionFootprints?.hitTestItem?.(this, it, x, y, { radiusMultiplier: it.type === "ball" ? 4.0 : 2.2 }) || { hit: false, distance: d };
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
}
if (hit && d < foundItemD) { foundItem = it; foundItemD = d; }
}
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem;
if (foundT) {
@ -786,7 +1168,7 @@
handleClick(x, y) {
const tool = this.tool;
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "new"].includes(tool)) {
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "new"].includes(tool)) {
const editable = findEditableItemAt(this, x, y);
if (editable && openEditableItemEditor(editable, this)) {
global.TarinaiCommands?.setSelection?.(this, null) || (this.selected = null);
@ -810,6 +1192,26 @@
}
if (tool === "poke") {
let mechanismTarget = null;
let mechanismTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || !global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type)) continue;
const result = global.TarinaiMechanicalSystem.hitTest(this, it, x, y, { padding: 20 });
const extent = global.TarinaiMechanicalSystem.reach?.(it) || (it.r || 64);
const d = Math.min(distXY(x, y, it.x, it.y), result.distance ?? Infinity);
if ((result.hit || distXY(x, y, it.x, it.y) <= Math.max(46, extent + 22)) && d < mechanismTargetD) { mechanismTarget = it; mechanismTargetD = d; }
}
if (mechanismTarget) {
if (global.TarinaiMechanicalSystem?.applyPokeImpulse?.(mechanismTarget, x, y, this)) {
const label = mechanismTarget.type === "rotator" ? "回転体" : "往復体";
this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color: mechanismTarget.type === "rotator" ? "rgba(184,132,230,0.40)" : "rgba(90,150,214,0.40)" }));
this.drawListDirty = true;
audio.poke?.();
this.log(`動力OFFの${label}をつついて動かした。`, "observe", { eventType: "mechanism_poke", hiddenFromObservation: true });
return;
}
}
let ballTarget = null;
let ballTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
@ -841,18 +1243,21 @@
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;
}
// In placement mode, clicks always mean placement.
// Editing rotators / reciprocators is reserved for observe or non-placement tools.
if (itemType && this.directSetDuplicatorAt?.(x, y, itemType)) return;
if (itemType) {
let px = x, py = y;
const rawItem = this.applyToolSize(new Item(itemType, px, py));
if (typeof isRotatableItemType === "function" && isRotatableItemType(itemType)) rawItem.angle = this.toolAngleFor ? this.toolAngleFor(itemType) : (typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0);
if (itemType === "reciprocator") {
rawItem.reciprocatorAxisAngle = rawItem.angle;
rawItem.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0;
}
// Placement uses the same footprint that the preview validates.
const placed = this.placeItem(rawItem, true);
if (!placed) return;
forceImmediateToolVisualRefresh(this, "tool-place");
const label = toolLabel(itemType);
this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
}

View file

@ -4,6 +4,25 @@
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_tool_actions.js");
function forceImmediateToolVisualRefresh(worldRef, reason = "tool-action") {
if (!worldRef) return;
worldRef.drawListDirty = true;
if (worldRef._renderStack) worldRef._renderStack.signature = "";
if (worldRef._visibleRenderStack) {
worldRef._visibleRenderStack.backItems = [];
worldRef._visibleRenderStack.layered = [];
worldRef._visibleRenderStack.carriedPlushies = [];
worldRef._visibleRenderStack.lodgedPins = [];
}
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.ensureSpatial?.(reason);
if (typeof global.render === "function") {
global.render();
global.requestAnimationFrame?.(() => global.render?.());
}
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
findDeleteToolTargetAt(x, y) {
let best = null;
@ -11,10 +30,17 @@
for (let i = (this.items || []).length - 1; i >= 0; i -= 1) {
const it = this.items[i];
if (!it || it.dead) continue;
const visualHit = Math.max(22, (it.r || 12) * 2.0);
const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit;
const d = distXY(x, y, it.x, it.y);
if (d < hit && d < bestD) { best = it; bestD = d; }
let d = distXY(x, y, it.x, it.y);
if (it.type === "rope" || it.type === "rod") {
const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, this);
const b = runtime?.endpointWorld?.(it.linkB, this);
d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? d;
if (d < 18 && d < bestD) { best = it; bestD = d; }
continue;
}
const result = global.TarinaiCollisionFootprints?.hitTestItem?.(this, it, x, y, { padding: this.screenSizeToWorld ? this.screenSizeToWorld(12) : 12, radiusMultiplier: 2.0 }) || { hit: false, distance: d };
if (result.hit && result.distance < bestD) { best = it; bestD = result.distance; }
}
return best;
},
@ -42,7 +68,7 @@
return 138;
},
deleteItemsInArea(x, y) {
deleteItemsInArea(x, y, options = {}) {
const radius = this.areaDeleteRadius?.() || 138;
const candidates = [];
for (let i = (this.items || []).length - 1; i >= 0; i -= 1) {
@ -50,10 +76,19 @@
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);
let d = distXY(x, y, it.x, it.y);
if (it.type === "rope" || it.type === "rod") {
const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, this);
const b = runtime?.endpointWorld?.(it.linkB, this);
d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? d;
if (d <= radius + 8) candidates.push(it);
continue;
}
const result = global.TarinaiCollisionFootprints?.hitTestItem?.(this, it, x, y, { padding: radius, radiusMultiplier: 0.85 }) || { hit: d <= radius + hitR * 0.85 };
if (result.hit || d <= radius + hitR * 0.85) candidates.push(it);
}
if (!candidates.length) { showToast("範囲内に消せるものがありません。"); return true; }
if (!candidates.length) { if (!options.silentEmpty) showToast("範囲内に消せるものがありません。"); return true; }
let removedCount = 0;
const kindCounts = new Map();
for (const target of candidates) {
@ -68,22 +103,130 @@
const label = toolLabel(target.type);
kindCounts.set(label, (kindCounts.get(label) || 0) + 1);
}
if (!removedCount) { showToast("範囲内に消せるものがありません。"); return true; }
if (!removedCount) { if (!options.silentEmpty) 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}個消しました。`);
if (options.drag) this._areaDeleteDragRemoved = (this._areaDeleteDragRemoved || 0) + removedCount;
if (!options.silentLog) {
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;
},
linkPointLocalForItem(item, x, y, useClickPoint = false) {
const angle = typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
const dx = (Number(x || 0) || 0) - (Number(item?.x || 0) || 0);
const dy = (Number(y || 0) || 0) - (Number(item?.y || 0) || 0);
if (!useClickPoint) return { localX: 0, localY: 0 };
const c = Math.cos(-angle), s = Math.sin(-angle);
return { localX: dx * c - dy * s, localY: dx * s + dy * c };
},
findLinkEndpointAt(x, y) {
let best = null;
let bestD = Infinity;
for (let i = (this.tarinai || []).length - 1; i >= 0; i -= 1) {
const t = this.tarinai[i];
if (!t || t.dead || this.isTarinaiHiddenInNestBox?.(t)) continue;
const hit = Math.max(18, (t.radius || 18) * 1.12);
const d = distXY(x, y, t.x, t.y);
if (d <= hit && d < bestD) {
best = { kind: "tarinai", id: t.id, type: t.type || "tarinai", label: t.name || "たりない", x: t.x, y: t.y, localX: 0, localY: 0, center: true };
bestD = d;
}
}
for (let i = (this.items || []).length - 1; i >= 0; i -= 1) {
const it = this.items[i];
if (!it || it.dead) continue;
if ((this.tool === "rope" || this.tool === "rod") && (it.type === "water" || it.type === "grass_bed")) continue;
const isMechanism = Boolean(global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type));
const isFence = this.isFenceType?.(it.type) || false;
const isConstraint = it.type === "rope" || it.type === "rod";
let hit = false;
let d = distXY(x, y, it.x, it.y);
if (isConstraint) {
const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, this);
const b = runtime?.endpointWorld?.(it.linkB, this);
if (!a || !b) continue;
d = runtime?.pointSegmentDistance?.(x, y, a.x, a.y, b.x, b.y) ?? d;
if (d > 20 || d >= bestD) continue;
const vx = b.x - a.x, vy = b.y - a.y;
const len2 = vx * vx + vy * vy || 1;
const attachT = Math.max(0, Math.min(1, ((x - a.x) * vx + (y - a.y) * vy) / len2));
const px = a.x + vx * attachT;
const py = a.y + vy * attachT;
best = { kind: "item", id: it.id, type: it.type, label: toolLabel(it.type), x: px, y: py, localX: 0, localY: 0, center: false, attachT };
bestD = d;
continue;
}
if (isMechanism || isFence) {
const result = global.TarinaiCollisionFootprints?.hitTestItem?.(this, it, x, y, { padding: 18, radiusMultiplier: 1.28 }) || { hit: false, distance: d };
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
} else {
const result = global.TarinaiCollisionFootprints?.hitTestItem?.(this, it, x, y, { radiusMultiplier: 1.35 }) || { hit: false, distance: d };
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
}
if (!hit || d >= bestD) continue;
const local = this.linkPointLocalForItem?.(it, x, y, isMechanism || isFence) || { localX: 0, localY: 0 };
best = { kind: "item", id: it.id, type: it.type, label: toolLabel(it.type), x: isMechanism || isFence ? x : it.x, y: isMechanism || isFence ? y : it.y, localX: local.localX, localY: local.localY, center: !(isMechanism || isFence) };
bestD = d;
}
return best;
},
useLinkToolAt(x, y) {
const endpoint = this.findLinkEndpointAt?.(x, y);
const toolName = this.tool === "rod" ? "棒" : "ヒモ";
if (!endpoint) { showToast(`${toolName}をつなぐ対象がありません。`); return true; }
if (!this.pendingLinkEndpoint) {
this.pendingLinkEndpoint = endpoint;
showToast(`${endpoint.label || "対象"}${toolName}の片端をつなぎました。もう片端を選んでください。`);
this.drawListDirty = true;
if (typeof global.render === "function") global.render();
return true;
}
const first = this.pendingLinkEndpoint;
if (first.kind === endpoint.kind && first.id === endpoint.id && distXY(first.x, first.y, endpoint.x, endpoint.y) < 8) {
showToast("別の位置または対象を選んでください。");
return true;
}
const runtime = global.TarinaiLinkRuntime;
const p1 = runtime?.endpointWorld?.(first, this) || { x: first.x, y: first.y };
const p2 = runtime?.endpointWorld?.(endpoint, this) || { x: endpoint.x, y: endpoint.y };
const constraintType = this.tool === "rod" ? "rod" : "rope";
const item = new Item(constraintType, (p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5);
item.world = this;
item.linkA = { ...first, x: p1.x, y: p1.y };
item.linkB = { ...endpoint, x: p2.x, y: p2.y };
item.linkLength = Math.max(24, distXY(p1.x, p1.y, p2.x, p2.y));
item.linkMidX = (p1.x + p2.x) * 0.5;
item.linkMidY = (p1.y + p2.y) * 0.5;
item.linkMidVX = 0;
item.linkMidVY = 0;
item.r = Math.max(18, item.linkLength * 0.5);
item.amount = 999;
const placed = this.addItem?.(item, constraintType);
this.pendingLinkEndpoint = null;
if (!placed) { showToast(`${toolName}を追加できませんでした。`); return true; }
forceImmediateToolVisualRefresh(this, `${constraintType}-create`);
this.log?.(`${toolName}をつないだ。`, "observe");
showToast(`${toolName}をつなぎました。`);
return true;
},
useToolAt(x, y) {
if (this.tool === "delete") return this.deleteItemAt(x, y);
if (this.tool === "area_delete") return this.deleteItemsInArea(x, y);
if (this.tool === "rope" || this.tool === "rod") return this.useLinkToolAt(x, y);
return false;
},
}));

View file

@ -178,22 +178,18 @@
for (const t of this.tarinai || []) {
if (!t || t.dead) continue;
if (t.state === "sunbath" || (t.sunbathTimer || 0) > 0.04) {
const fallbackX = Number.isFinite(t.sunbathAnchorX) ? t.sunbathAnchorX : (Number.isFinite(t._lastSunbathDrawX) ? t._lastSunbathDrawX : (Number.isFinite(t._lastValidX) ? t._lastValidX : pad));
const fallbackY = Number.isFinite(t.sunbathAnchorY) ? t.sunbathAnchorY : (Number.isFinite(t._lastSunbathDrawY) ? t._lastSunbathDrawY : (Number.isFinite(t._lastValidY) ? t._lastValidY : pad));
t.sunbathAnchorX = clamp(fallbackX, pad, safeW - pad);
t.sunbathAnchorY = clamp(fallbackY, pad, safeH - pad);
if (!Number.isFinite(t.x)) t.x = t.sunbathAnchorX;
if (!Number.isFinite(t.y)) t.y = t.sunbathAnchorY;
if (!Number.isFinite(t.x)) t.x = Number.isFinite(t.sunbathAnchorX) ? t.sunbathAnchorX : pad;
if (!Number.isFinite(t.y)) t.y = Number.isFinite(t.sunbathAnchorY) ? t.sunbathAnchorY : pad;
t.sunbathAnchorX = clamp(t.x, pad, safeW - pad);
t.sunbathAnchorY = clamp(t.y, pad, safeH - pad);
}
const result = repairPoint(t, t.radius || 22);
if (result === "missing" || result === "moved") repairTarinai(t);
if (t.state === "sunbath" || (t.sunbathTimer || 0) > 0.04) {
t.sunbathAnchorX = clamp(Number.isFinite(t.sunbathAnchorX) ? t.sunbathAnchorX : t.x, pad, safeW - pad);
t.sunbathAnchorY = clamp(Number.isFinite(t.sunbathAnchorY) ? t.sunbathAnchorY : t.y, pad, safeH - pad);
t.sunbathAnchorX = clamp(Number.isFinite(t.x) ? t.x : (Number.isFinite(t.sunbathAnchorX) ? t.sunbathAnchorX : pad), pad, safeW - pad);
t.sunbathAnchorY = clamp(Number.isFinite(t.y) ? t.y : (Number.isFinite(t.sunbathAnchorY) ? t.sunbathAnchorY : pad), pad, safeH - pad);
t.x = t.sunbathAnchorX;
t.y = t.sunbathAnchorY;
t.vx = 0;
t.vy = 0;
t._lastSunbathDrawX = t.x;
t._lastSunbathDrawY = t.y;
t._lastValidX = t.x;
@ -227,6 +223,7 @@
},
updateWeather(dt) {
if (window.TarinaiWeatherSystem?.enforceFixedWeather?.(this)) return;
this.nextWeatherChange -= dt;
if (this.nextWeatherChange > 0) return;
if (window.TarinaiWeatherSystem?.applyNextWeather) {

View file

@ -1,7 +1,7 @@
"use strict";
// Layer: simulation/phase-facade
// Backward-compatible phase names. Concrete update work lives in
// Phase names facade. Concrete update work lives in
// js/simulation_systems.js so system_order.js can evolve without World owning
// the phase implementation details.
(function (global) {

View file

@ -145,6 +145,7 @@
const previous = this.groundType || "soil";
this.groundType = next;
if (next !== previous || opts.force) {
window.TarinaiWeatherSystem?.enforceFixedWeather?.(this);
this.markTerrainDirty?.("ground-type");
this.enforceGrassLimit?.("ground-type");
this.updateItemCounts?.("ground-type");
@ -180,7 +181,7 @@
},
isFenceType(type = "") {
return type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence";
return typeof isFenceItemType === "function" ? isFenceItemType(type) : ["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"].includes(String(type || ""));
},
toolAngleFor(type = "") {

View file

@ -379,7 +379,7 @@ def check_current_save_format() -> None:
save_storage = read_text(ROOT / "js/save_storage.js")
restore_coordinator = read_text(ROOT / "js/restore_coordinator.js")
required_snapshot = [
"const SNAPSHOT_VERSION = 5",
"const SNAPSHOT_VERSION = SaveSchema.SNAPSHOT_VERSION",
"a: \"tj1\"",
"function compactWorld",
"function compactTarinai",
@ -444,12 +444,12 @@ def check_current_save_format() -> None:
fail(f"save backward compatibility residue still present: {token}")
if "renderStats" in snapshot or "TarinaiFreezeSystem" in snapshot or "renderArchive" in snapshot:
fail("snapshot restore must not call UI or freeze subsystems directly")
freeze = read_text(ROOT / "js/freeze_system.js")
if "global.renderStats" in freeze or "global.render?.()" in freeze or "renderFrozenPanel(worldRef);" in freeze:
fail("freeze core still performs direct UI rendering instead of emitting freeze:changed")
effects = read_text(ROOT / "js/world_event_effects.js")
if "freeze:changed" not in effects or "tool:placed" not in effects or "function playLogSoundForEntry" not in effects:
fail("event effects do not own freeze/tool/log side effects")
if "tool:placed" not in effects or "function playLogSoundForEntry" not in effects:
fail("event effects do not own tool/log side effects")
for rel in ("js/freeze_system.js", "js/freeze_store.js", "js/freeze_snapshot_adapter.js", "js/freeze_panel.js"):
if (ROOT / rel).exists():
fail(f"freezer subsystem file still exists after freezer removal: {rel}")
placement = read_text(ROOT / "js/world_placement_log.js")
if "playLogSoundForEntry" in placement:
fail("log sound mapping still lives in world_placement_log.js")
@ -866,7 +866,6 @@ def check_tarinai_update_pipeline_boundary() -> None:
"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",
@ -879,7 +878,6 @@ def check_tarinai_update_pipeline_boundary() -> None:
"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:
@ -897,14 +895,12 @@ def check_tarinai_update_pipeline_boundary() -> None:
"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",
]
@ -914,7 +910,6 @@ def check_tarinai_update_pipeline_boundary() -> None:
"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",
@ -927,32 +922,27 @@ def check_tarinai_update_pipeline_boundary() -> None:
"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")
if "TarinaiUpdatePipeline?.updateOne" not in runtime and "TarinaiUpdatePipeline.updateOne" not in runtime:
fail("Tarinai runtime does not route through TarinaiUpdatePipeline")
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 "TarinaiUpdatePipeline?.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")
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")
@ -970,10 +960,10 @@ def check_tarinai_update_pipeline_boundary() -> None:
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 "TarinaiNeedPlannerSystem?.updateOne" not in needs_items or "function resolveNeedsCore" not in needs_items:
fail("Tarinai resolveNeeds is not routed through planner system")
if "TarinaiNeedPlannerCoreSystem" not in needs_items:
fail("Tarinai need planner core 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"):
@ -984,7 +974,7 @@ def check_tarinai_update_pipeline_boundary() -> None:
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"]:
for token in ["TarinaiFrameUpdateStep", "TarinaiAiUpdateStep", "TarinaiEnvironmentUpdateStep", "TarinaiMovementUpdateStep", "TarinaiHealthUpdateStep"]:
if token not in pipeline:
fail(f"Tarinai update pipeline missing step token: {token}")
js = f"""
@ -1078,7 +1068,6 @@ def check_item_lifecycle_pipeline_boundary() -> None:
]
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",
@ -1086,7 +1075,6 @@ def check_item_lifecycle_pipeline_boundary() -> None:
"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:
@ -1094,7 +1082,6 @@ def check_item_lifecycle_pipeline_boundary() -> None:
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,
@ -1104,7 +1091,6 @@ def check_item_lifecycle_pipeline_boundary() -> None:
"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",
]
@ -1112,7 +1098,6 @@ def check_item_lifecycle_pipeline_boundary() -> None:
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",
@ -1125,17 +1110,16 @@ def check_item_lifecycle_pipeline_boundary() -> None:
"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")
if "TarinaiItemLifecyclePipeline?.updateOne" not in runtime and "TarinaiItemLifecyclePipeline.updateOne" not in runtime:
fail("item runtime does not route through TarinaiItemLifecyclePipeline")
pipeline = read_text(ROOT / "js/item_lifecycle_pipeline.js")
for token in ["TarinaiItemFrameLifecycleStep", "TarinaiItemDecayLifecycleStep", "TarinaiItemDynamicLifecycleStep", "TarinaiItemGrowthLifecycleStep", "TarinaiItemLegacyLifecycleStep"]:
for token in ["TarinaiItemFrameLifecycleStep", "TarinaiItemDecayLifecycleStep", "TarinaiItemDynamicLifecycleStep", "TarinaiItemGrowthLifecycleStep"]:
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")
@ -1147,7 +1131,7 @@ def check_item_lifecycle_pipeline_boundary() -> None:
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"]:
for token in ["item.updateFan", "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")
@ -1158,12 +1142,12 @@ def check_item_lifecycle_pipeline_boundary() -> None:
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"]:
for helper_token in ["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"]:
for token in ["updateBall(dt, worldRef) { return window.TarinaiItemDynamicBallSystem", "attachPushpin(t, worldRef"]:
if token not in lifecycle_runtime:
fail(f"prototype compatibility facade missing: {token}")
fail(f"prototype subsystem facade missing: {token}")
js = f"""
const vm = require('vm');
const fs = require('fs');

View file

@ -1,11 +1,11 @@
"use strict";
const APP_VERSION = "16.07.50";
const APP_VERSION = "38.00.00";
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_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"
"version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "collision_footprint_system", "mechanical_system", "constraint_system", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "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_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_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_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", "history_system", "save_codec", "save_storage", "save_system", "patch_helpers", "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 = [
"./",