From 077d4ab9ac9570c21a8ea48ed22371038ffa1e97 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 26 Jun 2026 19:04:32 +0900 Subject: [PATCH] stable --- app_manifest.json | 16 +- css/base.css | 39 +- css/components.css | 49 +++ css/layout.css | 21 + index.html | 284 +++++++------- js/collision_footprint_system.js | 238 ++++++++++++ js/command_dispatcher.js | 11 + js/constraint_system.js | 294 ++++++++++++++ js/freeze_system.js | 52 ++- js/ground_types.js | 8 +- js/history_system.js | 91 +++++ js/item_dynamic_ball_system.js | 10 +- js/item_dynamic_duplicator_system.js | 2 +- js/item_dynamic_pin_system.js | 2 +- js/item_dynamic_system.js | 5 +- js/item_dynamic_tool_system.js | 115 ++---- js/item_dynamic_zunchi_system.js | 22 +- js/item_lifecycle_legacy_system.js | 1 - js/item_lifecycle_pipeline.js | 25 +- js/item_lifecycle_runtime.js | 9 +- js/item_lifecycle_support.js | 87 +---- js/item_registry.js | 38 +- js/item_render_runtime.js | 278 +++++++------ js/item_runtime.js | 36 +- js/item_type_initializers.js | 36 +- js/item_update_policy.js | 4 +- js/items.js | 7 +- js/mechanical_system.js | 499 ++++++++++++++++++++++++ js/physics_helpers.js | 21 +- js/render.js | 164 ++++---- js/save_codec.js | 24 +- js/save_schema.js | 22 +- js/simulation_systems.js | 2 +- js/snapshot_system.js | 64 ++- js/structure_lifecycle.js | 3 +- js/structures.js | 22 +- js/system_order.js | 2 +- js/tarinai_action_planner_system.js | 4 +- js/tarinai_building_behavior.js | 10 +- js/tarinai_cursor_care_system.js | 6 +- js/tarinai_identity_social.js | 4 +- js/tarinai_item_interaction_system.js | 2 +- js/tarinai_local_environment_system.js | 6 +- js/tarinai_need_planner_system.js | 2 +- js/tarinai_needs_items.js | 23 +- js/tarinai_render.js | 12 +- js/tarinai_runtime.js | 36 +- js/tarinai_social_move_life.js | 35 +- js/tarinai_sunbath_system.js | 8 +- js/tarinai_update_pipeline.js | 30 +- js/tarinai_update_step_ai.js | 2 +- js/tarinai_update_step_frame.js | 2 +- js/tarinai_update_step_movement.js | 41 +- js/ui_bind.js | 13 +- js/ui_input_mouse.js | 27 +- js/ui_input_shared.js | 87 ++++- js/ui_input_touch.js | 19 +- js/ui_layout_dialogs.js | 1 + js/ui_tools.js | 3 +- js/version.js | 4 +- js/weather_system.js | 19 + js/world_combat_effects.js | 55 ++- js/world_environment.js | 145 +++---- js/world_event_effects.js | 19 - js/world_placement_log.js | 515 ++++++++++++++++++++++--- js/world_tool_actions.js | 167 +++++++- js/world_update.js | 17 +- js/world_update_phases.js | 2 +- js/world_view.js | 3 +- scripts/regression_check.py | 60 ++- service-worker.js | 4 +- 71 files changed, 2914 insertions(+), 1072 deletions(-) create mode 100644 js/collision_footprint_system.js create mode 100644 js/constraint_system.js create mode 100644 js/history_system.js create mode 100644 js/mechanical_system.js diff --git a/app_manifest.json b/app_manifest.json index 3f31e31..b498e50 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -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." } diff --git a/css/base.css b/css/base.css index c40b8bc..5a72a27 100644 --- a/css/base.css +++ b/css/base.css @@ -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"); } diff --git a/css/components.css b/css/components.css index ed6227d..d5fdc33 100644 --- a/css/components.css +++ b/css/components.css @@ -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; } +} diff --git a/css/layout.css b/css/layout.css index 625d2de..c7b4fec 100644 --- a/css/layout.css +++ b/css/layout.css @@ -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)); } diff --git a/index.html b/index.html index 2d2469a..2fe75ae 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -189,144 +189,140 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js new file mode 100644 index 0000000..5a620a5 --- /dev/null +++ b/js/collision_footprint_system.js @@ -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); diff --git a/js/command_dispatcher.js b/js/command_dispatcher.js index a82eb57..0c1e509 100644 --- a/js/command_dispatcher.js +++ b/js/command_dispatcher.js @@ -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); diff --git a/js/constraint_system.js b/js/constraint_system.js new file mode 100644 index 0000000..df4f2fc --- /dev/null +++ b/js/constraint_system.js @@ -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); diff --git a/js/freeze_system.js b/js/freeze_system.js index 9e31848..bb9b905 100644 --- a/js/freeze_system.js +++ b/js/freeze_system.js @@ -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 }); } diff --git a/js/ground_types.js b/js/ground_types.js index e82896a..5ff09d7 100644 --- a/js/ground_types.js +++ b/js/ground_types.js @@ -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; }, }); diff --git a/js/history_system.js b/js/history_system.js new file mode 100644 index 0000000..52c5c96 --- /dev/null +++ b/js/history_system.js @@ -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); diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index c123958..cf1b2c5 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -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); diff --git a/js/item_dynamic_duplicator_system.js b/js/item_dynamic_duplicator_system.js index a167eb1..000c0cd 100644 --- a/js/item_dynamic_duplicator_system.js +++ b/js/item_dynamic_duplicator_system.js @@ -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 || {}; diff --git a/js/item_dynamic_pin_system.js b/js/item_dynamic_pin_system.js index 1d07a7c..f8ea8b1 100644 --- a/js/item_dynamic_pin_system.js +++ b/js/item_dynamic_pin_system.js @@ -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)); diff --git a/js/item_dynamic_system.js b/js/item_dynamic_system.js index 4956d62..b2272ef 100644 --- a/js/item_dynamic_system.js +++ b/js/item_dynamic_system.js @@ -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; diff --git a/js/item_dynamic_tool_system.js b/js/item_dynamic_tool_system.js index 3980579..d80e10b 100644 --- a/js/item_dynamic_tool_system.js +++ b/js/item_dynamic_tool_system.js @@ -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); diff --git a/js/item_dynamic_zunchi_system.js b/js/item_dynamic_zunchi_system.js index c35a31b..6ec53d4 100644 --- a/js/item_dynamic_zunchi_system.js +++ b/js/item_dynamic_zunchi_system.js @@ -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; diff --git a/js/item_lifecycle_legacy_system.js b/js/item_lifecycle_legacy_system.js index 81c78dd..627eb7b 100644 --- a/js/item_lifecycle_legacy_system.js +++ b/js/item_lifecycle_legacy_system.js @@ -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); diff --git a/js/item_lifecycle_pipeline.js b/js/item_lifecycle_pipeline.js index b2bad93..d2c1809 100644 --- a/js/item_lifecycle_pipeline.js +++ b/js/item_lifecycle_pipeline.js @@ -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, diff --git a/js/item_lifecycle_runtime.js b/js/item_lifecycle_runtime.js index 19b1b69..629e87e 100644 --- a/js/item_lifecycle_runtime.js +++ b/js/item_lifecycle_runtime.js @@ -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); }, diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js index 2759982..e5b64a7 100644 --- a/js/item_lifecycle_support.js +++ b/js/item_lifecycle_support.js @@ -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, diff --git a/js/item_registry.js b/js/item_registry.js index 7e35632..6088c38 100644 --- a/js/item_registry.js +++ b/js/item_registry.js @@ -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; diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index 75f2c2d..fc2b5d1 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -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"); diff --git a/js/item_runtime.js b/js/item_runtime.js index 29ad5a8..c564796 100644 --- a/js/item_runtime.js +++ b/js/item_runtime.js @@ -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); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index baa9a2d..810f216 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -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"; diff --git a/js/item_update_policy.js b/js/item_update_policy.js index 3c4ecfa..ccdb0b1 100644 --- a/js/item_update_policy.js +++ b/js/item_update_policy.js @@ -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) { diff --git a/js/items.js b/js/items.js index 96dcefa..5543685 100644 --- a/js/items.js +++ b/js/items.js @@ -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); diff --git a/js/mechanical_system.js b/js/mechanical_system.js new file mode 100644 index 0000000..839dbf3 --- /dev/null +++ b/js/mechanical_system.js @@ -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); diff --git a/js/physics_helpers.js b/js/physics_helpers.js index 14868b5..98d5ba5 100644 --- a/js/physics_helpers.js +++ b/js/physics_helpers.js @@ -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; } diff --git a/js/render.js b/js/render.js index 6fec8a2..37ab3b2 100644 --- a/js/render.js +++ b/js/render.js @@ -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) { diff --git a/js/save_codec.js b/js/save_codec.js index 369deb7..e960c89 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -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"); diff --git a/js/save_schema.js b/js/save_schema.js index 3ff1534..2784d13 100644 --- a/js/save_schema.js +++ b/js/save_schema.js @@ -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, diff --git a/js/simulation_systems.js b/js/simulation_systems.js index f7cc536..79828d3 100644 --- a/js/simulation_systems.js +++ b/js/simulation_systems.js @@ -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]; diff --git a/js/snapshot_system.js b/js/snapshot_system.js index f22031c..3c1ed9c 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -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") { diff --git a/js/structure_lifecycle.js b/js/structure_lifecycle.js index 688a8f0..82f8a47 100644 --- a/js/structure_lifecycle.js +++ b/js/structure_lifecycle.js @@ -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; } diff --git a/js/structures.js b/js/structures.js index 390e8e0..bbe796d 100644 --- a/js/structures.js +++ b/js/structures.js @@ -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; }, diff --git a/js/system_order.js b/js/system_order.js index 0f6c70e..286579b 100644 --- a/js/system_order.js +++ b/js/system_order.js @@ -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; diff --git a/js/tarinai_action_planner_system.js b/js/tarinai_action_planner_system.js index 7650eb9..246223d 100644 --- a/js/tarinai_action_planner_system.js +++ b/js/tarinai_action_planner_system.js @@ -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) { diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js index 0b6cefc..d65131b 100644 --- a/js/tarinai_building_behavior.js +++ b/js/tarinai_building_behavior.js @@ -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); diff --git a/js/tarinai_cursor_care_system.js b/js/tarinai_cursor_care_system.js index bbae850..66299cd 100644 --- a/js/tarinai_cursor_care_system.js +++ b/js/tarinai_cursor_care_system.js @@ -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); diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index e5d681c..d955a54 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -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; diff --git a/js/tarinai_item_interaction_system.js b/js/tarinai_item_interaction_system.js index 9b2cb48..abf5d9b 100644 --- a/js/tarinai_item_interaction_system.js +++ b/js/tarinai_item_interaction_system.js @@ -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. diff --git a/js/tarinai_local_environment_system.js b/js/tarinai_local_environment_system.js index 759be35..0e82ffb 100644 --- a/js/tarinai_local_environment_system.js +++ b/js/tarinai_local_environment_system.js @@ -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); diff --git a/js/tarinai_need_planner_system.js b/js/tarinai_need_planner_system.js index 744cf6b..b514b81 100644 --- a/js/tarinai_need_planner_system.js +++ b/js/tarinai_need_planner_system.js @@ -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) { diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index ca6a452..9ae76a3 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -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) { diff --git a/js/tarinai_render.js b/js/tarinai_render.js index fab1132..269c68e 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -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; diff --git a/js/tarinai_runtime.js b/js/tarinai_runtime.js index a4b07e2..0255f9c 100644 --- a/js/tarinai_runtime.js +++ b/js/tarinai_runtime.js @@ -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); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 4324aac..987ea28 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -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) { diff --git a/js/tarinai_sunbath_system.js b/js/tarinai_sunbath_system.js index 38575ab..3712db2 100644 --- a/js/tarinai_sunbath_system.js +++ b/js/tarinai_sunbath_system.js @@ -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); diff --git a/js/tarinai_update_pipeline.js b/js/tarinai_update_pipeline.js index 47bb8a6..10dea01 100644 --- a/js/tarinai_update_pipeline.js +++ b/js/tarinai_update_pipeline.js @@ -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, diff --git a/js/tarinai_update_step_ai.js b/js/tarinai_update_step_ai.js index 5e68921..3fe2c3a 100644 --- a/js/tarinai_update_step_ai.js +++ b/js/tarinai_update_step_ai.js @@ -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 }; diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js index 5ad3e27..d949d29 100644 --- a/js/tarinai_update_step_frame.js +++ b/js/tarinai_update_step_frame.js @@ -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 = {}) { diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index cd5030c..42304af 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -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); diff --git a/js/ui_bind.js b/js/ui_bind.js index 00d19c1..eda439e 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -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); }); diff --git a/js/ui_input_mouse.js b/js/ui_input_mouse.js index 35b181f..ff92473 100644 --- a/js/ui_input_mouse.js +++ b/js/ui_input_mouse.js @@ -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; diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 918fdbc..9930008 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -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, diff --git a/js/ui_input_touch.js b/js/ui_input_touch.js index 9cda290..16f6d56 100644 --- a/js/ui_input_touch.js +++ b/js/ui_input_touch.js @@ -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) { diff --git a/js/ui_layout_dialogs.js b/js/ui_layout_dialogs.js index b7aa9e6..4b2cdc5 100644 --- a/js/ui_layout_dialogs.js +++ b/js/ui_layout_dialogs.js @@ -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); } diff --git a/js/ui_tools.js b/js/ui_tools.js index 2821f67..6270a29 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -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; diff --git a/js/version.js b/js/version.js index 9ba662a..36e9222 100644 --- a/js/version.js +++ b/js/version.js @@ -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}`; diff --git a/js/weather_system.js b/js/weather_system.js index 360421c..a3e6949 100644 --- a/js/weather_system.js +++ b/js/weather_system.js @@ -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; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 91fdecb..01dc0a4 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -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 }, diff --git a/js/world_environment.js b/js/world_environment.js index cbe65da..244ad49 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -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; diff --git a/js/world_event_effects.js b/js/world_event_effects.js index 2cc50e9..2895fac 100644 --- a/js/world_event_effects.js +++ b/js/world_event_effects.js @@ -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); diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 30c2e13..7669abd 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -163,10 +163,12 @@
+
-

中央が回転軸。ドラッグで輪郭を描く。大きい図形はキャンバス端まで使用可能。

+

中央が回転軸。直線・自由描画・消しゴムを選び、ドラッグで輪郭を編集。

@@ -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 = ` + `; + 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`); } diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js index 7ec623f..254aad5 100644 --- a/js/world_tool_actions.js +++ b/js/world_tool_actions.js @@ -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; }, })); diff --git a/js/world_update.js b/js/world_update.js index 3a440ed..e83cffa 100644 --- a/js/world_update.js +++ b/js/world_update.js @@ -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) { diff --git a/js/world_update_phases.js b/js/world_update_phases.js index ebdcebe..527c704 100644 --- a/js/world_update_phases.js +++ b/js/world_update_phases.js @@ -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) { diff --git a/js/world_view.js b/js/world_view.js index cfcd498..fac84ea 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -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 = "") { diff --git a/scripts/regression_check.py b/scripts/regression_check.py index 37ea021..74c2829 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -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'); diff --git a/service-worker.js b/service-worker.js index 6d47b7e..4c2783a 100644 --- a/service-worker.js +++ b/service-worker.js @@ -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 = [ "./",