diff --git a/app_manifest.json b/app_manifest.json index 3a4de5f..3f31e31 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "16.07.32", + "version": "16.07.50", "css": [ "css/base.css", "css/layout.css", @@ -28,7 +28,25 @@ "js/structures.js", "js/items.js", "js/item_type_initializers.js", + "js/item_lifecycle_support.js", + "js/item_lifecycle_legacy_system.js", "js/item_lifecycle_runtime.js", + "js/item_update_policy.js", + "js/item_dynamic_tool_system.js", + "js/item_dynamic_ball_system.js", + "js/item_dynamic_duplicator_system.js", + "js/item_dynamic_pin_system.js", + "js/item_dynamic_zunchi_system.js", + "js/item_dynamic_system.js", + "js/item_lifecycle_decay_system.js", + "js/item_lifecycle_growth_system.js", + "js/item_lifecycle_step_frame.js", + "js/item_lifecycle_step_decay.js", + "js/item_lifecycle_step_dynamic.js", + "js/item_lifecycle_step_growth.js", + "js/item_lifecycle_step_legacy.js", + "js/item_lifecycle_pipeline.js", + "js/item_runtime.js", "js/structure_lifecycle.js", "js/item_render_runtime.js", "js/ants.js", @@ -49,7 +67,26 @@ "js/tarinai_building_behavior.js", "js/tarinai_action_definitions.js", "js/tarinai_needs_items.js", + "js/tarinai_forced_action_planner_system.js", + "js/tarinai_action_planner_system.js", + "js/tarinai_need_planner_system.js", + "js/tarinai_item_interaction_context.js", + "js/tarinai_food_interaction_system.js", + "js/tarinai_contact_item_system.js", + "js/tarinai_item_interaction_system.js", + "js/tarinai_sunbath_system.js", + "js/tarinai_cursor_care_system.js", + "js/tarinai_local_environment_system.js", + "js/tarinai_update_legacy_system.js", "js/tarinai_social_move_life.js", + "js/tarinai_update_step_frame.js", + "js/tarinai_update_step_ai.js", + "js/tarinai_update_step_environment.js", + "js/tarinai_update_step_movement.js", + "js/tarinai_update_step_health.js", + "js/tarinai_update_step_legacy.js", + "js/tarinai_update_pipeline.js", + "js/tarinai_runtime.js", "js/tarinai_render.js", "js/world.js", "js/world_view.js", @@ -61,16 +98,29 @@ "js/world_ants_system.js", "js/weather_system.js", "js/item_update_scheduler.js", + "js/simulation_runtime_helpers.js", + "js/tarinai_update_policy.js", + "js/simulation_environment_system.js", + "js/simulation_item_ant_system.js", + "js/simulation_effects_system.js", + "js/simulation_creature_system.js", + "js/simulation_maintenance_system.js", + "js/simulation_ambient_system.js", + "js/simulation_systems.js", "js/world_update_phases.js", + "js/system_order.js", + "js/simulation.js", "js/world_update.js", "js/world_tool_actions.js", "js/world_placement_log.js", "js/world_event_effects.js", + "js/command_dispatcher.js", "js/text_catalog.js", "js/ui.js", "js/ui_log.js", "js/ui_helpers.js", "js/ui_selected.js", + "js/save_schema.js", "js/snapshot_system.js", "js/restore_coordinator.js", "js/save_codec.js", @@ -106,5 +156,5 @@ "assets/sounds/*.mp3", "assets/objects/*.png" ], - "notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list. Item definitions are centralized in js/item_registry.js. Save compatibility migration is intentionally not retained. Snapshot restore is data-only; save and freeze are split into coordinator/codec/storage/panel/adapter modules. EventBus owns phase audio and log UI/audio notification side effects. World.update is phase-driven through js/world_update_phases.js; UI field input is split into shared/touch/mouse modules. Item construction, lifecycle, rendering, Tarinai targeting, ActionSpec definitions, consumable behavior, social action ticks, and building behavior are split into dedicated modules. Lightweight cleanup: item type constructor initialization is isolated; action selection bonus logic is shared; diagnostics no longer use removed performance-quality fields. Ground definitions, structure lifecycle, delete-tool actions, shared tooltips, and ground UI helpers are split into dedicated modules. FPS work: profiler, static/dynamic spatial buckets, item update scheduler, visible-cell rendering, chunked terrain cache, adaptive DPR, and render quality LOD are included." + "notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list. Item definitions are centralized in js/item_registry.js. Save compatibility migration is intentionally not retained. Snapshot restore is data-only; save and freeze are split into coordinator/codec/storage/panel/adapter modules. EventBus owns phase audio and log UI/audio notification side effects. World.update is phase-driven through js/world_update_phases.js; UI field input is split into shared/touch/mouse modules. Item construction, lifecycle, rendering, Tarinai targeting, ActionSpec definitions, consumable behavior, social action ticks, and building behavior are split into dedicated modules. Lightweight cleanup: item type constructor initialization is isolated; action selection bonus logic is shared; diagnostics no longer use removed performance-quality fields. Ground definitions, structure lifecycle, delete-tool actions, shared tooltips, and ground UI helpers are split into dedicated modules. FPS work: profiler, static/dynamic spatial buckets, item update scheduler, visible-cell rendering, chunked terrain cache, adaptive DPR, and render quality LOD are included. Boundary cleanup v1: save schema constants are centralized in js/save_schema.js and World.update now routes through js/simulation.js before the phase runner. Boundary cleanup v2: basic UI tool and selection changes route through js/command_dispatcher.js. Boundary cleanup v3: UI pointer, focus, favorite, pause, speed, and tool-size changes route through command_dispatcher; simulation order is named in js/system_order.js. Boundary cleanup v4: concrete world update phase work is extracted into js/simulation_systems.js; world_update_phases.js is now a compatibility facade. Boundary cleanup v5: simulation_systems.js is reduced to a facade; runtime helpers, environment, item/ant, effects, creature, maintenance, and ambient spawn systems are split into dedicated files. Boundary cleanup v6: item and Tarinai update entrypoints now route through js/item_runtime.js and js/tarinai_runtime.js before the legacy lifecycle implementations. Boundary cleanup v7: item update cadence is centralized in js/item_update_policy.js; Tarinai update cadence is centralized in js/tarinai_update_policy.js; scheduler and simulation helpers now consume those policies. Boundary cleanup v8: Tarinai update now routes through js/tarinai_update_pipeline.js with frame, AI, environment, movement, health, and legacy fallback steps. Boundary cleanup v9: Item lifecycle now routes through js/item_lifecycle_pipeline.js with frame, decay, dynamic, growth, and legacy fallback steps. Boundary cleanup v10: dynamic item behavior dispatch is split into js/item_dynamic_system.js plus tool, ball, duplicator, pin, and zunchi submodules; item_lifecycle_step_dynamic.js now calls that boundary. Boundary cleanup v11: fan/magnet/rotator, ball physics, duplicator loading, pin attachment, and zunchi motion/lifecycle implementations now live in the dynamic subsystem files; Item.prototype keeps thin compatibility facades. Boundary cleanup v12: item lifecycle support helpers and legacy fallback moved out of item_lifecycle_runtime.js; decay and growth implementation moved into dedicated item_lifecycle_*_system modules while lifecycle steps remain thin pipeline adapters. Boundary cleanup v13: Tarinai item interaction is extracted into js/tarinai_item_interaction_system.js; AI update step now calls that system directly while Tarinai.prototype keeps a thin compatibility facade. Boundary cleanup v14: remaining Tarinai interaction/planning cleanup is consolidated; item interaction scan/context, food consumption, and contact effects are split into dedicated modules, and resolveNeeds now routes through explicit need/action/forced planner boundaries while keeping legacy behavior as a fallback system. Boundary cleanup v15: Tarinai item interaction orchestration preserves the legacy per-candidate order by trying food consumption and then contact effects for each candidate before advancing." } diff --git a/css/base.css b/css/base.css index 39f4e77..c40b8bc 100644 --- a/css/base.css +++ b/css/base.css @@ -580,3 +580,113 @@ h1 { background: #efffde; border-color: rgba(103,178,44,0.46); } + +.rotator-editor { + position: fixed; + inset: 0; + z-index: 43; + display: grid; + place-items: center; + padding: 18px; + background: rgba(34, 29, 22, 0.34); + backdrop-filter: blur(3px); +} +.rotator-editor.hidden { display: none; } +.rotator-editor-panel { + width: min(620px, calc(100vw - 28px)); + border-radius: 14px; + padding: 14px; + background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.97)); + border: 1px solid rgba(94, 70, 42, 0.20); + box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72); +} +.rotator-editor-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(94, 70, 42, 0.13); +} +.rotator-editor-titlebar h2 { margin: 0; font-size: 16px; letter-spacing: .03em; } +.rotator-editor-close { + width: 30px; + height: 30px; + border-radius: 999px; + border: 1px solid rgba(84,68,48,0.16); + background: rgba(255,255,255,0.72); + color: var(--ink); + font-size: 19px; + line-height: 1; + cursor: pointer; +} +.rotator-editor-close:hover { background: rgba(255,248,232,0.96); } +.rotator-editor-controls { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin-bottom: 8px; +} +.rotator-editor-controls label { + display: grid; + grid-template-columns: auto 1fr 72px auto; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--ink); +} +.rotator-editor-controls input[type="number"] { + width: 72px; + box-sizing: border-box; + border-radius: 8px; + border: 1px solid rgba(84,68,48,0.18); + background: rgba(255,255,255,0.84); + padding: 4px 6px; +} +.rotator-editor-tools { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} +.rotator-editor-tools button { + border: 1px solid rgba(84,68,48,0.16); + background: rgba(255,255,255,0.74); + color: var(--ink); + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + cursor: pointer; +} +.rotator-editor-tools button.active { + background: rgba(137, 101, 198, 0.18); + border-color: rgba(112, 80, 170, 0.38); + font-weight: 700; +} +#rotatorCanvas { + width: 100%; + height: min(58vh, 360px); + display: block; + border-radius: 12px; + border: 1px solid rgba(84,68,48,0.20); + background: #f7f3ea; + touch-action: none; + cursor: crosshair; +} +.rotator-editor-hint { + margin: 7px 2px 0; + color: var(--muted); + font-size: 12px; +} +.rotator-editor-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} +@media (max-width: 560px) { + .rotator-editor-panel { padding: 10px; } + .rotator-editor-controls label { grid-template-columns: 1fr; align-items: stretch; } + #rotatorCanvas { height: 320px; } +} diff --git a/css/layout.css b/css/layout.css index dc36aa9..625d2de 100644 --- a/css/layout.css +++ b/css/layout.css @@ -4,6 +4,7 @@ .tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); } .tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); } .tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } +.tool[data-tool="area_delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } .tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); } .tool[data-tool="poke"] { --tool-emoji: "\1F448"; } @@ -24,7 +25,8 @@ .tool[data-tool="poke"].tool-emoji-icon::before, .tool[data-tool="water_hose"].tool-emoji-icon::before { font-size: 25px; } -.tool[data-tool="delete"] { --tool-icon-zoom: 1.42; } +.tool[data-tool="delete"], +.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; } .tool[data-tool="ball"] { --tool-icon-zoom: 1.36; } .tool[data-tool="observe"], .tool[data-tool="new"] { --tool-icon-zoom: 1.20; } diff --git a/index.html b/index.html index 9edda5e..2d2469a 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -95,7 +95,7 @@
個体0
死亡0
状態安定
-
地面
+
地面
@@ -189,94 +189,144 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/ants.js b/js/ants.js index eaf8e79..3f3089f 100644 --- a/js/ants.js +++ b/js/ants.js @@ -175,7 +175,7 @@ class AntActor { const haulers = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId === t.id && a.targetToken === t.liveToken).length; if (haulers >= ANT_NEST_MAX_OUTSIDE) continue; } - const weak = clamp((100 - (t.energy || 0)) / 100, 0, 1); + const weak = 1 - (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(t) : clamp((t.energy || 0) / Math.max(1, t.maxEnergy || 100), 0, 1)); const score = d - weak * 18 + stableUnit(`${this.id}:${t.id}`, "ant-prey") * 6; if (score < bestScore) { best = t; @@ -314,7 +314,7 @@ class AntActor { if (leadHauler) { target.x = clamp(target.x + ux * speed * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); target.y = clamp(target.y + uy * speed * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); - target.energy = clamp((target.energy || 0) - dt * 0.028, 0, 100); + target.energy = clamp((target.energy || 0) - dt * 0.028, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(target) : (Number(target.maxEnergy) || 100)); } const offsetAngle = this.seed + this.age * 2.0 + (haulers > 1 ? this.activeHaulersForTarget().findIndex(a => a === this) * 1.7 : 0); const ring = target.radius * (0.64 + 0.06 * Math.min(haulers, 3)); @@ -327,8 +327,8 @@ class AntActor { const stillSleeping = Boolean(target.sleeping || targetWasSleeping || target.state === "sleep"); const aggressiveAttack = !stillSleeping && (target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack"); const struggle = stillSleeping ? 0 : (aggressiveAttack - ? (1.25 + aggression * 1.6 + clamp((target.energy || 0) / 100, 0, 1) * 0.35) / Math.max(1, haulers * 0.9) - : (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers)); + ? (1.25 + aggression * 1.6 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.35) / Math.max(1, haulers * 0.9) + : (0.42 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.18) / Math.max(1, haulers)); this.hp -= dt * struggle; if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) { diff --git a/js/command_dispatcher.js b/js/command_dispatcher.js new file mode 100644 index 0000000..a82eb57 --- /dev/null +++ b/js/command_dispatcher.js @@ -0,0 +1,192 @@ +"use strict"; + +// Layer: commands +// UI calls this facade instead of mutating cross-cutting world state directly. +// Keep this file small: commands normalize user intent, then delegate game rules to World/system modules. +(function (global) { + const DEFAULT_SPEEDS = Object.freeze([1, 2, 4]); + const DEFAULT_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); + + function emit(worldRef, type, payload) { + worldRef?.emit?.(type, payload); + return payload; + } + + function ok(payload = {}) { + return { ok: true, ...payload }; + } + + function fail(reason, payload = {}) { + return { ok: false, reason, ...payload }; + } + + function selectTool(worldRef, toolId) { + if (!worldRef) return fail("missing_world"); + const id = String(toolId || "observe"); + if (worldRef.tool === id) return ok({ toolId: id, changed: false }); + worldRef.tool = id; + emit(worldRef, "tool:selected", { toolId: id }); + return ok({ toolId: id, changed: true }); + } + + function setSelection(worldRef, target) { + if (!worldRef) return fail("missing_world"); + const next = target || null; + if (worldRef.selected === next) return ok({ selected: next, changed: false }); + worldRef.selected = next; + emit(worldRef, "selection:changed", { selected: worldRef.selected }); + return ok({ selected: worldRef.selected, changed: true }); + } + + function clearSelection(worldRef) { + return setSelection(worldRef, null); + } + + function focusSelection(worldRef, target, options = {}) { + if (!worldRef) return fail("missing_world"); + if (!target || target.dead) return fail("missing_target"); + const result = setSelection(worldRef, target); + target.focusPulseTimer = Math.max(target.focusPulseTimer || 0, Number(options.pulse ?? 1.15) || 1.15); + if (options.selectObserve !== false) selectTool(worldRef, "observe"); + if (options.center !== false && Number.isFinite(target.x) && Number.isFinite(target.y)) { + const visibleW = worldRef.visibleWorldW ? worldRef.visibleWorldW() : (worldRef.viewportW || worldRef.w || 0); + const visibleH = worldRef.visibleWorldH ? worldRef.visibleWorldH() : (worldRef.viewportH || worldRef.h || 0); + worldRef.cameraX = target.x - visibleW / 2; + worldRef.cameraY = target.y - visibleH / 2; + worldRef.clampCamera?.(); + } + return ok({ selected: result.selected || target, target }); + } + + function toggleSelectedFavorite(worldRef, target = worldRef?.selected || null) { + if (!worldRef) return fail("missing_world"); + if (!target || target.dead) return fail("missing_target"); + target.favorite = !target.favorite; + emit(worldRef, "selection:favorite-changed", { selected: target, favorite: Boolean(target.favorite) }); + return ok({ selected: target, favorite: Boolean(target.favorite) }); + } + + function scalableToolSet() { + if (typeof global.scalableToolIds === "function") return new Set(global.scalableToolIds()); + return new Set(); + } + + function setToolSize(worldRef, tool, size) { + if (!worldRef) return fail("missing_world"); + const id = String(tool || worldRef.tool || ""); + const next = String(size || "medium"); + if (!id || !scalableToolSet().has(id)) return fail("not_scalable", { toolId: id }); + if (!worldRef.toolSizes) worldRef.toolSizes = {}; + worldRef.toolSizes[id] = next; + worldRef.toolSize = next; + emit(worldRef, "tool:size-changed", { toolId: id, size: next }); + return ok({ toolId: id, size: next }); + } + + function cycleToolSize(worldRef, tool, order = DEFAULT_SIZE_ORDER) { + if (!worldRef) return fail("missing_world"); + const id = String(tool || worldRef.tool || ""); + if (!id || !scalableToolSet().has(id)) return fail("not_scalable", { toolId: id }); + const sizes = Array.isArray(order) && order.length ? order.map(String) : Array.from(DEFAULT_SIZE_ORDER); + const current = typeof global.normalizedToolSizeFor === "function" + ? global.normalizedToolSizeFor(worldRef, id) + : ((worldRef.toolSizes && worldRef.toolSizes[id]) || worldRef.toolSize || "medium"); + const idx = sizes.indexOf(current); + const next = sizes[(idx + 1 + sizes.length) % sizes.length]; + return setToolSize(worldRef, id, next); + } + + function togglePaused(worldRef) { + if (!worldRef) return fail("missing_world"); + worldRef.paused = !worldRef.paused; + emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) }); + return ok({ paused: Boolean(worldRef.paused) }); + } + + function setPaused(worldRef, paused) { + if (!worldRef) return fail("missing_world"); + worldRef.paused = Boolean(paused); + emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) }); + return ok({ paused: Boolean(worldRef.paused) }); + } + + function cycleSpeed(worldRef, speeds = DEFAULT_SPEEDS) { + if (!worldRef) return fail("missing_world"); + const list = Array.isArray(speeds) && speeds.length ? speeds.map(Number).filter(Number.isFinite) : Array.from(DEFAULT_SPEEDS); + const i = list.indexOf(Number(worldRef.speed) || 1); + worldRef.speed = list[(i + 1 + list.length) % list.length]; + emit(worldRef, "simulation:speed-changed", { speed: worldRef.speed }); + return ok({ speed: worldRef.speed }); + } + + function primaryPointer(worldRef, x, y) { + if (!worldRef) return fail("missing_world"); + const px = Number(x); + const py = Number(y); + if (!Number.isFinite(px) || !Number.isFinite(py)) return fail("invalid_point", { x, y }); + if (typeof worldRef.handleClick !== "function") return fail("missing_pointer_handler"); + worldRef.handleClick(px, py); + emit(worldRef, "pointer:primary", { x: px, y: py, toolId: worldRef.tool || "" }); + return ok({ x: px, y: py, toolId: worldRef.tool || "" }); + } + + function dispatch(worldRef, command = {}) { + switch (command.type) { + case "tool.select": + return selectTool(worldRef, command.toolId); + case "tool.size.set": + return setToolSize(worldRef, command.toolId, command.size); + case "tool.size.cycle": + return cycleToolSize(worldRef, command.toolId, command.order); + case "selection.set": + return setSelection(worldRef, command.target || null); + case "selection.clear": + return clearSelection(worldRef); + case "selection.focus": + return focusSelection(worldRef, command.target || null, command.options || {}); + case "selection.favorite.toggle": + return toggleSelectedFavorite(worldRef, command.target || worldRef?.selected || null); + case "simulation.paused.set": + return setPaused(worldRef, command.paused); + case "simulation.paused.toggle": + return togglePaused(worldRef); + case "simulation.speed.cycle": + return cycleSpeed(worldRef, command.speeds); + case "pointer.primary": + return primaryPointer(worldRef, command.x, command.y); + default: + return fail("unknown_command", { type: command.type || "" }); + } + } + + + function installWorldFacade() { + const World = global.World; + if (!World?.prototype || World.prototype.dispatchCommand) return; + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + dispatchCommand(command = {}) { return dispatch(this, command); }, + selectToolCommand(toolId) { return selectTool(this, toolId); }, + setSelectionCommand(target) { return setSelection(this, target); }, + clearSelectionCommand() { return clearSelection(this); }, + focusSelectionCommand(target, options = {}) { return focusSelection(this, target, options); }, + primaryPointerCommand(x, y) { return primaryPointer(this, x, y); }, + })); + } + + installWorldFacade(); + + global.TarinaiCommands = Object.freeze({ + dispatch, + selectTool, + setSelection, + clearSelection, + focusSelection, + toggleSelectedFavorite, + setToolSize, + cycleToolSize, + setPaused, + togglePaused, + cycleSpeed, + primaryPointer, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/ground_types.js b/js/ground_types.js index ce80a62..e82896a 100644 --- a/js/ground_types.js +++ b/js/ground_types.js @@ -1,12 +1,14 @@ "use strict"; -const GROUND_TYPE_ORDER = Object.freeze(["soil", "concrete", "blanket", "foot_massage", "ice"]); +const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice"]); const GROUND_TYPES = Object.freeze({ - soil: Object.freeze({ id: "soil", label: "土", description: "いつものじめん", stressMultiplier: 1.0, grassMultiplier: 1.0 }), - concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassLimit: 0 }), - blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0 }), - foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "青いまだら地面。ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0 }), - ice: Object.freeze({ id: "ice", label: "氷", description: "つるつる滑る。草は生えにくい", stressMultiplier: 1.1, grassMultiplier: 0.15 }), + // 既存セーブとの互換性のため、デフォルトIDは soil のままにし、表示名だけ「砂」にする。 + soil: Object.freeze({ id: "soil", label: "砂", description: "普通の地面。", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }), + dirt: Object.freeze({ id: "dirt", label: "土", description: "草がよく育つ地面。", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }), + concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }), + blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }), + foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "青いまだら地面。ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }), + ice: Object.freeze({ id: "ice", label: "氷", description: "つるつる滑る。草は生えにくい", stressMultiplier: 1.1, grassMultiplier: 0.15, grassGrowthMultiplier: 0.35 }), }); function groundDefinition(id = "soil") { @@ -18,7 +20,7 @@ function groundExists(id = "soil") { } function groundLabel(id = "soil") { - return groundDefinition(id).label || "土"; + return groundDefinition(id).label || "砂"; } function groundDescription(id = "soil") { @@ -35,6 +37,12 @@ function normalizedGroundGrassMultiplier(id = "soil") { return Number.isFinite(mult) && mult >= 0 ? mult : 1; } +function normalizedGroundGrassGrowthMultiplier(id = "soil") { + const def = groundDefinition(id); + const mult = Number(def.grassGrowthMultiplier ?? def.grassMultiplier); + return Number.isFinite(mult) && mult >= 0 ? mult : 1; +} + function groundLimitForField(fieldType = "garden") { const config = typeof CONFIG !== "undefined" ? CONFIG : null; const byField = config?.grassLimitsByField || { cage: 19, garden: 99, park: 299 }; @@ -71,6 +79,7 @@ window.TarinaiGround = Object.freeze({ description: groundDescription, stressMultiplier: normalizedGroundStressMultiplier, grassMultiplier: normalizedGroundGrassMultiplier, + grassGrowthMultiplier: normalizedGroundGrassGrowthMultiplier, grassLimit: groundGrassLimit, nextId: nextGroundTypeId, tooltipText: groundTooltipText, diff --git a/js/health.js b/js/health.js index 0abd436..74724da 100644 --- a/js/health.js +++ b/js/health.js @@ -126,9 +126,11 @@ const HEALTH = (() => { } function applyDamage(t, amount, reason = "") { - const before = t.energy; + const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100); + t.maxEnergy = maxEnergy; + const before = clamp(Number(t.energy) || 0, 0, maxEnergy); const loss = Math.max(0, amount || 0); - t.energy = clamp(t.energy - loss, 0, 100); + t.energy = clamp(before - loss, 0, maxEnergy); const actualLoss = Math.max(0, before - t.energy); let cause = ""; if (actualLoss > 0.01) { diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js new file mode 100644 index 0000000..c123958 --- /dev/null +++ b/js/item_dynamic_ball_system.js @@ -0,0 +1,249 @@ +"use strict"; + +// Layer: entity-runtime/items/dynamic-system +// Owns ball item motion and obstacle bounce behavior. Item.prototype ball +// methods are compatibility facades that delegate to this system. +(function (global) { + function update(item, dt, worldRef) { + if (!item || item.dead || item.type !== "ball") return false; + item.prevX = item.x; + item.prevY = item.y; + const moving = Math.hypot(item.vx || 0, item.vy || 0); + if (moving > 0.04) { + item.x += (item.vx || 0) * dt; + item.y += (item.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.72; item.spinVelocity *= -0.72; } + if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.72; item.spinVelocity *= -0.72; } + if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; } + if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; } + resolveObstacleCollisions(item, dt, worldRef); + item.x = clamp(item.x, p, worldRef.w - p); + item.y = clamp(item.y, p, worldRef.h - p); + const groundFriction = Math.pow(0.72, dt); + item.vx *= groundFriction; + item.vy *= groundFriction; + } + item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt + (item.vx || 0) * dt / Math.max(8, item.r || 18); + item.spinVelocity *= Math.pow(0.65, dt); + if (Math.hypot(item.vx || 0, item.vy || 0) < 0.18) { item.vx = 0; item.vy = 0; } + return true; + } + + function resolveObstacleCollisions(item, dt, worldRef) { + if (!worldRef?.nearbyItems) return; + const speed = Math.hypot(item.vx || 0, item.vy || 0); + const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120); + const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || []; + const seen = new Set(items); + // 回転体は大きな輪郭を設計できるため、中心が近傍セル外でも当たる可能性がある。 + // 中心ではなく外接半径で追加走査して、長い棒・大きな円形にも衝突させる。 + if (Array.isArray(worldRef.items)) { + for (const it of worldRef.items) { + if (!it || it.dead || it === item || it.type !== "rotator" || seen.has(it)) continue; + const reach = worldRef.rotatorExtent?.(it) || Math.max(64, it.r || 64); + if (distXY(item.x, item.y, it.x, it.y) <= searchRadius + reach + 36) { + items.push(it); + seen.add(it); + } + } + } + for (const it of items) { + if (!it || it === item || it.dead) continue; + if (it.type === "bed") { + applyHayDrag(item, it, dt, worldRef); + } else if (it.type === "stone") { + resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), 0.82, worldRef); + } + const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : []; + if (!rects.length) continue; + for (const rect of rects) resolveRectBounce(item, rect, worldRef, it); + } + } + + function applyHayDrag(item, bed, dt, worldRef) { + const rx = Math.max(26, (bed.r || 37) * 1.72 + (item.r || 18) * 0.40); + const ry = Math.max(18, (bed.r || 37) * 0.92 + (item.r || 18) * 0.34); + const dx = item.x - bed.x; + const dy = item.y - bed.y; + const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1; + if (!inside) return; + const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016)); + item.vx *= slow; + item.vy *= slow; + item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016)); + if ((item.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(item.vx || 0, item.vy || 0) > 120) { + item.lastHaySlowLogAt = worldRef.time || 0; + worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" })); + } + } + + function resolveCircleBounce(item, obstacle, hitRadius, restitution, worldRef) { + let dx = item.x - obstacle.x; + let dy = item.y - obstacle.y; + let d = Math.hypot(dx, dy); + if (d >= hitRadius) { + const px = item.prevX; + const py = item.prevY; + if (!Number.isFinite(px) || !Number.isFinite(py)) return; + const sx = item.x - px; + const sy = item.y - py; + const len2 = sx * sx + sy * sy; + if (len2 <= 0.0001) return; + const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1); + const cx = px + sx * t; + const cy = py + sy * t; + dx = cx - obstacle.x; + dy = cy - obstacle.y; + d = Math.hypot(dx, dy); + if (d >= hitRadius) return; + if (d < 0.001) { + const speed = Math.hypot(item.vx || 0, item.vy || 0); + if (speed > 0.001) { dx = -(item.vx || 0) / speed; dy = -(item.vy || 0) / speed; d = 1; } + else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; } + } + } + if (d < 0.001) { + const speed = Math.hypot(item.vx || 0, item.vy || 0); + if (speed > 0.001) { dx = -(item.vx || 0) / speed; dy = -(item.vy || 0) / speed; d = 1; } + else { dx = Math.cos(item.seed || 0); dy = Math.sin(item.seed || 0); d = 1; } + } + const nx = dx / d; + const ny = dy / d; + const toward = (item.vx || 0) * nx + (item.vy || 0) * ny; + item.x = obstacle.x + nx * (hitRadius + 0.5); + item.y = obstacle.y + ny * (hitRadius + 0.5); + if (toward < 0) { + item.vx = (item.vx || 0) - (1 + restitution) * toward * nx; + item.vy = (item.vy || 0) - (1 + restitution) * toward * ny; + } else { + item.vx = (item.vx || 0) + nx * 24; + item.vy = (item.vy || 0) + ny * 24; + } + item.vx *= 0.96; + item.vy *= 0.96; + item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38); + emitBounce(item, worldRef, obstacle); + } + + function resolveRectBounce(item, rect, worldRef, source = null) { + if (!rect) return; + const hitRadius = (item.r || 18) + 2; + const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78; + const velocityDamp = rect.bounce ? 1.0 : 0.94; + const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0); + if (rect.oriented && global.TarinaiPhysics?.bounceCircleOffRect) { + const bounced = global.TarinaiPhysics.bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { minBounceSpeed }); + if (!bounced) return; + item.vx *= velocityDamp; + item.vy *= velocityDamp; + emitBounce(item, worldRef, source || rect.item || rect); + return; + } + const ensureMinNormalSpeed = (nx, ny) => { + if (!(rect.bounce && minBounceSpeed > 0)) return; + const normalSpeed = (item.vx || 0) * nx + (item.vy || 0) * ny; + if (normalSpeed >= minBounceSpeed) return; + const add = minBounceSpeed - normalSpeed; + item.vx = (item.vx || 0) + nx * add; + item.vy = (item.vy || 0) + ny * add; + }; + const cx = clamp(item.x, rect.left, rect.right); + const cy = clamp(item.y, rect.top, rect.bottom); + let dx = item.x - cx; + let dy = item.y - cy; + let d = Math.hypot(dx, dy); + if (d >= hitRadius) { + const px = item.prevX; + const py = item.prevY; + const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius }; + if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, item.x, item.y, expanded)) return; + let nx = 0; + let ny = 0; + const vx = item.vx || 0; + const vy = item.vy || 0; + const candidates = []; + const sx = item.x - px; + const sy = item.y - py; + if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) }); + if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) }); + if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) }); + if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) }); + if (candidates.length) { + candidates.sort((a, b) => a.t - b.t); + nx = candidates[0].nx; + ny = candidates[0].ny; + } else if (Math.abs(vx) >= Math.abs(vy)) { + nx = vx >= 0 ? -1 : 1; + } else { + ny = vy >= 0 ? -1 : 1; + } + const toward = vx * nx + vy * ny; + if (nx < 0) item.x = expanded.left - 0.5; + else if (nx > 0) item.x = expanded.right + 0.5; + if (ny < 0) item.y = expanded.top - 0.5; + else if (ny > 0) item.y = expanded.bottom + 0.5; + if (nx) item.y = clamp(item.y, expanded.top, expanded.bottom); + if (ny) item.x = clamp(item.x, expanded.left, expanded.right); + if (toward < 0) { + item.vx = vx - (1 + restitution) * toward * nx; + item.vy = vy - (1 + restitution) * toward * ny; + } else { + item.vx = vx + nx * 18; + item.vy = vy + ny * 18; + } + ensureMinNormalSpeed(nx, ny); + item.vx *= velocityDamp; + item.vy *= velocityDamp; + item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); + emitBounce(item, worldRef, source || rect.item || rect); + return; + } + if (d < 0.001) { + const left = Math.abs(item.x - rect.left); + const right = Math.abs(rect.right - item.x); + const top = Math.abs(item.y - rect.top); + const bottom = Math.abs(rect.bottom - item.y); + const m = Math.min(left, right, top, bottom); + if (m === left) { dx = -1; dy = 0; } + else if (m === right) { dx = 1; dy = 0; } + else if (m === top) { dx = 0; dy = -1; } + else { dx = 0; dy = 1; } + d = 1; + } + const nx = dx / d; + const ny = dy / d; + const toward = (item.vx || 0) * nx + (item.vy || 0) * ny; + item.x = cx + nx * (hitRadius + 0.5); + item.y = cy + ny * (hitRadius + 0.5); + if (toward < 0) { + item.vx = (item.vx || 0) - (1 + restitution) * toward * nx; + item.vy = (item.vy || 0) - (1 + restitution) * toward * ny; + } else { + item.vx = (item.vx || 0) + nx * 18; + item.vy = (item.vy || 0) + ny * 18; + } + ensureMinNormalSpeed(nx, ny); + item.vx *= velocityDamp; + item.vy *= velocityDamp; + item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); + emitBounce(item, worldRef, source || rect.item || rect); + } + + function emitBounce(item, worldRef, obstacle) { + const now = worldRef?.time || 0; + if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return; + item.lastObstacleBounceAt = now; + const color = (obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"); + worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color })); + } + + global.TarinaiItemDynamicBallSystem = Object.freeze({ + update, + resolveObstacleCollisions, + applyHayDrag, + resolveCircleBounce, + resolveRectBounce, + emitBounce, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_dynamic_duplicator_system.js b/js/item_dynamic_duplicator_system.js new file mode 100644 index 0000000..a167eb1 --- /dev/null +++ b/js/item_dynamic_duplicator_system.js @@ -0,0 +1,104 @@ +"use strict"; + +// Layer: entity-runtime/items/dynamic-system +// Owns duplicator item loading/reloading behavior. Direct stored-type helpers +// remain exposed from TarinaiDuplicatorRuntime for UI/tool commands and +// save/restore compatibility. +(function (global) { + function support() { + return global.TarinaiItemLifecycleRuntimeSupport || {}; + } + + function update(item, dt, worldRef) { + if (!item || item.dead || item.type !== "duplicator") return false; + const s = support(); + const syncRoles = s.syncDuplicatorRoles || global.TarinaiDuplicatorRuntime?.syncRoles; + const applyStoredPin = s.duplicatorApplyStoredPin; + const canLoadNow = s.duplicatorCanLoadNow; + const loadPointFor = s.duplicatorLoadPoint || global.TarinaiDuplicatorRuntime?.loadPoint; + const loadTypeForItem = s.duplicatorLoadTypeForItem || global.TarinaiDuplicatorRuntime?.loadTypeForItem; + const candidateReach = s.duplicatorCandidateReach; + const setStoredType = s.duplicatorSetStoredType || global.TarinaiDuplicatorRuntime?.setStoredType; + if (typeof syncRoles !== "function" || typeof loadPointFor !== "function" || typeof loadTypeForItem !== "function" || typeof setStoredType !== "function") return false; + + item.amount = 999; + syncRoles(item); + if (typeof applyStoredPin === "function") applyStoredPin(item, worldRef); + item.duplicatorPinFlash = Math.max(0, (item.duplicatorPinFlash || 0) - dt); + if (!worldRef?.nearbyItems) return true; + if (typeof canLoadNow === "function" && !canLoadNow(item, worldRef)) { + item._duplicatorCandidateKey = ""; + item._duplicatorCandidateSince = 0; + return true; + } + + const loadPoint = loadPointFor(item); + const pickupRadius = Math.max(68, (item.r || 34) + 44); + const candidates = Array.from(worldRef.nearbyItems(item.x, item.y, pickupRadius, true) || []); + // SpatialGrid の分類漏れを補う。ただし、複製機からかなり近いものだけに限定し、 + // 移動中の複製機が周辺アイテムを吸い込む挙動を避ける。 + if (worldRef.items) { + for (const candidate of worldRef.items) { + if (!candidate || candidates.includes(candidate) || candidate === item || candidate.dead) continue; + const approxReach = pickupRadius + Math.max(16, candidate.r || 12); + if (distXY(item.x, item.y, candidate.x, candidate.y) <= approxReach) candidates.push(candidate); + } + } + + let best = null, bestType = "", bestSlotD = Infinity, bestScore = Infinity; + for (const it of candidates) { + if (!it || it === item || it.dead || it.playerHeld || it._heldByPlayer) continue; + const loadType = loadTypeForItem(it); + if (!loadType) continue; + if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue; + const slotD = distXY(loadPoint.x, loadPoint.y, it.x, it.y); + const centerD = distXY(item.x, item.y, it.x, it.y); + const slotReach = typeof candidateReach === "function" ? candidateReach(it) : Math.max(20, Math.min(36, (it?.r || 12) * 0.82 + 13)); + const bodyReach = Math.max(22, (item.r || 34) * 0.46 + (it.r || 12) * 0.36); + if (slotD > slotReach && centerD > bodyReach) continue; + // 基本は投入口に近いものを優先。同距離なら置かれたばかりのものを優先し、 + // 複数候補があるときに意図しない古いアイテムへ寄りにくくする。 + const score = slotD + Math.max(0, Number(it.age || 0)) * 0.012; + if (score < bestScore) { + best = it; + bestType = loadType; + bestSlotD = slotD; + bestScore = score; + } + } + + if (!best || !bestType) { + item._duplicatorCandidateKey = ""; + item._duplicatorCandidateSince = 0; + return true; + } + + const candidateKey = `${best.id || ""}:${bestType}`; + const now = Number(worldRef.time || 0); + if (item._duplicatorCandidateKey !== candidateKey) { + item._duplicatorCandidateKey = candidateKey; + item._duplicatorCandidateSince = now; + return true; + } + const dwell = item.storedFoodType ? (s.DUPLICATOR_REPLACE_DWELL_SECONDS ?? 0.70) : (s.DUPLICATOR_LOAD_DWELL_SECONDS ?? 0.38); + if (now - Number(item._duplicatorCandidateSince || 0) < dwell) return true; + + const changed = item.storedFoodType !== bestType; + if (!changed && item.storedFoodType) { + item._duplicatorCandidateKey = ""; + item._duplicatorCandidateSince = 0; + return true; + } + + setStoredType(item, bestType, worldRef, { consumeItem: best, direct: false, force: false, log: false, emit: false }); + // Item.dead is a getter based on amount. Do not assign to it. + worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.emit?.("duplicator:loaded", { duplicator: item, foodType: item.storedFoodType, replaced: changed, slotDistance: bestSlotD }); + worldRef.log?.(`複製機に${item.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(item.storedFoodType) ? "observe" : "food"); + return true; + } + + global.TarinaiItemDynamicDuplicatorSystem = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_dynamic_pin_system.js b/js/item_dynamic_pin_system.js new file mode 100644 index 0000000..1d07a7c --- /dev/null +++ b/js/item_dynamic_pin_system.js @@ -0,0 +1,195 @@ +"use strict"; + +// Layer: entity-runtime/items/dynamic-system +// Owns pushpin/oshibyo item motion, attachment, lodged damage, and detach +// behavior. Prototype methods remain as compatibility facades. +(function (global) { + function isPin(item) { + return Boolean(item && typeof global.isPinType === "function" && global.isPinType(item.type)); + } + + function update(item, dt, worldRef) { + if (!isPin(item) || item.dead) return false; + if (!worldRef) return false; + if (item.pinState === "lodged") { + updateLodged(item, dt, worldRef); + return true; + } + if (global.TarinaiPhysics?.applyKinematicItemMotion) { + global.TarinaiPhysics.applyKinematicItemMotion(item, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 }); + } else { + item.prevX = item.x; + item.prevY = item.y; + item.x += (item.vx || 0) * dt; + item.y += (item.vy || 0) * dt; + } + tryStick(item, worldRef); + return true; + } + + function tryStick(item, worldRef) { + if (!worldRef?.tarinai?.length) return false; + if (item.pinState === "lodged") return false; + const now = worldRef.time || 0; + if ((item.noStickUntil || 0) > now) return false; + let best = null; + let bestD = Infinity; + const hitRange = Math.max(10, (item.r || 8) * 1.25); + const candidates = worldRef.nearbyTarinai?.(item.x, item.y, hitRange + 32, true) || worldRef.tarinai; + for (const t of candidates) { + if (!t || t.dead) continue; + if (item.noStickTargetId && item.noStickTargetId === t.id && (item.noStickTargetUntil || 0) > now) continue; + const d = distXY(item.x, item.y, t.x, t.y); + const hit = (t.radius || 16) * 0.86 + hitRange; + if (d <= hit && d < bestD) { best = t; bestD = d; } + } + if (!best) return false; + return attach(item, best, worldRef, bestD); + } + + function attach(item, t, worldRef, d = null) { + if (!t || t.dead) return false; + if (t.stuckPushpinId && t.stuckPushpinId !== item.id) return false; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); + const dx = item.x - t.x; + const dy = item.y - t.y; + const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); + item.pinState = "lodged"; + item.deletable = false; + item.pinTargetId = t.id; + const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); + const visualSide = isOshibyo ? -faceDir : faceDir; + item.pinVisualSide = visualSide; + item.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide); + item.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); + item.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + item.pinDamageTick = 0; + item.pinFallCheckTimer = 0; + item.vx = 0; + item.vy = 0; + item.spinVelocity = 0; + item.spin = item.pinAttachAngle; + t.stuckPushpinId = item.id; + t.sleeping = false; + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8); + if (isOshibyo) { + t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f"; + worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); + worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); + return true; + } + if (t.enterPanic) { + t.enterPanic({ threat: item, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); + } else { + t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); + t.fearTimer = Math.max(t.fearTimer || 0, 1.3); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); + } + if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); + if ((worldRef.time || 0) >= (item.pinLogAt || -999) + 1.2) { + item.pinLogAt = worldRef.time || 0; + worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); + } + worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); + return true; + } + + function updateLodged(item, dt, worldRef) { + const t = worldRef.liveTarinaiById?.(item.pinTargetId) || null; + if (!t) { + detach(item, worldRef, "owner-lost"); + return; + } + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); + t.stuckPushpinId = item.id; + item.deletable = false; + const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); + const visualSide = isOshibyo ? -faceDir : faceDir; + item.pinVisualSide = visualSide; + item.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36; + item.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04; + item.prevX = item.x; + item.prevY = item.y; + item.spin = visualSide * (isOshibyo ? 0.52 : 0.28); + item.vx = t.vx || 0; + item.vy = t.vy || 0; + if (isOshibyo) { + if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044"; + return; + } + item.pinDamageTick = (item.pinDamageTick || 0) + dt; + while (item.pinDamageTick >= 0.55) { + item.pinDamageTick -= 0.55; + if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); + if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); + if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); + } + t.sleeping = false; + if (t.enterPanic) t.enterPanic({ threat: item, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); + else { + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + t.fearTimer = Math.max(t.fearTimer || 0, 0.65); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); + } + item.pinFallCheckTimer = (item.pinFallCheckTimer || 0) + dt; + while (item.pinFallCheckTimer >= 1.0) { + item.pinFallCheckTimer -= 1.0; + if (Math.random() < 0.10) { + detach(item, worldRef, "fall"); + return; + } + } + } + + function detach(item, worldRef, reason = "released") { + const t = worldRef?.liveTarinaiById?.(item.pinTargetId) || null; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null; + const wasOshibyo = Boolean(behavior?.blocksZunchi); + const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0; + if (t && t.stuckPushpinId === item.id) t.stuckPushpinId = null; + if (t && wasOshibyo) t.oshiriByoZunchiStock = 0; + if (reason === "pinch") { + const now = worldRef?.time || 0; + item.noStickUntil = now + 1.0; + item.noStickTargetId = t?.id || ""; + item.noStickTargetUntil = now + 1.0; + } else if (t) { + const now = worldRef?.time || 0; + item.noStickTargetId = t.id || ""; + item.noStickTargetUntil = now + 0.55; + } + item.pinState = "loose"; + item.pinTargetId = ""; + item.pinDamageTick = 0; + item.pinFallCheckTimer = 0; + item.deletable = true; + if (reason === "pinch") { + item.vx = 0; + item.vy = 0; + item.spinVelocity = 0; + } else { + item.vx = rand(-24, 24); + item.vy = rand(-10, 18); + item.spinVelocity = rand(-1.6, 1.6); + item.spin += rand(-0.25, 0.25); + if (t) { + item.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30)); + item.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30)); + } + worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); + if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); + } + + if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { + worldRef.spawnBurstZunchi(t || item, storedZunchi, item); + } + } + + global.TarinaiItemDynamicPinSystem = Object.freeze({ update, isPin, tryStick, attach, updateLodged, detach }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_dynamic_system.js b/js/item_dynamic_system.js new file mode 100644 index 0000000..4956d62 --- /dev/null +++ b/js/item_dynamic_system.js @@ -0,0 +1,32 @@ +"use strict"; + +// Layer: entity-runtime/items/dynamic-system +// Central dispatch table for active item behavior. Lifecycle steps should call +// this module instead of knowing every dynamic item type. +(function (global) { + function update(item, dt, worldRef) { + if (!item || item.dead) return false; + switch (item.type) { + case "firecracker": + case "genkotsu": + case "fan": + case "magnet": + case "rotator": + case "gate_fence": + return global.TarinaiItemDynamicToolSystem?.update(item, dt, worldRef) !== false; + case "ball": + return global.TarinaiItemDynamicBallSystem?.update(item, dt, worldRef) !== false; + case "duplicator": + return global.TarinaiItemDynamicDuplicatorSystem?.update(item, dt, worldRef) !== false; + case "zunchi": + return global.TarinaiItemDynamicZunchiSystem?.updateMotion(item, dt, worldRef) !== false; + default: + if (global.TarinaiItemDynamicPinSystem?.isPin(item)) { + return global.TarinaiItemDynamicPinSystem.update(item, dt, worldRef) !== false; + } + return false; + } + } + + global.TarinaiItemDynamicSystem = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_dynamic_tool_system.js b/js/item_dynamic_tool_system.js new file mode 100644 index 0000000..3980579 --- /dev/null +++ b/js/item_dynamic_tool_system.js @@ -0,0 +1,136 @@ +"use strict"; + +// Layer: entity-runtime/items/dynamic-system +// Owns dynamic tool behavior for fan, magnet, rotator, firecracker, genkotsu, +// and static gate-fence ticking. Item.prototype methods delegate here for +// compatibility; lifecycle dispatch also calls this boundary directly. +(function (global) { + function support() { + return global.TarinaiItemLifecycleRuntimeSupport || {}; + } + + function updateFirecracker(item, dt, worldRef) { + item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt); + if (item.fuseTimer <= 0) { + if (worldRef?.explodeFirecracker) worldRef.explodeFirecracker(item); + else item.amount = 0; + } + } + + function updateGenkotsu(item, dt) { + item.impactFlash = Math.max(0, (item.impactFlash || 0) - dt); + if (item.dropImpactDone) { + item.lingerTimer = Math.max(0, (item.lingerTimer || 0) - dt); + if ((item.lingerTimer || 0) <= 0) item.amount = 0; + } else { + item.amount = Math.max(item.amount || 0, 100); + } + } + + function updateFan(item, dt, worldRef) { + if (!item || item.dead || item.type !== "fan") return false; + item.amount = 999; + if (!worldRef) return false; + const { fanApplyToBody, itemAngleForRuntime } = support(); + if (typeof fanApplyToBody !== "function") return false; + let changed = false; + const range = Math.max(190, (item.r || 32) * 7.4); + for (const t of worldRef.nearbyTarinai?.(item.x, item.y, range + 80, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + changed = fanApplyToBody(item, t, dt, worldRef, 1.0) || changed; + } + const kinematicTypes = new Set(["ball", "zunchi", "pushpin", "oshibyo"]); + for (const it of worldRef.nearbyItems?.(item.x, item.y, range + 80, true) || []) { + if (!it || it === item || it.dead || !kinematicTypes.has(it.type)) continue; + changed = fanApplyToBody(item, it, dt, worldRef, it.type === "ball" ? 1.12 : 0.82) || changed; + } + const now = worldRef.time || 0; + if (changed && (item.fanPulseAt || -999) + 0.12 <= now) { + item.fanPulseAt = now; + const angle = typeof itemAngleForRuntime === "function" ? itemAngleForRuntime(item) : (Number(item.angle) || 0); + const x = item.x + Math.cos(angle) * (item.r || 32) * rand(0.8, 2.8); + const y = item.y + Math.sin(angle) * (item.r || 32) * rand(0.8, 2.8) + rand(-10, 10); + worldRef.effects?.push(new Effect("wind", x, y, { vx: Math.cos(angle) * rand(16, 40), vy: Math.sin(angle) * rand(16, 40), size: rand(8, 18), life: rand(0.18, 0.32), color: "rgba(160,210,245,0.28)" })); + } + if (changed) { + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.("fan-force"); + } + return changed; + } + + function updateMagnet(item, dt, worldRef) { + if (!item || item.dead || item.type !== "magnet") return false; + item.amount = 999; + if (!worldRef) return false; + const { isMagnetTargetItem, magnetApplyToBody, magnetPolePoint } = support(); + if (typeof isMagnetTargetItem !== "function" || typeof magnetApplyToBody !== "function") return false; + let changed = false; + const range = Math.max(170, (item.r || 30) * 7.4); + for (const it of worldRef.nearbyItems?.(item.x, item.y, range + 80, true) || []) { + if (!isMagnetTargetItem(it)) continue; + if (it.type === "pushpin" || it.type === "oshibyo") { + if (it.pinState === "lodged") continue; + } + changed = magnetApplyToBody(item, it, dt, worldRef, it.type === "ball" ? 0.42 : 1.0) || changed; + } + for (const t of worldRef.nearbyTarinai?.(item.x, item.y, range + 80, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const lodged = t.currentLodgedPin?.() || (worldRef.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === t.id); + if (!lodged) continue; + changed = magnetApplyToBody(item, t, dt, worldRef, 0.55) || changed; + } + const now = worldRef.time || 0; + if (changed && (item.magnetPulseAt || -999) + 0.18 <= now) { + item.magnetPulseAt = now; + const pole = typeof magnetPolePoint === "function" ? magnetPolePoint(item) : { x: item.x, y: item.y }; + worldRef.effects?.push(new Effect("ring", pole.x, pole.y, { size: Math.max(12, (item.r || 30) * 0.85), life: 0.16, color: "rgba(120,150,230,0.34)" })); + } + if (changed) { + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.("magnet-force"); + } + return changed; + } + + function updateRotator(item, dt, worldRef) { + if (!item || item.dead || item.type !== "rotator") return false; + item.amount = 999; + const speed = Number(item.rotatorSpeed || 0) || 0; + if (!speed || !Number.isFinite(speed)) return false; + const before = Number(item.angle || 0) || 0; + item.angle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(before + speed * dt, 0) : before + speed * dt; + const extent = worldRef?.rotatorExtent?.(item); + if (Number.isFinite(extent)) item.r = Math.max(item.r || 64, Math.min(460, extent)); + item.rotatorSpinPhase = (item.rotatorSpinPhase || 0) + Math.abs(speed) * dt; + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.("rotator-spin"); + return true; + } + + function updateGateFence(item) { + if (item) item.amount = 999; + return Boolean(item); + } + + function update(item, dt, worldRef) { + if (!item || item.dead) return false; + if (item.type === "firecracker") return updateFirecracker(item, dt, worldRef); + if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef); + if (item.type === "fan") return updateFan(item, dt, worldRef); + if (item.type === "magnet") return updateMagnet(item, dt, worldRef); + if (item.type === "rotator") return updateRotator(item, dt, worldRef); + if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef); + return false; + } + + global.TarinaiItemDynamicToolSystem = Object.freeze({ + update, + updateFirecracker, + updateGenkotsu, + updateFan, + updateMagnet, + updateRotator, + updateGateFence, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_dynamic_zunchi_system.js b/js/item_dynamic_zunchi_system.js new file mode 100644 index 0000000..c35a31b --- /dev/null +++ b/js/item_dynamic_zunchi_system.js @@ -0,0 +1,100 @@ +"use strict"; + +// Layer: entity-runtime/items/dynamic-system +// Owns zunchi item motion, high-speed collision, and lifecycle stage ticking. +// Prototype methods remain as compatibility facades. +(function (global) { + function updateMotion(item, dt, worldRef) { + if (!item || item.dead || item.type !== "zunchi") return false; + const speed = Math.hypot(item.vx || 0, item.vy || 0); + if (speed < 0.08) { item.vx = 0; item.vy = 0; return true; } + if (global.TarinaiPhysics?.applyKinematicItemMotion) { + global.TarinaiPhysics.applyKinematicItemMotion(item, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 }); + } else { + item.prevX = item.x; + item.prevY = item.y; + item.x += (item.vx || 0) * dt; + item.y += (item.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.34; } + if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.34; } + if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.34; } + if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.34; } + const slow = Math.pow(0.68, dt * 2.4); + item.vx *= slow; + item.vy *= slow; + worldRef.drawListDirty = true; + } + resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed); + item.spin = (item.spin || 0) + speed * dt / Math.max(8, item.r || 14); + return true; + } + + function resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed = 0) { + if (!worldRef || speed < 140 || (item.amount || 0) <= 0) return; + const search = Math.max(38, (item.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18); + for (const t of worldRef.nearbyTarinai?.(item.x, item.y, search, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const d = distXY(item.x, item.y, t.x, t.y); + if (d > (t.radius || 20) * 0.72 + (item.r || 14)) continue; + const now = worldRef.time || 0; + if ((item.lastHitTarinaiAt || {})[t.id] && item.lastHitTarinaiAt[t.id] + 0.55 > now) continue; + item.lastHitTarinaiAt = item.lastHitTarinaiAt || {}; + item.lastHitTarinaiAt[t.id] = now; + const nx = speed > 0 ? (item.vx || 1) / speed : rand(-1, 1); + const ny = speed > 0 ? (item.vy || 0) / speed : rand(-1, 1); + const impulse = clamp(speed * 1.15, 150, 620); + t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20); + t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10); + t.fallTimer = Math.max(t.fallTimer || 0, 0.85); + t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer); + t.fallDir = nx < 0 ? -1 : 1; + if (t.enterPanic) t.enterPanic({ threat: item, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" }); + else { + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true }); + if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); + } + worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); + worldRef.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); + item.vx *= -0.18; + item.vy *= -0.18; + break; + } + } + + function updateLifecycle(item, dt, worldRef) { + if (!item || item.dead || item.type !== "zunchi") return false; + const rainy = worldRef.weather === "light_rain"; + const rainBoost = rainy ? 1.28 : 1; + const decayBoost = rainy ? 1.35 : 1; + let waterBoost = 0; + const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(item.x, item.y, 84) : (worldRef.items || []); + for (const it of nearby) { + if (it.type !== "water") continue; + waterBoost += clamp(1 - distXY(item.x, item.y, it.x, it.y) / 80, 0, 1); + } + item.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65); + const decayDt = (item.spawnGrace || 0) > 0 ? 0 : dt; + if (item.stage === "fresh") { + item.freshness = clamp(1 - item.stageTimer / 110, 0, 1); + item.amount -= decayDt * 0.018 * decayBoost; + if (item.stageTimer > 110) { item.stage = "dry"; item.stageTimer = 0; } + } else if (item.stage === "dry") { + item.freshness = clamp(0.55 - item.stageTimer / 220, 0.20, 0.55); + item.amount -= decayDt * 0.032 * decayBoost; + if (item.stageTimer > 150) { item.stage = "decomposing"; item.stageTimer = 0; item.fertility = 0.35; } + } else if (item.stage === "decomposing") { + item.fertility = clamp(item.fertility + dt * 0.015, 0, 1); + item.amount -= decayDt * 0.046 * decayBoost; + if (item.stageTimer > 190) { item.stage = "fertile_soil"; item.stageTimer = 0; item.amount = Math.min(item.amount, 140); } + } else if (item.stage === "fertile_soil") { + item.fertility = clamp(1 - item.stageTimer / 360, 0, 1); + item.amount -= decayDt * 0.090 * decayBoost; + } + return true; + } + + global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, resolveHighSpeedTarinaiCollision, updateLifecycle }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_decay_system.js b/js/item_lifecycle_decay_system.js new file mode 100644 index 0000000..de36516 --- /dev/null +++ b/js/item_lifecycle_decay_system.js @@ -0,0 +1,66 @@ +"use strict"; + +// Layer: entity-runtime/items/decay-system +// Owns passive amount decay and spoilage side effects for water, food, +// transient traces, splats, and ant corpses. The lifecycle step is now only a +// thin pipeline adapter. +(function (global) { + const MEDICINE_LIKE_FOOD_TYPES = Object.freeze([ + "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", + "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", + ]); + + function decayServingFood(item, dt, worldRef) { + if (typeof isServingFoodType !== "function" || !isServingFoodType(item.type)) return false; + const interval = passiveFoodDecayInterval(worldRef); + item.passiveFoodDecayTimer = (item.passiveFoodDecayTimer || 0) + dt; + let changed = false; + let ticks = 0; + while (item.passiveFoodDecayTimer >= interval && ticks < 3) { + item.passiveFoodDecayTimer -= interval; + ticks++; + if (Number.isFinite(item.foodServingsRemaining)) { + const before = Math.max(0, item.foodServingsRemaining || 0); + const lost = Math.min(before, interval * passiveFoodDecayRate(item)); + if (lost > 0) { + changed = true; + item.foodServingsRemaining = Math.max(0, before - lost); + item.amount = item.foodServingsRemaining; + const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING; + worldRef?.registerFoodSpoilage?.(lost * penalty, item); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true }); + if (item.foodServingsRemaining <= 0.015) item.amount = 0; + } + } else if (MEDICINE_LIKE_FOOD_TYPES.includes(item.type)) { + const before = item.amount || 0; + const lost = Math.min(before, interval * 0.12); + item.amount -= lost; + if (lost > 0) { + changed = true; + const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? 0.002; + worldRef?.registerFoodSpoilage?.(lost * penalty, item); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true }); + } + } + } + return changed; + } + + function update(item, dt, worldRef) { + if (!item || item.dead) return { done: true }; + if (item.type === "water") item.amount -= dt * 0.9; + decayServingFood(item, dt, worldRef); + if (item.type === "trace") item.amount -= dt * 1.35; + if (item.type === "splat") item.amount -= dt * 1.05; + if (item.type === "ant_corpse") item.amount -= dt * 0.018; + return { done: false }; + } + + global.TarinaiItemDecaySystem = Object.freeze({ + MEDICINE_LIKE_FOOD_TYPES, + decayServingFood, + update, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_growth_system.js b/js/item_lifecycle_growth_system.js new file mode 100644 index 0000000..fbf63fb --- /dev/null +++ b/js/item_lifecycle_growth_system.js @@ -0,0 +1,38 @@ +"use strict"; + +// Layer: entity-runtime/items/growth-system +// Owns passive grass normalization and zunchi lifecycle stage ticking. The +// lifecycle step is kept as a pipeline adapter only. +(function (global) { + function updateGrass(item) { + if (!item || item.dead || item.type !== "grass") return false; + // Grass is intentionally not simulated per item. World.update randomly + // advances one grass by one discrete stage every few seconds. + normalizeGrassStage(item); + return true; + } + + function updateZunchiLifecycle(item, dt, worldRef) { + if (!item || item.dead || item.type !== "zunchi") return false; + item.lifecycleTimer += dt; + const interval = item.lifecycleInterval || (0.95 + stableUnit(item.id, "zunchi-life") * 0.45); + if (item.lifecycleTimer < interval) return false; + const lifecycleDt = Math.min(item.lifecycleTimer, 2.4); + item.lifecycleTimer = 0; + global.TarinaiItemDynamicZunchiSystem?.updateLifecycle(item, lifecycleDt, worldRef); + return true; + } + + function update(item, dt, worldRef) { + if (!item || item.dead) return { done: true }; + if (item.type === "grass") updateGrass(item); + else if (item.type === "zunchi") updateZunchiLifecycle(item, dt, worldRef); + return { done: false }; + } + + global.TarinaiItemGrowthSystem = Object.freeze({ + updateGrass, + updateZunchiLifecycle, + update, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_legacy_system.js b/js/item_lifecycle_legacy_system.js new file mode 100644 index 0000000..81c78dd --- /dev/null +++ b/js/item_lifecycle_legacy_system.js @@ -0,0 +1,97 @@ +"use strict"; + +// Layer: entity-runtime/items/legacy-lifecycle +// Compatibility fallback for the pre-pipeline monolithic Item update body. +// Normal runtime goes through item_lifecycle_pipeline.js; this exists only so +// old callers and partial-load environments have a stable fallback. +(function (global) { + function updateOne(item, dt, worldRef = global.world) { + item.age += dt; + if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt); + if (item.dropTimer > 0) { + const beforeDrop = item.dropTimer; + item.dropTimer = Math.max(0, item.dropTimer - dt); + if (beforeDrop > 0 && item.dropTimer <= 0 && !item.dropImpactDone) { + item.dropImpactDone = true; + if (worldRef.itemDropImpact) worldRef.itemDropImpact(item); + } + } + if (item.type === "water") item.amount -= dt * 0.9; + if (isServingFoodType(item.type)) { + const interval = passiveFoodDecayInterval(worldRef); + item.passiveFoodDecayTimer = (item.passiveFoodDecayTimer || 0) + dt; + let ticks = 0; + while (item.passiveFoodDecayTimer >= interval && ticks < 3) { + item.passiveFoodDecayTimer -= interval; + ticks++; + if (Number.isFinite(item.foodServingsRemaining)) { + const before = Math.max(0, item.foodServingsRemaining || 0); + const lost = Math.min(before, interval * passiveFoodDecayRate(item)); + if (lost > 0) { + item.foodServingsRemaining = Math.max(0, before - lost); + item.amount = item.foodServingsRemaining; + const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING; + worldRef?.registerFoodSpoilage?.(lost * penalty, item); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true }); + if (item.foodServingsRemaining <= 0.015) item.amount = 0; + } + } else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(item.type)) { + const before = item.amount || 0; + const lost = Math.min(before, interval * 0.12); + item.amount -= lost; + if (lost > 0) { + const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? 0.002; + worldRef?.registerFoodSpoilage?.(lost * penalty, item); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true }); + } + } + } + } + if (item.type === "trace") item.amount -= dt * 1.35; + if (item.type === "splat") item.amount -= dt * 1.05; + if (item.type === "ant_corpse") item.amount -= dt * 0.018; + if (item.type === "firecracker") { + item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt); + if (item.fuseTimer <= 0) { + if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(item); + else item.amount = 0; + } + } + if (item.type === "genkotsu") { + item.impactFlash = Math.max(0, (item.impactFlash || 0) - dt); + if (item.dropImpactDone) { + item.lingerTimer = Math.max(0, (item.lingerTimer || 0) - dt); + if ((item.lingerTimer || 0) <= 0) item.amount = 0; + } else { + item.amount = Math.max(item.amount || 0, 100); + } + } + if (item.type === "ball") item.updateBall(dt, worldRef); + if (item.type === "fan") item.updateFan(dt, worldRef); + if (item.type === "magnet") item.updateMagnet(dt, worldRef); + if (item.type === "rotator") item.updateRotator(dt, worldRef); + if (item.type === "gate_fence") item.amount = 999; + if (item.type === "duplicator") item.updateDuplicator(dt, worldRef); + if (isPinType(item.type)) item.updatePushpin(dt, worldRef); + if (item.type === "zunchi") item.updateZunchiMotion(dt, worldRef); + if (item.type === "grass") { + // Grass is intentionally not simulated per item. World.update randomly + // advances one grass by one discrete stage every few seconds. + normalizeGrassStage(item); + } else if (item.type === "zunchi") { + item.lifecycleTimer += dt; + const interval = item.lifecycleInterval || (0.95 + stableUnit(item.id, "zunchi-life") * 0.45); + if (item.lifecycleTimer >= interval) { + const lifecycleDt = Math.min(item.lifecycleTimer, 2.4); + item.lifecycleTimer = 0; + item.updateZunchi(lifecycleDt, worldRef); + } + } + + return true; + } + + global.TarinaiItemLifecycleLegacySystem = Object.freeze({ updateOne }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_pipeline.js b/js/item_lifecycle_pipeline.js new file mode 100644 index 0000000..b2bad93 --- /dev/null +++ b/js/item_lifecycle_pipeline.js @@ -0,0 +1,52 @@ +"use strict"; + +// Layer: entity-runtime/items/pipeline +// Owns the ordered item lifecycle pipeline. Runtime calls this before falling +// back to the monolithic legacy Item.prototype.update body. Type-specific item +// methods still live on Item.prototype for compatibility while dispatch is moved +// into explicit lifecycle steps. +(function (global) { + const CONCRETE_STEP_NAMES = Object.freeze([ + "TarinaiItemFrameLifecycleStep", + "TarinaiItemDecayLifecycleStep", + "TarinaiItemDynamicLifecycleStep", + "TarinaiItemGrowthLifecycleStep", + ]); + + function concreteSteps() { + return CONCRETE_STEP_NAMES.map(name => global[name]).filter(step => step && typeof step.update === "function"); + } + + function canRunConcretePipeline() { + return concreteSteps().length === CONCRETE_STEP_NAMES.length; + } + + function runConcrete(item, dt, worldRef, context) { + for (const step of concreteSteps()) { + const result = step.update(item, dt, worldRef, context) || {}; + if (result.done) return result.ok !== false; + } + return true; + } + + function runLegacy(item, dt, worldRef, context) { + const step = global.TarinaiItemLegacyLifecycleStep; + if (step && typeof step.update === "function") return step.update(item, dt, worldRef, context).ok !== false; + const legacyUpdate = context.legacyUpdate; + if (typeof legacyUpdate !== "function") return false; + legacyUpdate.call(item, dt, worldRef); + return true; + } + + function updateOne(item, dt, worldRef, options = {}) { + if (!item || item.dead) return false; + const context = { legacyUpdate: options.legacyUpdate }; + return canRunConcretePipeline() ? runConcrete(item, dt, worldRef, context) : runLegacy(item, dt, worldRef, context); + } + + global.TarinaiItemLifecyclePipeline = Object.freeze({ + stepNames: CONCRETE_STEP_NAMES, + canRunConcretePipeline, + updateOne, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_runtime.js b/js/item_lifecycle_runtime.js index 853af79..19b1b69 100644 --- a/js/item_lifecycle_runtime.js +++ b/js/item_lifecycle_runtime.js @@ -1,929 +1,45 @@ "use strict"; -// Item update/lifecycle and type-specific runtime methods. - -const DUPLICATOR_STORABLE_TYPES = new Set([ - "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", - "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", - "zunda_juice", "grass", "water", "pushpin", "oshibyo" -]); - -function duplicatorLoadTypeForItem(item) { - if (!item || item.dead || item.type === "duplicator") return ""; - const type = String(item.type || ""); - if (!type || type === "water_bowl") return ""; - if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") return ""; - // Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が - // serving-food 判定や medicine role の揺れに左右されず複製機へ入る。 - if (DUPLICATOR_STORABLE_TYPES.has(type)) return type; - if (typeof isServingFoodType === "function" && isServingFoodType(type)) return type; - if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type; - const foodDef = window.TarinaiItemRegistry?.food?.get?.(type); - if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type; - if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type; - return ""; -} - - - -function itemAngleForRuntime(item) { - return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0); -} - -function fanAffectsPoint(fan, x, y) { - const angle = itemAngleForRuntime(fan); - const ux = Math.cos(angle); - const uy = Math.sin(angle); - const dx = x - (fan.x || 0); - const dy = y - (fan.y || 0); - const forward = dx * ux + dy * uy; - const side = -dx * uy + dy * ux; - const range = Math.max(170, (fan.r || 32) * 7.2); - const width = Math.max(42, (fan.r || 32) * (1.0 + Math.max(0, forward) / Math.max(1, range) * 3.1)); - if (forward < -(fan.r || 32) * 0.3 || forward > range || Math.abs(side) > width) return null; - const forwardFalloff = 1 - clamp(forward / range, 0, 1); - const sideFalloff = 1 - clamp(Math.abs(side) / width, 0, 1); - const strength = forwardFalloff * forwardFalloff * (0.25 + sideFalloff * 0.75); - return strength > 0.04 ? { ux, uy, strength, range, forward, side } : null; -} - -function fanApplyToBody(fan, body, dt, worldRef, scale = 1) { - if (!fan || !body || body.dead || body === fan || body.playerHeld || body._heldByPlayer) return false; - const hit = fanAffectsPoint(fan, body.x || 0, body.y || 0); - if (!hit) return false; - const force = Math.max(120, (fan.r || 32) * 5.6) * hit.strength * scale; - body.vx = clamp((body.vx || 0) + hit.ux * force * dt, -720, 720); - body.vy = clamp((body.vy || 0) + hit.uy * force * dt, -720, 720); - if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) { - body.impulseVx = clamp((body.impulseVx || 0) + hit.ux * force * dt * 0.42, -420, 420); - body.impulseVy = clamp((body.impulseVy || 0) + hit.uy * force * dt * 0.42, -420, 420); - if ((worldRef?.time || 0) >= (body.lastFanThoughtAt || -999) + 1.4) { - body.lastFanThoughtAt = worldRef?.time || 0; - body.thought = "風に流されている。"; - body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1); - } - } else { - body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x; - body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y; - if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + hit.side * 0.012 * force * dt, -48, 48); - } - return true; -} - -function isMagnetTargetItem(item) { - if (!item || item.dead) return false; - return item.type === "pushpin" || item.type === "oshibyo" || item.type === "ball"; -} - -function magnetPolePoint(magnet) { - const angle = itemAngleForRuntime(magnet); - return { x: (magnet.x || 0) + Math.cos(angle) * (magnet.r || 30) * 1.10, y: (magnet.y || 0) + Math.sin(angle) * (magnet.r || 30) * 1.10 }; -} - -function magnetApplyToBody(magnet, body, dt, worldRef, scale = 1) { - if (!magnet || !body || body.dead || body === magnet || body.playerHeld || body._heldByPlayer) return false; - const pole = magnetPolePoint(magnet); - const dx = pole.x - (body.x || 0); - const dy = pole.y - (body.y || 0); - const d = Math.max(1, Math.hypot(dx, dy)); - const range = Math.max(150, (magnet.r || 30) * 7.2); - if (d > range) return false; - const falloff = Math.pow(1 - d / range, 1.35); - const force = Math.max(180, (magnet.r || 30) * 7.8) * falloff * scale; - const nx = dx / d; - const ny = dy / d; - body.vx = clamp((body.vx || 0) + nx * force * dt, -740, 740); - body.vy = clamp((body.vy || 0) + ny * force * dt, -740, 740); - if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) { - body.impulseVx = clamp((body.impulseVx || 0) + nx * force * dt * 0.35, -420, 420); - body.impulseVy = clamp((body.impulseVy || 0) + ny * force * dt * 0.35, -420, 420); - if ((worldRef?.time || 0) >= (body.lastMagnetThoughtAt || -999) + 1.8) { - body.lastMagnetThoughtAt = worldRef?.time || 0; - body.thought = "磁石に引っぱられている。"; - body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1); - } - } else { - body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x; - body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y; - if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + (nx - ny) * force * dt * 0.08, -54, 54); - } - return true; -} - -const DUPLICATOR_LOAD_DWELL_SECONDS = 0.38; -const DUPLICATOR_REPLACE_DWELL_SECONDS = 0.70; - -function duplicatorLoadPoint(duplicator) { - const r = duplicator?.r || 34; - return { x: (duplicator?.x || 0) + r * 0.62, y: (duplicator?.y || 0) + r * 0.24 }; -} - -function duplicatorCandidateReach(item) { - return Math.max(20, Math.min(36, (item?.r || 12) * 0.82 + 13)); -} - -function duplicatorCanLoadNow(duplicator, worldRef) { - if (!duplicator || duplicator.type !== "duplicator") return false; - if (duplicator.playerHeld || duplicator._heldByPlayer) return false; - const now = Number(worldRef?.time || 0); - if (now < Number(duplicator.duplicatorLoadSuppressedUntil || 0)) return false; - if (Math.hypot(duplicator.vx || 0, duplicator.vy || 0) > 24) return false; - return true; -} - -function syncDuplicatorRoles(item) { - if (!item || item.type !== "duplicator") return; - const type = String(item.storedFoodType || ""); - const pinLoaded = Boolean(type && typeof isPinType === "function" && isPinType(type)); - item.roles.food = Boolean(type && !pinLoaded); - item.roles.medicine = Boolean(type && !pinLoaded && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))); - item.roles.drink = Boolean(!pinLoaded && (type === "water" || type === "zunda_juice")); - item.roles.danger = pinLoaded; -} - -function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, options = {}) { - if (!duplicator || duplicator.type !== "duplicator") return false; - const type = String(storedType || ""); - if (!type) return false; - const probe = { type, amount: 1, foodServingsRemaining: 1, dead: false }; - const loadType = duplicatorLoadTypeForItem(probe); - if (!loadType) return false; - const changed = duplicator.storedFoodType !== loadType; - if (!changed && duplicator.storedFoodType && !options.force) return true; - const def = typeof itemDefinition === "function" ? itemDefinition(loadType) : null; - const newLabel = def?.label || (typeof toolLabel === "function" ? toolLabel(loadType) : loadType); - duplicator.storedFoodType = loadType; - duplicator.storedFoodLabel = newLabel; - syncDuplicatorRoles(duplicator); - duplicator.loadedAt = Number(worldRef?.time || 0); - duplicator._duplicatorCandidateKey = ""; - duplicator._duplicatorCandidateSince = 0; - duplicator.duplicatorPinFlash = typeof isPinType === "function" && isPinType(loadType) ? 0.28 : (duplicator.duplicatorPinFlash || 0); - const consume = options.consumeItem; - if (consume && typeof consume === "object") { - consume.amount = 0; - consume.foodServingsRemaining = 0; - } - worldRef?.markItemBucketsDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); - worldRef?.markSpatialDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); - worldRef?.markTerrainDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); - if (options.emit !== false) worldRef?.emit?.("duplicator:loaded", { duplicator, foodType: loadType, replaced: changed, direct: Boolean(options.direct) }); - if (options.log !== false) worldRef?.log?.(`複製機に${duplicator.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(loadType) ? "observe" : "food"); - return true; -} - -if (typeof window !== "undefined") { - window.TarinaiDuplicatorRuntime = Object.freeze({ - loadTypeForItem: duplicatorLoadTypeForItem, - loadPoint: duplicatorLoadPoint, - setStoredType: duplicatorSetStoredType, - syncRoles: syncDuplicatorRoles, - }); -} - -function duplicatorApplyStoredPin(duplicator, worldRef) { - const storedType = String(duplicator?.storedFoodType || ""); - if (!duplicator || duplicator.type !== "duplicator" || !(typeof isPinType === "function" && isPinType(storedType))) return false; - const now = Number(worldRef?.time || 0); - if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false; - const dish = duplicatorLoadPoint(duplicator); - const reach = Math.max(24, (duplicator.r || 34) * 0.56); - const candidates = worldRef?.nearbyTarinai?.(dish.x, dish.y, reach + 72, true) || []; - for (const t of candidates) { - if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue; - if (t.stuckPushpinId || t.currentLodgedPin?.()) continue; - const hit = reach + Math.max(10, (t.radius || 18) * 0.78); - if (distXY(dish.x, dish.y, t.x, t.y) > hit) continue; - const pin = new Item(storedType, dish.x, dish.y); - pin.amount = 999; - pin.noStickUntil = 0; - pin.noStickTargetId = ""; - pin.noStickTargetUntil = 0; - pin.duplicatedFromId = duplicator.id || ""; - const added = worldRef?.addItem?.(pin, "duplicator-pin", { terrain: false }) || null; - if (!added && worldRef?.items) { - pin.world = worldRef; - worldRef.items.push(pin); - worldRef.markItemBucketsDirty?.("duplicator-pin"); - worldRef.markSpatialDirty?.("duplicator-pin"); - } - const ok = pin.attachPushpin?.(t, worldRef, distXY(dish.x, dish.y, t.x, t.y)); - if (!ok) { - pin.amount = 0; - continue; - } - duplicator.usedCount = (duplicator.usedCount || 0) + 1; - duplicator.nextDuplicatedPinAt = now + 0.82; - duplicator.duplicatorPinFlash = 0.24; - worldRef.effects?.push(new Effect("ring", dish.x, dish.y, { size: Math.max(15, (duplicator.r || 34) * 0.62), life: 0.18, color: storedType === "oshibyo" ? "rgba(73,119,205,0.40)" : "rgba(214,72,72,0.42)" })); - worldRef.drawListDirty = true; - worldRef.markSpatialDirty?.("duplicator-pin-lodged"); - return true; - } - return false; -} +// Layer: entity-runtime/items/compatibility +// Keeps legacy Item.prototype methods available while implementation ownership +// lives in lifecycle/dynamic systems. Object.assign(Item.prototype, { -update(dt, worldRef = world) { - this.age += dt; - if (this.type === "zunchi") this.spawnGrace = Math.max(0, (this.spawnGrace || 0) - dt); - if (this.dropTimer > 0) { - const beforeDrop = this.dropTimer; - this.dropTimer = Math.max(0, this.dropTimer - dt); - if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) { - this.dropImpactDone = true; - if (worldRef.itemDropImpact) worldRef.itemDropImpact(this); - } - } - if (this.type === "water") this.amount -= dt * 0.9; - if (isServingFoodType(this.type)) { - const interval = passiveFoodDecayInterval(worldRef); - this.passiveFoodDecayTimer = (this.passiveFoodDecayTimer || 0) + dt; - let ticks = 0; - while (this.passiveFoodDecayTimer >= interval && ticks < 3) { - this.passiveFoodDecayTimer -= interval; - ticks++; - if (Number.isFinite(this.foodServingsRemaining)) { - const before = Math.max(0, this.foodServingsRemaining || 0); - const lost = Math.min(before, interval * passiveFoodDecayRate(this)); - if (lost > 0) { - this.foodServingsRemaining = Math.max(0, before - lost); - this.amount = this.foodServingsRemaining; - const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING; - worldRef?.registerFoodSpoilage?.(lost * penalty, this); - worldRef?.markTerrainDirty?.("food-passive-decay"); - worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); - if (this.foodServingsRemaining <= 0.015) this.amount = 0; - } - } else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(this.type)) { - const before = this.amount || 0; - const lost = Math.min(before, interval * 0.12); - this.amount -= lost; - if (lost > 0) { - const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? 0.002; - worldRef?.registerFoodSpoilage?.(lost * penalty, this); - worldRef?.markTerrainDirty?.("food-passive-decay"); - worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); - } - } - } - } - if (this.type === "trace") this.amount -= dt * 1.35; - if (this.type === "splat") this.amount -= dt * 1.05; - if (this.type === "ant_corpse") this.amount -= dt * 0.018; - if (this.type === "firecracker") { - this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt); - if (this.fuseTimer <= 0) { - if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this); - else this.amount = 0; - } - } - if (this.type === "genkotsu") { - this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt); - if (this.dropImpactDone) { - this.lingerTimer = Math.max(0, (this.lingerTimer || 0) - dt); - if ((this.lingerTimer || 0) <= 0) this.amount = 0; - } else { - this.amount = Math.max(this.amount || 0, 100); - } - } - if (this.type === "ball") this.updateBall(dt, worldRef); - if (this.type === "fan") this.updateFan(dt, worldRef); - if (this.type === "magnet") this.updateMagnet(dt, worldRef); - if (this.type === "gate_fence") this.amount = 999; - if (this.type === "duplicator") this.updateDuplicator(dt, worldRef); - if (isPinType(this.type)) this.updatePushpin(dt, worldRef); - if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); - if (this.type === "grass") { - // Grass is intentionally not simulated per item. World.update randomly - // advances one grass by one discrete stage every few seconds. - normalizeGrassStage(this); - } else if (this.type === "zunchi") { - this.lifecycleTimer += dt; - const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45); - if (this.lifecycleTimer >= interval) { - const lifecycleDt = Math.min(this.lifecycleTimer, 2.4); - this.lifecycleTimer = 0; - this.updateZunchi(lifecycleDt, worldRef); - } - } - }, +update(dt, worldRef = world) { return window.TarinaiItemLifecycleLegacySystem?.updateOne?.(this, dt, worldRef) ?? false; }, +updateFan(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateFan?.(this, dt, worldRef); }, -updateFan(dt, worldRef) { - this.amount = 999; - if (!worldRef) return; - let changed = false; - const range = Math.max(190, (this.r || 32) * 7.4); - for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range + 80, true) || []) { - if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; - changed = fanApplyToBody(this, t, dt, worldRef, 1.0) || changed; - } - const kinematicTypes = new Set(["ball", "zunchi", "pushpin", "oshibyo"]); - for (const it of worldRef.nearbyItems?.(this.x, this.y, range + 80, true) || []) { - if (!it || it === this || it.dead || !kinematicTypes.has(it.type)) continue; - changed = fanApplyToBody(this, it, dt, worldRef, it.type === "ball" ? 1.12 : 0.82) || changed; - } - const now = worldRef.time || 0; - if (changed && (this.fanPulseAt || -999) + 0.12 <= now) { - this.fanPulseAt = now; - const angle = itemAngleForRuntime(this); - const x = this.x + Math.cos(angle) * (this.r || 32) * rand(0.8, 2.8); - const y = this.y + Math.sin(angle) * (this.r || 32) * rand(0.8, 2.8) + rand(-10, 10); - worldRef.effects?.push(new Effect("wind", x, y, { vx: Math.cos(angle) * rand(16, 40), vy: Math.sin(angle) * rand(16, 40), size: rand(8, 18), life: rand(0.18, 0.32), color: "rgba(160,210,245,0.28)" })); - } - if (changed) { - worldRef.drawListDirty = true; - worldRef.markSpatialDirty?.("fan-force"); - } - }, +updateMagnet(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateMagnet?.(this, dt, worldRef); }, -updateMagnet(dt, worldRef) { - this.amount = 999; - if (!worldRef) return; - let changed = false; - const range = Math.max(170, (this.r || 30) * 7.4); - for (const it of worldRef.nearbyItems?.(this.x, this.y, range + 80, true) || []) { - if (!isMagnetTargetItem(it)) continue; - if (it.type === "pushpin" || it.type === "oshibyo") { - if (it.pinState === "lodged") continue; - } - changed = magnetApplyToBody(this, it, dt, worldRef, it.type === "ball" ? 0.42 : 1.0) || changed; - } - for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range + 80, true) || []) { - if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; - const lodged = t.currentLodgedPin?.() || (worldRef.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === t.id); - if (!lodged) continue; - changed = magnetApplyToBody(this, t, dt, worldRef, 0.55) || changed; - } - const now = worldRef.time || 0; - if (changed && (this.magnetPulseAt || -999) + 0.18 <= now) { - this.magnetPulseAt = now; - const pole = magnetPolePoint(this); - worldRef.effects?.push(new Effect("ring", pole.x, pole.y, { size: Math.max(12, (this.r || 30) * 0.85), life: 0.16, color: "rgba(120,150,230,0.34)" })); - } - if (changed) { - worldRef.drawListDirty = true; - worldRef.markSpatialDirty?.("magnet-force"); - } - }, +updateRotator(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateRotator?.(this, dt, worldRef); }, -updateDuplicator(dt, worldRef) { - this.amount = 999; - syncDuplicatorRoles(this); - duplicatorApplyStoredPin(this, worldRef); - this.duplicatorPinFlash = Math.max(0, (this.duplicatorPinFlash || 0) - dt); - if (!worldRef?.nearbyItems) return; - if (!duplicatorCanLoadNow(this, worldRef)) { - this._duplicatorCandidateKey = ""; - this._duplicatorCandidateSince = 0; - return; - } +updateDuplicator(dt, worldRef) { return window.TarinaiItemDynamicDuplicatorSystem?.update?.(this, dt, worldRef); }, - const loadPoint = duplicatorLoadPoint(this); - const pickupRadius = Math.max(68, (this.r || 34) + 44); - const candidates = Array.from(worldRef.nearbyItems(this.x, this.y, pickupRadius, true) || []); - // SpatialGrid の分類漏れを補う。ただし、複製機からかなり近いものだけに限定し、 - // 移動中の複製機が周辺アイテムを吸い込む挙動を避ける。 - if (worldRef.items) { - for (const item of worldRef.items) { - if (!item || candidates.includes(item) || item === this || item.dead) continue; - const approxReach = pickupRadius + Math.max(16, item.r || 12); - if (distXY(this.x, this.y, item.x, item.y) <= approxReach) candidates.push(item); - } - } +updateZunchiMotion(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateMotion?.(this, dt, worldRef); }, - let best = null, bestType = "", bestSlotD = Infinity, bestScore = Infinity; - for (const it of candidates) { - if (!it || it === this || it.dead || it.playerHeld || it._heldByPlayer) continue; - const loadType = duplicatorLoadTypeForItem(it); - if (!loadType) continue; - if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue; - const slotD = distXY(loadPoint.x, loadPoint.y, it.x, it.y); - const centerD = distXY(this.x, this.y, it.x, it.y); - const slotReach = duplicatorCandidateReach(it); - const bodyReach = Math.max(22, (this.r || 34) * 0.46 + (it.r || 12) * 0.36); - if (slotD > slotReach && centerD > bodyReach) continue; - // 基本は投入口に近いものを優先。同距離なら置かれたばかりのものを優先し、 - // 複数候補があるときに意図しない古いアイテムへ寄りにくくする。 - const score = slotD + Math.max(0, Number(it.age || 0)) * 0.012; - if (score < bestScore) { - best = it; - bestType = loadType; - bestSlotD = slotD; - bestScore = score; - } - } +resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { return window.TarinaiItemDynamicZunchiSystem?.resolveHighSpeedTarinaiCollision?.(this, dt, worldRef, speed); }, - if (!best || !bestType) { - this._duplicatorCandidateKey = ""; - this._duplicatorCandidateSince = 0; - return; - } +updateBall(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.update?.(this, dt, worldRef); }, - const candidateKey = `${best.id || ""}:${bestType}`; - const now = Number(worldRef.time || 0); - if (this._duplicatorCandidateKey !== candidateKey) { - this._duplicatorCandidateKey = candidateKey; - this._duplicatorCandidateSince = now; - return; - } - const dwell = this.storedFoodType ? DUPLICATOR_REPLACE_DWELL_SECONDS : DUPLICATOR_LOAD_DWELL_SECONDS; - if (now - Number(this._duplicatorCandidateSince || 0) < dwell) return; +resolveBallObstacleCollisions(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(this, dt, worldRef); }, - const changed = this.storedFoodType !== bestType; - if (!changed && this.storedFoodType) { - this._duplicatorCandidateKey = ""; - this._duplicatorCandidateSince = 0; - return; - } +applyBallHayDrag(bed, dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.applyHayDrag?.(this, bed, dt, worldRef); }, - duplicatorSetStoredType(this, bestType, worldRef, { consumeItem: best, direct: false, force: false, log: false, emit: false }); - // Item.dead is a getter based on amount. Do not assign to it. - worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.emit?.("duplicator:loaded", { duplicator: this, foodType: this.storedFoodType, replaced: changed, slotDistance: bestSlotD }); - worldRef.log?.(`複製機に${this.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(this.storedFoodType) ? "observe" : "food"); - }, +resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveCircleBounce?.(this, obstacle, hitRadius, restitution, worldRef); }, -updateZunchiMotion(dt, worldRef) { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } - if (window.TarinaiPhysics?.applyKinematicItemMotion) { - window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 }); - } else { - this.prevX = this.x; - this.prevY = this.y; - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - const p = Math.max(28, CONFIG.worldPadding || 30); - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } - if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } - if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } - const slow = Math.pow(0.68, dt * 2.4); - this.vx *= slow; - this.vy *= slow; - worldRef.drawListDirty = true; - } - this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed); - this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14); - }, +resolveBallRectBounce(rect, worldRef, source = null) { return window.TarinaiItemDynamicBallSystem?.resolveRectBounce?.(this, rect, worldRef, source); }, -resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { - if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return; - const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18); - for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) { - if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; - const d = distXY(this.x, this.y, t.x, t.y); - if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue; - const now = worldRef.time || 0; - if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue; - this.lastHitTarinaiAt = this.lastHitTarinaiAt || {}; - this.lastHitTarinaiAt[t.id] = now; - const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1); - const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1); - const impulse = clamp(speed * 1.15, 150, 620); - t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20); - t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10); - t.fallTimer = Math.max(t.fallTimer || 0, 0.85); - t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer); - t.fallDir = nx < 0 ? -1 : 1; - if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" }); - else { - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); - t.fearTimer = Math.max(t.fearTimer || 0, 0.55); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true }); - if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); - } - worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); - worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); - this.vx *= -0.18; - this.vy *= -0.18; - break; - } - }, +emitBallBounce(worldRef, obstacle) { return window.TarinaiItemDynamicBallSystem?.emitBounce?.(this, worldRef, obstacle); }, -updateBall(dt, worldRef) { - this.prevX = this.x; - this.prevY = this.y; - const moving = Math.hypot(this.vx || 0, this.vy || 0); - if (moving > 0.04) { - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - const p = Math.max(28, CONFIG.worldPadding || 30); - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; } - if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } - if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } - this.resolveBallObstacleCollisions(dt, worldRef); - this.x = clamp(this.x, p, worldRef.w - p); - this.y = clamp(this.y, p, worldRef.h - p); - const groundFriction = Math.pow(0.72, dt); - this.vx *= groundFriction; - this.vy *= groundFriction; - } - this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18); - this.spinVelocity *= Math.pow(0.65, dt); - if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; } - }, +updatePushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.update?.(this, dt, worldRef); }, -resolveBallObstacleCollisions(dt, worldRef) { - if (!worldRef?.nearbyItems) return; - const speed = Math.hypot(this.vx || 0, this.vy || 0); - const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120); - const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || []; - for (const it of items) { - if (!it || it === this || it.dead) continue; - if (it.type === "bed") { - this.applyBallHayDrag(it, dt, worldRef); - } else if (it.type === "stone") { - this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef); - } - const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : []; - if (!rects.length) continue; - for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it); - } - }, +tryStickPushpin(worldRef) { return window.TarinaiItemDynamicPinSystem?.tryStick?.(this, worldRef); }, -applyBallHayDrag(bed, dt, worldRef) { - const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40); - const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34); - const dx = this.x - bed.x; - const dy = this.y - bed.y; - const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1; - if (!inside) return; - const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016)); - this.vx *= slow; - this.vy *= slow; - this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016)); - if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) { - this.lastHaySlowLogAt = worldRef.time || 0; - worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" })); - } - }, +attachPushpin(t, worldRef, d = null) { return window.TarinaiItemDynamicPinSystem?.attach?.(this, t, worldRef, d); }, -resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) { - let dx = this.x - obstacle.x; - let dy = this.y - obstacle.y; - let d = Math.hypot(dx, dy); - if (d >= hitRadius) { - const px = this.prevX; - const py = this.prevY; - if (!Number.isFinite(px) || !Number.isFinite(py)) return; - const sx = this.x - px; - const sy = this.y - py; - const len2 = sx * sx + sy * sy; - if (len2 <= 0.0001) return; - const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1); - const cx = px + sx * t; - const cy = py + sy * t; - dx = cx - obstacle.x; - dy = cy - obstacle.y; - d = Math.hypot(dx, dy); - if (d >= hitRadius) return; - if (d < 0.001) { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } - else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; } - } - } - if (d < 0.001) { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } - else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; } - } - const nx = dx / d; - const ny = dy / d; - const toward = (this.vx || 0) * nx + (this.vy || 0) * ny; - this.x = obstacle.x + nx * (hitRadius + 0.5); - this.y = obstacle.y + ny * (hitRadius + 0.5); - if (toward < 0) { - this.vx = (this.vx || 0) - (1 + restitution) * toward * nx; - this.vy = (this.vy || 0) - (1 + restitution) * toward * ny; - } else { - this.vx = (this.vx || 0) + nx * 24; - this.vy = (this.vy || 0) + ny * 24; - } - this.vx *= 0.96; - this.vy *= 0.96; - this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38); - this.emitBallBounce(worldRef, obstacle); - }, +updateLodgedPushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.updateLodged?.(this, dt, worldRef); }, -resolveBallRectBounce(rect, worldRef, source = null) { - if (!rect) return; - const hitRadius = (this.r || 18) + 2; - const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78; - const velocityDamp = rect.bounce ? 1.0 : 0.94; - const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0); - const ensureMinNormalSpeed = (nx, ny) => { - if (!(rect.bounce && minBounceSpeed > 0)) return; - const normalSpeed = (this.vx || 0) * nx + (this.vy || 0) * ny; - if (normalSpeed >= minBounceSpeed) return; - const add = minBounceSpeed - normalSpeed; - this.vx = (this.vx || 0) + nx * add; - this.vy = (this.vy || 0) + ny * add; - }; - const cx = clamp(this.x, rect.left, rect.right); - const cy = clamp(this.y, rect.top, rect.bottom); - let dx = this.x - cx; - let dy = this.y - cy; - let d = Math.hypot(dx, dy); - if (d >= hitRadius) { - const px = this.prevX; - const py = this.prevY; - const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius }; - if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return; - let nx = 0; - let ny = 0; - const vx = this.vx || 0; - const vy = this.vy || 0; - const candidates = []; - const sx = this.x - px; - const sy = this.y - py; - if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) }); - if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) }); - if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) }); - if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) }); - if (candidates.length) { - candidates.sort((a, b) => a.t - b.t); - nx = candidates[0].nx; - ny = candidates[0].ny; - } else if (Math.abs(vx) >= Math.abs(vy)) { - nx = vx >= 0 ? -1 : 1; - } else { - ny = vy >= 0 ? -1 : 1; - } - const toward = vx * nx + vy * ny; - if (nx < 0) this.x = expanded.left - 0.5; - else if (nx > 0) this.x = expanded.right + 0.5; - if (ny < 0) this.y = expanded.top - 0.5; - else if (ny > 0) this.y = expanded.bottom + 0.5; - if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom); - if (ny) this.x = clamp(this.x, expanded.left, expanded.right); - if (toward < 0) { - this.vx = vx - (1 + restitution) * toward * nx; - this.vy = vy - (1 + restitution) * toward * ny; - } else { - this.vx = vx + nx * 18; - this.vy = vy + ny * 18; - } - ensureMinNormalSpeed(nx, ny); - this.vx *= velocityDamp; - this.vy *= velocityDamp; - this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); - this.emitBallBounce(worldRef, source || rect.item || rect); - return; - } - if (d < 0.001) { - const left = Math.abs(this.x - rect.left); - const right = Math.abs(rect.right - this.x); - const top = Math.abs(this.y - rect.top); - const bottom = Math.abs(rect.bottom - this.y); - const m = Math.min(left, right, top, bottom); - if (m === left) { dx = -1; dy = 0; } - else if (m === right) { dx = 1; dy = 0; } - else if (m === top) { dx = 0; dy = -1; } - else { dx = 0; dy = 1; } - d = 1; - } - const nx = dx / d; - const ny = dy / d; - const toward = (this.vx || 0) * nx + (this.vy || 0) * ny; - this.x = cx + nx * (hitRadius + 0.5); - this.y = cy + ny * (hitRadius + 0.5); - if (toward < 0) { - this.vx = (this.vx || 0) - (1 + restitution) * toward * nx; - this.vy = (this.vy || 0) - (1 + restitution) * toward * ny; - } else { - this.vx = (this.vx || 0) + nx * 18; - this.vy = (this.vy || 0) + ny * 18; - } - ensureMinNormalSpeed(nx, ny); - this.vx *= velocityDamp; - this.vy *= velocityDamp; - this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); - this.emitBallBounce(worldRef, source || rect.item || rect); - }, +detachPushpin(worldRef, reason = "released") { return window.TarinaiItemDynamicPinSystem?.detach?.(this, worldRef, reason); }, -emitBallBounce(worldRef, obstacle) { - const now = worldRef?.time || 0; - if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return; - this.lastObstacleBounceAt = now; - const color = (obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"); - worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color })); - }, - -updatePushpin(dt, worldRef) { - if (!worldRef) return; - if (this.pinState === "lodged") { - this.updateLodgedPushpin(dt, worldRef); - return; - } - if (window.TarinaiPhysics?.applyKinematicItemMotion) { - window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 }); - } else { - this.prevX = this.x; - this.prevY = this.y; - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - } - this.tryStickPushpin(worldRef); - }, - -tryStickPushpin(worldRef) { - if (!worldRef?.tarinai?.length) return false; - if (this.pinState === "lodged") return false; - const now = worldRef.time || 0; - if ((this.noStickUntil || 0) > now) return false; - let best = null; - let bestD = Infinity; - const hitRange = Math.max(10, (this.r || 8) * 1.25); - const candidates = worldRef.nearbyTarinai?.(this.x, this.y, hitRange + 32, true) || worldRef.tarinai; - for (const t of candidates) { - if (!t || t.dead) continue; - if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue; - const d = distXY(this.x, this.y, t.x, t.y); - const hit = (t.radius || 16) * 0.86 + hitRange; - if (d <= hit && d < bestD) { best = t; bestD = d; } - } - if (!best) return false; - return this.attachPushpin(best, worldRef, bestD); - }, - -attachPushpin(t, worldRef, d = null) { - if (!t || t.dead) return false; - if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false; - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const isOshibyo = Boolean(behavior?.blocksZunchi); - const dx = this.x - t.x; - const dy = this.y - t.y; - const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); - this.pinState = "lodged"; - this.deletable = false; - this.pinTargetId = t.id; - const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = isOshibyo ? -faceDir : faceDir; - this.pinVisualSide = visualSide; - this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide); - this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); - this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); - this.pinDamageTick = 0; - this.pinFallCheckTimer = 0; - this.vx = 0; - this.vy = 0; - this.spinVelocity = 0; - this.spin = this.pinAttachAngle; - t.stuckPushpinId = this.id; - t.sleeping = false; - t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8); - if (isOshibyo) { - t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f"; - worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); - worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); - return true; - } - if (t.enterPanic) { - t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); - } else { - t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); - t.fearTimer = Math.max(t.fearTimer || 0, 1.3); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); - if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); - } - if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); - if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { - this.pinLogAt = worldRef.time || 0; - worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); - } - worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); - return true; - }, - -updateLodgedPushpin(dt, worldRef) { - const t = worldRef.liveTarinaiById?.(this.pinTargetId) || null; - if (!t) { - this.detachPushpin(worldRef, "owner-lost"); - return; - } - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const isOshibyo = Boolean(behavior?.blocksZunchi); - t.stuckPushpinId = this.id; - this.deletable = false; - const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = isOshibyo ? -faceDir : faceDir; - this.pinVisualSide = visualSide; - this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36; - this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04; - this.prevX = this.x; - this.prevY = this.y; - this.spin = visualSide * (isOshibyo ? 0.52 : 0.28); - this.vx = t.vx || 0; - this.vy = t.vy || 0; - if (isOshibyo) { - if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044"; - return; - } - this.pinDamageTick = (this.pinDamageTick || 0) + dt; - while (this.pinDamageTick >= 0.55) { - this.pinDamageTick -= 0.55; - if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); - t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); - if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); - if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); - } - t.sleeping = false; - if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); - else { - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); - t.fearTimer = Math.max(t.fearTimer || 0, 0.65); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); - t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); - } - this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt; - while (this.pinFallCheckTimer >= 1.0) { - this.pinFallCheckTimer -= 1.0; - if (Math.random() < 0.10) { - this.detachPushpin(worldRef, "fall"); - return; - } - } - }, - -detachPushpin(worldRef, reason = "released") { - const t = worldRef?.liveTarinaiById?.(this.pinTargetId) || null; - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const wasOshibyo = Boolean(behavior?.blocksZunchi); - const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0; - if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null; - if (t && wasOshibyo) t.oshiriByoZunchiStock = 0; - if (reason === "pinch") { - const now = worldRef?.time || 0; - this.noStickUntil = now + 1.0; - this.noStickTargetId = t?.id || ""; - this.noStickTargetUntil = now + 1.0; - } else if (t) { - const now = worldRef?.time || 0; - this.noStickTargetId = t.id || ""; - this.noStickTargetUntil = now + 0.55; - } - this.pinState = "loose"; - this.pinTargetId = ""; - this.pinDamageTick = 0; - this.pinFallCheckTimer = 0; - this.deletable = true; - if (reason === "pinch") { - this.vx = 0; - this.vy = 0; - this.spinVelocity = 0; - } else { - this.vx = rand(-24, 24); - this.vy = rand(-10, 18); - this.spinVelocity = rand(-1.6, 1.6); - this.spin += rand(-0.25, 0.25); - if (t) { - this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30)); - this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30)); - } - worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); - if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); - } - - if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { - worldRef.spawnBurstZunchi(t || this, storedZunchi, this); - } - }, - -updateZunchi(dt, worldRef) { - const rainy = worldRef.weather === "light_rain"; - const rainBoost = rainy ? 1.28 : 1; - const decayBoost = rainy ? 1.35 : 1; - let waterBoost = 0; - const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []); - for (const it of nearby) { - if (it.type !== "water") continue; - waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1); - } - this.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65); - const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt; - if (this.stage === "fresh") { - this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); - this.amount -= decayDt * 0.018 * decayBoost; - if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; } - } else if (this.stage === "dry") { - this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55); - this.amount -= decayDt * 0.032 * decayBoost; - if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; } - } else if (this.stage === "decomposing") { - this.fertility = clamp(this.fertility + dt * 0.015, 0, 1); - this.amount -= decayDt * 0.046 * decayBoost; - if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); } - } else if (this.stage === "fertile_soil") { - this.fertility = clamp(1 - this.stageTimer / 360, 0, 1); - this.amount -= decayDt * 0.090 * decayBoost; - } - } +updateZunchi(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateLifecycle?.(this, dt, worldRef); } }); diff --git a/js/item_lifecycle_step_decay.js b/js/item_lifecycle_step_decay.js new file mode 100644 index 0000000..3ed2f47 --- /dev/null +++ b/js/item_lifecycle_step_decay.js @@ -0,0 +1,13 @@ +"use strict"; + +// Layer: entity-runtime/items/lifecycle-step +// Pipeline adapter for item decay. The decay implementation lives in +// js/item_lifecycle_decay_system.js. +(function (global) { + function update(item, dt, worldRef) { + if (!item || item.dead) return { done: true }; + return global.TarinaiItemDecaySystem?.update(item, dt, worldRef) || { done: false }; + } + + global.TarinaiItemDecayLifecycleStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_step_dynamic.js b/js/item_lifecycle_step_dynamic.js new file mode 100644 index 0000000..3d9cac5 --- /dev/null +++ b/js/item_lifecycle_step_dynamic.js @@ -0,0 +1,14 @@ +"use strict"; + +// Layer: entity-runtime/items/lifecycle-step +// Owns the dynamic item lifecycle slot. Type-specific dispatch lives in +// js/item_dynamic_system.js and its family-specific submodules. +(function (global) { + function update(item, dt, worldRef) { + if (!item || item.dead) return { done: true }; + global.TarinaiItemDynamicSystem?.update(item, dt, worldRef); + return { done: false }; + } + + global.TarinaiItemDynamicLifecycleStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_step_frame.js b/js/item_lifecycle_step_frame.js new file mode 100644 index 0000000..90e4166 --- /dev/null +++ b/js/item_lifecycle_step_frame.js @@ -0,0 +1,23 @@ +"use strict"; + +// Layer: entity-runtime/items/lifecycle-step +// Owns per-item frame bookkeeping that is common across item types: age, +// zunchi spawn grace, and delayed drop impact handling. +(function (global) { + function update(item, dt, worldRef) { + if (!item || item.dead) return { done: true }; + item.age += dt; + if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt); + if (item.dropTimer > 0) { + const beforeDrop = item.dropTimer; + item.dropTimer = Math.max(0, item.dropTimer - dt); + if (beforeDrop > 0 && item.dropTimer <= 0 && !item.dropImpactDone) { + item.dropImpactDone = true; + if (worldRef?.itemDropImpact) worldRef.itemDropImpact(item); + } + } + return { done: false }; + } + + global.TarinaiItemFrameLifecycleStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_step_growth.js b/js/item_lifecycle_step_growth.js new file mode 100644 index 0000000..b9349f8 --- /dev/null +++ b/js/item_lifecycle_step_growth.js @@ -0,0 +1,13 @@ +"use strict"; + +// Layer: entity-runtime/items/lifecycle-step +// Pipeline adapter for item growth/lifecycle stage ticking. The implementation +// lives in js/item_lifecycle_growth_system.js. +(function (global) { + function update(item, dt, worldRef) { + if (!item || item.dead) return { done: true }; + return global.TarinaiItemGrowthSystem?.update(item, dt, worldRef) || { done: false }; + } + + global.TarinaiItemGrowthLifecycleStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_step_legacy.js b/js/item_lifecycle_step_legacy.js new file mode 100644 index 0000000..6b02e6c --- /dev/null +++ b/js/item_lifecycle_step_legacy.js @@ -0,0 +1,15 @@ +"use strict"; + +// Layer: entity-runtime/items/lifecycle-step +// Fallback step for environments where the split item lifecycle pipeline cannot +// run. The captured legacy Item.prototype.update is kept by item_runtime.js. +(function (global) { + function update(item, dt, worldRef, context = {}) { + const legacyUpdate = context.legacyUpdate; + if (!item || item.dead || typeof legacyUpdate !== "function") return { done: true, ok: false }; + legacyUpdate.call(item, dt, worldRef); + return { done: true, ok: true }; + } + + global.TarinaiItemLegacyLifecycleStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js new file mode 100644 index 0000000..2759982 --- /dev/null +++ b/js/item_lifecycle_support.js @@ -0,0 +1,246 @@ +"use strict"; + +// Layer: entity-runtime/items/support +// Owns shared lifecycle support helpers used by dynamic item systems and +// placement/tool commands. The compatibility runtime now imports these via +// globals instead of carrying helper implementations inline. +(function (global) { +const DUPLICATOR_STORABLE_TYPES = new Set([ + "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", + "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", + "zunda_juice", "grass", "water", "pushpin", "oshibyo" +]); + +function duplicatorLoadTypeForItem(item) { + if (!item || item.dead || item.type === "duplicator") return ""; + const type = String(item.type || ""); + if (!type || type === "water_bowl") return ""; + if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") return ""; + // Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が + // serving-food 判定や medicine role の揺れに左右されず複製機へ入る。 + if (DUPLICATOR_STORABLE_TYPES.has(type)) return type; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) return type; + if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type; + const foodDef = global.TarinaiItemRegistry?.food?.get?.(type); + if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type; + if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type; + return ""; +} + + + +function itemAngleForRuntime(item) { + return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0); +} + +function fanAffectsPoint(fan, x, y) { + const angle = itemAngleForRuntime(fan); + const ux = Math.cos(angle); + const uy = Math.sin(angle); + const dx = x - (fan.x || 0); + const dy = y - (fan.y || 0); + const forward = dx * ux + dy * uy; + const side = -dx * uy + dy * ux; + const range = Math.max(170, (fan.r || 32) * 7.2); + const width = Math.max(42, (fan.r || 32) * (1.0 + Math.max(0, forward) / Math.max(1, range) * 3.1)); + if (forward < -(fan.r || 32) * 0.3 || forward > range || Math.abs(side) > width) return null; + const forwardFalloff = 1 - clamp(forward / range, 0, 1); + const sideFalloff = 1 - clamp(Math.abs(side) / width, 0, 1); + const strength = forwardFalloff * forwardFalloff * (0.25 + sideFalloff * 0.75); + return strength > 0.04 ? { ux, uy, strength, range, forward, side } : null; +} + +function fanApplyToBody(fan, body, dt, worldRef, scale = 1) { + if (!fan || !body || body.dead || body === fan || body.playerHeld || body._heldByPlayer) return false; + const hit = fanAffectsPoint(fan, body.x || 0, body.y || 0); + if (!hit) return false; + const force = Math.max(120, (fan.r || 32) * 5.6) * hit.strength * scale; + body.vx = clamp((body.vx || 0) + hit.ux * force * dt, -720, 720); + body.vy = clamp((body.vy || 0) + hit.uy * force * dt, -720, 720); + if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) { + body.impulseVx = clamp((body.impulseVx || 0) + hit.ux * force * dt * 0.42, -420, 420); + body.impulseVy = clamp((body.impulseVy || 0) + hit.uy * force * dt * 0.42, -420, 420); + if ((worldRef?.time || 0) >= (body.lastFanThoughtAt || -999) + 1.4) { + body.lastFanThoughtAt = worldRef?.time || 0; + body.thought = "風に流されている。"; + body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1); + } + } else { + body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x; + body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y; + if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + hit.side * 0.012 * force * dt, -48, 48); + } + return true; +} + +function isMagnetTargetItem(item) { + if (!item || item.dead) return false; + return item.type === "pushpin" || item.type === "oshibyo" || item.type === "ball"; +} + +function magnetPolePoint(magnet) { + const angle = itemAngleForRuntime(magnet); + return { x: (magnet.x || 0) + Math.cos(angle) * (magnet.r || 30) * 1.10, y: (magnet.y || 0) + Math.sin(angle) * (magnet.r || 30) * 1.10 }; +} + +function magnetApplyToBody(magnet, body, dt, worldRef, scale = 1) { + if (!magnet || !body || body.dead || body === magnet || body.playerHeld || body._heldByPlayer) return false; + const pole = magnetPolePoint(magnet); + const dx = pole.x - (body.x || 0); + const dy = pole.y - (body.y || 0); + const d = Math.max(1, Math.hypot(dx, dy)); + const range = Math.max(150, (magnet.r || 30) * 7.2); + if (d > range) return false; + const falloff = Math.pow(1 - d / range, 1.35); + const force = Math.max(180, (magnet.r || 30) * 7.8) * falloff * scale; + const nx = dx / d; + const ny = dy / d; + body.vx = clamp((body.vx || 0) + nx * force * dt, -740, 740); + body.vy = clamp((body.vy || 0) + ny * force * dt, -740, 740); + if ((typeof Tarinai !== "undefined" && body instanceof Tarinai) || body.radius) { + body.impulseVx = clamp((body.impulseVx || 0) + nx * force * dt * 0.35, -420, 420); + body.impulseVy = clamp((body.impulseVy || 0) + ny * force * dt * 0.35, -420, 420); + if ((worldRef?.time || 0) >= (body.lastMagnetThoughtAt || -999) + 1.8) { + body.lastMagnetThoughtAt = worldRef?.time || 0; + body.thought = "磁石に引っぱられている。"; + body.pinchThoughtTimer = Math.max(body.pinchThoughtTimer || 0, 1.1); + } + } else { + body.prevX = Number.isFinite(body.prevX) ? body.prevX : body.x; + body.prevY = Number.isFinite(body.prevY) ? body.prevY : body.y; + if (Number.isFinite(body.spinVelocity)) body.spinVelocity = clamp((body.spinVelocity || 0) + (nx - ny) * force * dt * 0.08, -54, 54); + } + return true; +} + +const DUPLICATOR_LOAD_DWELL_SECONDS = 0.38; +const DUPLICATOR_REPLACE_DWELL_SECONDS = 0.70; + +function duplicatorLoadPoint(duplicator) { + const r = duplicator?.r || 34; + return { x: (duplicator?.x || 0) + r * 0.62, y: (duplicator?.y || 0) + r * 0.24 }; +} + +function duplicatorCandidateReach(item) { + return Math.max(20, Math.min(36, (item?.r || 12) * 0.82 + 13)); +} + +function duplicatorCanLoadNow(duplicator, worldRef) { + if (!duplicator || duplicator.type !== "duplicator") return false; + if (duplicator.playerHeld || duplicator._heldByPlayer) return false; + const now = Number(worldRef?.time || 0); + if (now < Number(duplicator.duplicatorLoadSuppressedUntil || 0)) return false; + if (Math.hypot(duplicator.vx || 0, duplicator.vy || 0) > 24) return false; + return true; +} + +function syncDuplicatorRoles(item) { + if (!item || item.type !== "duplicator") return; + const type = String(item.storedFoodType || ""); + const pinLoaded = Boolean(type && typeof isPinType === "function" && isPinType(type)); + item.roles.food = Boolean(type && !pinLoaded); + item.roles.medicine = Boolean(type && !pinLoaded && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))); + item.roles.drink = Boolean(!pinLoaded && (type === "water" || type === "zunda_juice")); + item.roles.danger = pinLoaded; +} + +function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, options = {}) { + if (!duplicator || duplicator.type !== "duplicator") return false; + const type = String(storedType || ""); + if (!type) return false; + const probe = { type, amount: 1, foodServingsRemaining: 1, dead: false }; + const loadType = duplicatorLoadTypeForItem(probe); + if (!loadType) return false; + const changed = duplicator.storedFoodType !== loadType; + if (!changed && duplicator.storedFoodType && !options.force) return true; + const def = typeof itemDefinition === "function" ? itemDefinition(loadType) : null; + const newLabel = def?.label || (typeof toolLabel === "function" ? toolLabel(loadType) : loadType); + duplicator.storedFoodType = loadType; + duplicator.storedFoodLabel = newLabel; + syncDuplicatorRoles(duplicator); + duplicator.loadedAt = Number(worldRef?.time || 0); + duplicator._duplicatorCandidateKey = ""; + duplicator._duplicatorCandidateSince = 0; + duplicator.duplicatorPinFlash = typeof isPinType === "function" && isPinType(loadType) ? 0.28 : (duplicator.duplicatorPinFlash || 0); + const consume = options.consumeItem; + if (consume && typeof consume === "object") { + consume.amount = 0; + consume.foodServingsRemaining = 0; + } + worldRef?.markItemBucketsDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); + worldRef?.markSpatialDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); + worldRef?.markTerrainDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); + if (options.emit !== false) worldRef?.emit?.("duplicator:loaded", { duplicator, foodType: loadType, replaced: changed, direct: Boolean(options.direct) }); + if (options.log !== false) worldRef?.log?.(`複製機に${duplicator.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(loadType) ? "observe" : "food"); + return true; +} + +global.TarinaiDuplicatorRuntime = Object.freeze({ + loadTypeForItem: duplicatorLoadTypeForItem, + loadPoint: duplicatorLoadPoint, + setStoredType: duplicatorSetStoredType, + syncRoles: syncDuplicatorRoles, + }); + +function duplicatorApplyStoredPin(duplicator, worldRef) { + const storedType = String(duplicator?.storedFoodType || ""); + if (!duplicator || duplicator.type !== "duplicator" || !(typeof isPinType === "function" && isPinType(storedType))) return false; + const now = Number(worldRef?.time || 0); + if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false; + const dish = duplicatorLoadPoint(duplicator); + const reach = Math.max(24, (duplicator.r || 34) * 0.56); + const candidates = worldRef?.nearbyTarinai?.(dish.x, dish.y, reach + 72, true) || []; + for (const t of candidates) { + if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue; + if (t.stuckPushpinId || t.currentLodgedPin?.()) continue; + const hit = reach + Math.max(10, (t.radius || 18) * 0.78); + if (distXY(dish.x, dish.y, t.x, t.y) > hit) continue; + const pin = new Item(storedType, dish.x, dish.y); + pin.amount = 999; + pin.noStickUntil = 0; + pin.noStickTargetId = ""; + pin.noStickTargetUntil = 0; + pin.duplicatedFromId = duplicator.id || ""; + const added = worldRef?.addItem?.(pin, "duplicator-pin", { terrain: false }) || null; + if (!added && worldRef?.items) { + pin.world = worldRef; + worldRef.items.push(pin); + worldRef.markItemBucketsDirty?.("duplicator-pin"); + worldRef.markSpatialDirty?.("duplicator-pin"); + } + const ok = pin.attachPushpin?.(t, worldRef, distXY(dish.x, dish.y, t.x, t.y)); + if (!ok) { + pin.amount = 0; + continue; + } + duplicator.usedCount = (duplicator.usedCount || 0) + 1; + duplicator.nextDuplicatedPinAt = now + 0.82; + duplicator.duplicatorPinFlash = 0.24; + worldRef.effects?.push(new Effect("ring", dish.x, dish.y, { size: Math.max(15, (duplicator.r || 34) * 0.62), life: 0.18, color: storedType === "oshibyo" ? "rgba(73,119,205,0.40)" : "rgba(214,72,72,0.42)" })); + worldRef.drawListDirty = true; + worldRef.markSpatialDirty?.("duplicator-pin-lodged"); + return true; + } + return false; +} + +global.TarinaiItemLifecycleRuntimeSupport = Object.freeze({ + itemAngleForRuntime, + fanAffectsPoint, + fanApplyToBody, + isMagnetTargetItem, + magnetPolePoint, + magnetApplyToBody, + duplicatorLoadTypeForItem, + duplicatorLoadPoint, + duplicatorCandidateReach, + duplicatorCanLoadNow, + syncDuplicatorRoles, + duplicatorSetStoredType, + duplicatorApplyStoredPin, + DUPLICATOR_LOAD_DWELL_SECONDS, + DUPLICATOR_REPLACE_DWELL_SECONDS, + }); + + +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_registry.js b/js/item_registry.js index de9a039..7e35632 100644 --- a/js/item_registry.js +++ b/js/item_registry.js @@ -5,6 +5,8 @@ const TOOL_DEFINITIONS = Object.freeze({ observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" }, delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" }, + area_delete: { id: "area_delete", label: "範囲削除", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "カーソル周囲の広い範囲にある置きものや汚れをまとめて消す。" }, + copy: { id: "copy", label: "コピー", placeable: false, scalable: false, iconText: "⧉", tooltip: "置いたアイテムをクリックしてコピーし、次のクリックで設定ごと貼り付ける。" }, poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" }, new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, @@ -43,8 +45,9 @@ const TOOL_DEFINITIONS = Object.freeze({ gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u89b3\u5bdf\u30e2\u30fc\u30c9\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, fan: { id: "fan", itemType: "fan", label: "\u6247\u98a8\u6a5f", placeable: true, scalable: true, radius: 32, amount: 999, collisionShape: "oriented_rect", tooltip: "\u524d\u65b9\u306b\u98a8\u3092\u51fa\u3057\u3001\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u62bc\u3059\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, magnet: { id: "magnet", itemType: "magnet", label: "\u78c1\u77f3", placeable: true, scalable: true, radius: 30, amount: 999, collisionShape: "oriented_rect", tooltip: "\u524d\u65b9\u306e\u6975\u306b\u753b\u92f2\u30fb\u304a\u3057\u308a\u92f2\u30fb\u30dc\u30fc\u30eb\u3092\u5f15\u304d\u5bc4\u305b\u308b\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + rotator: { id: "rotator", itemType: "rotator", label: "回転体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "↻", collisionShape: "custom_rotator", tooltip: "設置後に観察でクリックすると、回転速度と輪郭を編集できる。直線・自由描画で大きな回転物も作れる。" }, water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" }, - trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, + trace: { id: "trace", itemType: "trace", label: "死骸", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true }, food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true }, ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true }, @@ -52,17 +55,17 @@ const TOOL_DEFINITIONS = Object.freeze({ const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet"]), + obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]), food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), pin: new Set(["pushpin", "oshibyo"]), kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), sleepFurniture: new Set(["bed", "nest_box"]), - draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]), }); -const ROTATABLE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet"])); +const ROTATABLE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"])); function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); } function defaultItemAngle(type = "") { const t = String(type || ""); @@ -115,10 +118,10 @@ let EFFECT_REGISTRY = null; let EFFECT_DEFINITIONS = null; const TOOL_CATEGORIES = Object.freeze([ - { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, + { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "delete", "area_delete", "water_hose"] }, { id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] }, { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, - { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_h", "bounce_fence", "gate_fence", "fan", "magnet"] }, + { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_h", "bounce_fence", "gate_fence", "fan", "magnet", "rotator"] }, { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] }, ]); @@ -507,7 +510,7 @@ function toolTipsFromDefinitions() { if (context.source === "eating") { const nutrition = Number(context.nutrition) || 0; if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 }); - tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100); + tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100)); tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index e68a819..75f2c2d 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -685,6 +685,48 @@ draw(ctx, t, lighting = null) { ctx.fillText("N", this.r * 0.56, -this.r * 0.56); ctx.fillText("S", this.r * 0.56, this.r * 0.56); ctx.restore(); + } else if (this.type === "rotator") { + const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); + const speed = Number(this.rotatorSpeed || 0) || 0; + const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]])).slice(0, 96); + const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12)); + ctx.save(); + ctx.rotate(angle); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5"; + ctx.lineWidth = thick; + ctx.shadowColor = styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)"; + ctx.shadowBlur = Math.max(4, thick * 0.28); + ctx.beginPath(); + for (const seg of segments) { + if (!Array.isArray(seg) || seg.length < 4) continue; + ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); + ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); + } + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.strokeStyle = styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)"; + ctx.lineWidth = Math.max(1.4, thick * 0.22); + ctx.beginPath(); + for (const seg of segments) { + if (!Array.isArray(seg) || seg.length < 4) continue; + ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0); + ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0); + } + ctx.stroke(); + ctx.restore(); + ctx.fillStyle = styleNight ? "#3c324e" : "#f4eaff"; + ctx.strokeStyle = styleNight ? "#a28cc8" : "#6e4fa4"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.arc(0, 0, Math.max(8, thick * 0.72), 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = styleNight ? "#d8c5ff" : "#6e4fa4"; + ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(speed >= 0 ? "↻" : "↺", 0, 0); } else if (this.type === "zunchi") { const zunchiId = this.zunchiVariant || "zunchi"; const img = images.get(zunchiId) || images.get("zunchi"); diff --git a/js/item_runtime.js b/js/item_runtime.js new file mode 100644 index 0000000..29ad5a8 --- /dev/null +++ b/js/item_runtime.js @@ -0,0 +1,80 @@ +"use strict"; + +// Layer: entity-runtime/items +// Owns the public item update boundary. Item.prototype.update remains available +// for legacy callers, but it now routes through TarinaiItemRuntime so callers do +// not need to know where the lifecycle implementation lives. +(function (global) { + let lifecycleUpdate = null; + + function installPrototypeBoundary() { + const proto = global.Item?.prototype; + if (!proto || proto._tarinaiItemRuntimeInstalled) return false; + lifecycleUpdate = proto.update || lifecycleUpdate; + if (typeof lifecycleUpdate === "function" && !proto._tarinaiLifecycleUpdate) { + Object.defineProperty(proto, "_tarinaiLifecycleUpdate", { + value: lifecycleUpdate, + configurable: true, + writable: true, + }); + } + proto.update = function update(dt, worldRef = global.world) { + return global.TarinaiItemRuntime.updateOne(this, dt, worldRef); + }; + Object.defineProperty(proto, "_tarinaiItemRuntimeInstalled", { + value: true, + configurable: true, + writable: true, + }); + return true; + } + + function lifecycleFor(item) { + return item?._tarinaiLifecycleUpdate || lifecycleUpdate; + } + + function updateOne(item, dt, worldRef = global.world) { + const fn = lifecycleFor(item); + if (!item || item.dead || typeof fn !== "function") return false; + if (global.TarinaiItemLifecyclePipeline?.updateOne) { + return global.TarinaiItemLifecyclePipeline.updateOne(item, dt, worldRef, { legacyUpdate: fn }) !== false; + } + fn.call(item, dt, worldRef); + return true; + } + + function updateScheduled(worldRef, dt) { + if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt); + const helpers = global.TarinaiSimulationRuntime?.helpers; + if (!helpers?.forScheduledItems || !helpers?.itemUpdateInterval) return 0; + let ran = 0; + helpers.forScheduledItems(worldRef, (item) => { + const interval = helpers.itemUpdateInterval(item); + if (!Number.isFinite(interval)) return; + if (interval <= 0) { + if (updateOne(item, dt, worldRef)) ran += 1; + return; + } + item._scheduledUpdateDt = Math.min(10, (item._scheduledUpdateDt || 0) + dt); + if (item._scheduledUpdateDt < interval) return; + const scheduledDt = item._scheduledUpdateDt; + item._scheduledUpdateDt = 0; + if (updateOne(item, scheduledDt, worldRef)) ran += 1; + }); + return ran; + } + + function trackedDynamicItems(worldRef) { + return global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef) || worldRef?._itemUpdateRealtime || []; + } + + const api = Object.freeze({ + installPrototypeBoundary, + updateOne, + updateScheduled, + trackedDynamicItems, + }); + + global.TarinaiItemRuntime = api; + installPrototypeBoundary(); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 533cd2f..baa9a2d 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -117,6 +117,13 @@ function initializeItemTypeState(item, type, x, y) { if (type === "magnet") { item.magnetPulseAt = rand(0, 0.4); } + if (type === "rotator") { + item.rotatorSpeed = Math.PI * 0.65; + item.rotatorThickness = 12; + item.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]]; + item.rotatorEditorOpenAt = -999; + item.amount = 999; + } if (isServingFoodType(type)) { item.toolSize = "medium"; diff --git a/js/item_update_policy.js b/js/item_update_policy.js new file mode 100644 index 0000000..3c4ecfa --- /dev/null +++ b/js/item_update_policy.js @@ -0,0 +1,82 @@ +"use strict"; + +// Layer: entity-runtime/items/policy +// Owns the item update cadence and scheduled-item candidate set. Simulation +// helpers and the item scheduler both read this single policy instead of +// duplicating item-type timing rules. +(function (global) { + const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "fan", "magnet", "rotator"]); + const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); + const SCHEDULED_ITEM_TYPES = Object.freeze([ + "ball", "genkotsu", "firecracker", "pushpin", "oshibyo", + "plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food", + "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", + "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator", "water_bowl", + ]); + + function itemUpdateInterval(item) { + if (!item || item.dead) return Infinity; + if (REALTIME_ITEM_TYPES.has(item.type)) return 0; + if (PIN_ITEM_TYPES.has(item.type)) return 0; + if (item.isStructure && item.type === "plushie") return 0; + if (item.isStructure && item.type === "grass_bed") return 3.0; + if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0; + if (item.type === "zunchi") return 2.4; + if (item.type === "grass") return Infinity; + if (item.type === "duplicator") return 0.5; + if (item.type === "water") return 1.8; + if (item.type === "trace" || item.type === "splat") return 1.2; + if (item.type === "ant_corpse") return 3.5; + if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return 5.0; + if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0; + return Infinity; + } + + function ensureBuckets(worldRef) { + if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-policy"); + } + + function forScheduledItems(worldRef, visitor) { + if (!worldRef || typeof visitor !== "function") return 0; + let count = 0; + if (!worldRef.itemsOfType) { + for (const it of worldRef.items || []) { + if (!it || it.dead || it.type === "grass") continue; + visitor(it); + count += 1; + } + return count; + } + ensureBuckets(worldRef); + for (const type of SCHEDULED_ITEM_TYPES) { + const bucket = worldRef.itemsOfType(type); + for (const it of bucket || []) { + if (!it || it.dead) continue; + visitor(it); + count += 1; + } + } + return count; + } + + function candidateItems(worldRef) { + const out = []; + forScheduledItems(worldRef, (item) => out.push(item)); + return out; + } + + function isRealtime(item) { + return itemUpdateInterval(item) <= 0; + } + + global.TarinaiItemUpdatePolicy = Object.freeze({ + REALTIME_ITEM_TYPES, + PIN_ITEM_TYPES, + SCHEDULED_ITEM_TYPES, + itemUpdateInterval, + forScheduledItems, + ensureBuckets, + candidateItems, + isRealtime, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_update_scheduler.js b/js/item_update_scheduler.js index 56bd0f1..7488fd4 100644 --- a/js/item_update_scheduler.js +++ b/js/item_update_scheduler.js @@ -1,30 +1,22 @@ "use strict"; (function (global) { - const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "fan", "magnet"]); - const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); - const SCHEDULED_ITEM_TYPES = [ - "ball", "genkotsu", "firecracker", "pushpin", "oshibyo", - "plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food", - "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", - "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", - ]; + const policy = () => global.TarinaiItemUpdatePolicy || {}; function itemUpdateInterval(item) { - if (!item || item.dead) return Infinity; - if (REALTIME_ITEM_TYPES.has(item.type)) return 0; - if (PIN_ITEM_TYPES.has(item.type)) return 0; - if (item.isStructure && item.type === "plushie") return 0; - if (item.isStructure && item.type === "grass_bed") return 3.0; - if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0; - if (item.type === "zunchi") return 2.4; - if (item.type === "grass") return Infinity; - if (item.type === "duplicator") return 0.5; - if (item.type === "water") return 1.8; - if (item.type === "trace" || item.type === "splat") return 1.2; - if (item.type === "ant_corpse") return 3.5; - if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0; - return Infinity; + const interval = policy().itemUpdateInterval?.(item); + return Number.isFinite(interval) ? interval : Infinity; + } + + function candidateItems(worldRef) { + if (policy().candidateItems) return policy().candidateItems(worldRef); + return (worldRef?.items || []).filter(it => it && !it.dead && it.type !== "grass"); + } + + function ensureBuckets(worldRef) { + if (policy().ensureBuckets) return policy().ensureBuckets(worldRef); + if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-scheduler"); + return undefined; } function heapPush(heap, entry) { @@ -66,19 +58,6 @@ ].join(":"); } - function ensureBuckets(worldRef) { - if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-scheduler"); - } - - function candidateItems(worldRef) { - if (!worldRef?.itemsOfType) return (worldRef.items || []).filter(it => it && !it.dead && it.type !== "grass"); - const out = []; - for (const type of SCHEDULED_ITEM_TYPES) { - const bucket = worldRef.itemsOfType(type) || []; - for (const it of bucket) if (it && !it.dead) out.push(it); - } - return out; - } function scheduleItem(state, item, now, interval = itemUpdateInterval(item)) { if (!item || item.dead || !Number.isFinite(interval)) return false; @@ -132,7 +111,8 @@ continue; } if (interval <= 0) { - item.update(dt, worldRef); + if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(item, dt, worldRef); + else item.update(dt, worldRef); item._schedulerLastAt = now; ran += 1; if (Number.isFinite(itemUpdateInterval(item)) && itemUpdateInterval(item) <= 0) nextRealtime.push(item); @@ -163,7 +143,8 @@ continue; } const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval))); - item.update(elapsed, worldRef); + if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef); + else item.update(elapsed, worldRef); item._schedulerLastAt = now; ran += 1; dueRuns += 1; diff --git a/js/items.js b/js/items.js index ab5d286..96dcefa 100644 --- a/js/items.js +++ b/js/items.js @@ -427,6 +427,7 @@ function previewItemRadiusFor(type = "") { if (type === "genkotsu") return 30; if (type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") return 24; if (type === "fan" || type === "magnet") return 24; + if (type === "rotator") return 25; if (type === "nest_box") return 26; if (type === "duplicator") return 24; if (type === "signboard") return 22; diff --git a/js/physics_helpers.js b/js/physics_helpers.js index 35523d1..14868b5 100644 --- a/js/physics_helpers.js +++ b/js/physics_helpers.js @@ -84,6 +84,15 @@ obj.vy = (obj.vy || 0) + ny * add; } } + if (rect.rotator) { + const omega = clamp(Number(rect.angularVelocity || 0) || 0, -8, 8); + const rx = obj.x - (rect.centerX ?? rect.item?.x ?? rect.cx ?? 0); + const ry = obj.y - (rect.centerY ?? rect.item?.y ?? rect.cy ?? 0); + const tvx = -ry * omega; + const tvy = rx * omega; + obj.vx = clamp((obj.vx || 0) + tvx * 0.28 + nx * 20, -900, 900); + obj.vy = clamp((obj.vy || 0) + tvy * 0.28 + ny * 20, -900, 900); + } if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46); return true; } diff --git a/js/render.js b/js/render.js index 569bd20..6fec8a2 100644 --- a/js/render.js +++ b/js/render.js @@ -205,6 +205,14 @@ function placementPreviewFor(world) { const influence = type === "fan" ? world.fanInfluenceShape?.(tmp) : world.magnetInfluenceShape?.(tmp); return { type, x, y, r, rect: rawRect, influence, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) }; } + if (type === "rotator" && world.rotatorObstacleRects) { + tmp.rotatorSpeed = Math.PI * 0.65; + tmp.rotatorThickness = 12; + tmp.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]]; + const rects = world.rotatorObstacleRects(tmp) || []; + const bounds = world.rotatorBoundsAabb?.(tmp) || null; + return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; + } if (type === "nest_box" && world.nestBoxBaseRect) { const rect = world.nestBoxBaseRect(tmp); return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) }; @@ -219,7 +227,7 @@ function placementPreviewFor(world) { function drawPlacementPreview(ctx, world) { const preview = placementPreviewFor(world); if (!preview) return; - const { type, x, y, r, rect, influence, blocked } = preview; + const { type, x, y, r, rect, rects, influence, blocked } = preview; const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10; ctx.save(); ctx.globalAlpha = blocked ? 0.44 : 0.72; @@ -228,20 +236,25 @@ function drawPlacementPreview(ctx, world) { ctx.strokeStyle = blocked ? "rgba(194,70,58,0.86)" : "rgba(110,178,55,0.88)"; ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)"; if (influence) drawDeviceInfluenceShape(ctx, influence, blocked, 1.15); - if (rect) { - if (rect.oriented) { + const drawRectPreview = (rr) => { + if (!rr) return; + if (rr.oriented) { ctx.save(); - ctx.translate(rect.cx || x, rect.cy || y); - ctx.rotate(rect.angle || 0); - roundedRect(ctx, -(rect.halfW || (rect.right - rect.left) / 2), -(rect.halfH || (rect.bottom - rect.top) / 2), (rect.halfW || (rect.right - rect.left) / 2) * 2, (rect.halfH || (rect.bottom - rect.top) / 2) * 2, 8); + ctx.translate(rr.cx || x, rr.cy || y); + ctx.rotate(rr.angle || 0); + roundedRect(ctx, -(rr.halfW || (rr.right - rr.left) / 2), -(rr.halfH || (rr.bottom - rr.top) / 2), (rr.halfW || (rr.right - rr.left) / 2) * 2, (rr.halfH || (rr.bottom - rr.top) / 2) * 2, Math.min(8, Math.max(2, rr.halfH || 4))); ctx.fill(); ctx.stroke(); ctx.restore(); } else { - roundedRect(ctx, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, 8); + roundedRect(ctx, rr.left, rr.top, rr.right - rr.left, rr.bottom - rr.top, 8); ctx.fill(); ctx.stroke(); } + }; + if (rect || (rects && rects.length)) { + if (rects && rects.length) for (const rr of rects) drawRectPreview(rr); + else drawRectPreview(rect); if (type === "nest_box" && world.nestBoxSolidRects) { ctx.setLineDash([3, 4]); ctx.strokeStyle = blocked ? "rgba(194,70,58,0.38)" : "rgba(110,178,55,0.42)"; @@ -271,6 +284,67 @@ function drawPlacementPreview(ctx, world) { ctx.restore(); } + +function drawOperationToolPreview(ctx, world) { + const p = world?.pointer; + if (!p?.inside) return; + if (world?.tool === "copy" && world.copyBuffer) { + const type = world.copyBuffer.type || ""; + const item = world.copyPreviewItemAt?.(p.x, p.y); + if (item) { + ctx.save(); + ctx.globalAlpha = 0.50; + ctx.setLineDash([7, 5]); + ctx.lineWidth = 2; + ctx.strokeStyle = "rgba(128,92,198,0.86)"; + ctx.fillStyle = "rgba(150,110,220,0.12)"; + const rects = world.solidObstacleRects?.(item) || []; + if (rects.length) { + for (const rr of rects) { + if (rr.oriented) { + ctx.save(); ctx.translate(rr.cx, rr.cy); ctx.rotate(rr.angle || 0); + roundedRect(ctx, -(rr.halfW || 10), -(rr.halfH || 5), (rr.halfW || 10) * 2, (rr.halfH || 5) * 2, 6); + ctx.fill(); ctx.stroke(); ctx.restore(); + } else { roundedRect(ctx, rr.left, rr.top, rr.right - rr.left, rr.bottom - rr.top, 6); ctx.fill(); ctx.stroke(); } + } + } else { + const r = Math.max(10, item.r || itemRadiusFor?.(type, 16) || 16); + ctx.beginPath(); ctx.ellipse(p.x, p.y, r * 1.45, r * 0.95, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(p.x - 9, p.y); ctx.lineTo(p.x + 9, p.y); ctx.moveTo(p.x, p.y - 9); ctx.lineTo(p.x, p.y + 9); ctx.stroke(); + ctx.restore(); + } + return; + } + if (world?.tool !== "area_delete") return; + const radius = world.areaDeleteRadius?.() || 138; + ctx.save(); + ctx.globalAlpha = 0.42; + ctx.lineWidth = 2.2; + ctx.setLineDash([8, 7]); + ctx.strokeStyle = "rgba(218,82,70,0.82)"; + ctx.fillStyle = "rgba(218,82,70,0.10)"; + ctx.beginPath(); + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.setLineDash([3, 8]); + ctx.globalAlpha = 0.34; + ctx.beginPath(); + ctx.arc(p.x, p.y, radius * 0.58, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 0.70; + ctx.beginPath(); + ctx.moveTo(p.x - 10, p.y); + ctx.lineTo(p.x + 10, p.y); + ctx.moveTo(p.x, p.y - 10); + ctx.lineTo(p.x, p.y + 10); + ctx.stroke(); + ctx.restore(); +} + function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) { if (tarinai.dead || tarinai.world.selected !== tarinai) return; ctx.save(); @@ -723,8 +797,8 @@ function drawGardenBed(w, h, lighting) { const groundType = world.groundType || "soil"; const x = 18, y = 22, bw = w - 36, bh = h - 44; const border = activeCtx.createLinearGradient(0, y, 0, y + bh); - const borderTop = groundType === "ice" ? "#bfe6f4" : groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#79a9cf" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66"); - const borderBottom = groundType === "ice" ? "#77b7d2" : groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#4f79a3" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b"); + const borderTop = groundType === "ice" ? "#bfe6f4" : groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#79a9cf" : groundType === "dirt" ? "#8fb06a" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66"); + const borderBottom = groundType === "ice" ? "#77b7d2" : groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#4f79a3" : groundType === "dirt" ? "#5f7c43" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b"); border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72"); border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68"); activeCtx.fillStyle = border; @@ -748,6 +822,8 @@ function drawGardenBed(w, h, lighting) { ? ["#dff8ff", "#b8e7f5", "#8bcfe4"] : groundType === "foot_massage" ? ["#d2e6f4", "#a8c8e6", "#7fa9cd"] + : groundType === "dirt" + ? [lighting.goldenStrength > 0.25 ? "#d4b47d" : "#c9a46d", lighting.goldenStrength > 0.25 ? "#b89261" : "#a87948", lighting.goldenStrength > 0.25 ? "#927046" : "#7b5a38"] : fieldType === "park" ? ["#cfe5a8", "#b8d990", "#91bd74"] : fieldType === "cage" @@ -765,12 +841,12 @@ function drawGardenBed(w, h, lighting) { roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28); activeCtx.clip(); - const speckCount = groundType === "ice" ? 54 : groundType === "blanket" ? 28 : groundType === "foot_massage" ? 180 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46; + const speckCount = groundType === "ice" ? 54 : groundType === "blanket" ? 28 : groundType === "foot_massage" ? 180 : groundType === "dirt" ? 96 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46; for (let i = 0; i < speckCount; i++) { const px = randSeed(900 + i, x + 24, x + bw - 24); const py = randSeed(1200 + i, y + 22, y + bh - 24); - const r = randSeed(1600 + i, 1.2, groundType === "foot_massage" ? 5.2 : 3.8); - const a = randSeed(1900 + i, groundType === "foot_massage" ? 0.065 : 0.03, groundType === "foot_massage" ? 0.18 : 0.09) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70); + const r = randSeed(1600 + i, 1.2, groundType === "foot_massage" ? 5.2 : (groundType === "dirt" ? 4.6 : 3.8)); + const a = randSeed(1900 + i, groundType === "foot_massage" ? 0.065 : (groundType === "dirt" ? 0.045 : 0.03), groundType === "foot_massage" ? 0.18 : (groundType === "dirt" ? 0.13 : 0.09)) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70); if (groundType === "foot_massage") { const warm = i % 5 === 0; activeCtx.fillStyle = warm @@ -780,7 +856,7 @@ function drawGardenBed(w, h, lighting) { activeCtx.ellipse(px, py, r * randSeed(3100 + i, 1.1, 2.0), r * randSeed(3400 + i, 0.65, 1.25), randSeed(3700 + i, -1.2, 1.2), 0, Math.PI * 2); activeCtx.fill(); } else { - activeCtx.fillStyle = groundType === "blanket" ? `rgba(255,255,255,${a * 1.35})` : (groundType === "ice" ? `rgba(255,255,255,${a * 1.65})` : (groundType === "foot_massage" ? `rgba(${Math.round(randSeed(2000 + i, 86, 128))}, ${Math.round(randSeed(2300 + i, 142, 196))}, ${Math.round(randSeed(2600 + i, 186, 235))}, ${a})` : `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`)); + activeCtx.fillStyle = groundType === "blanket" ? `rgba(255,255,255,${a * 1.35})` : (groundType === "ice" ? `rgba(255,255,255,${a * 1.65})` : (groundType === "dirt" ? `rgba(${Math.round(randSeed(2000 + i, 94, 132))}, ${Math.round(randSeed(2300 + i, 66, 104))}, ${Math.round(randSeed(2600 + i, 38, 70))}, ${a})` : `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`)); activeCtx.beginPath(); activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2); activeCtx.fill(); @@ -823,6 +899,24 @@ function drawGardenBed(w, h, lighting) { activeCtx.restore(); } + if (groundType === "dirt") { + activeCtx.save(); + activeCtx.globalAlpha = light > 0.42 ? 0.16 : 0.10; + activeCtx.strokeStyle = "rgba(72, 52, 32, 0.34)"; + activeCtx.lineWidth = 1.0; + for (let i = 0; i < 34; i++) { + const px = randSeed(10100 + i, x + 30, x + bw - 30); + const py = randSeed(10400 + i, y + 30, y + bh - 32); + const len = randSeed(10700 + i, 8, 24); + const a = randSeed(11000 + i, -1.4, 1.4); + activeCtx.beginPath(); + activeCtx.moveTo(px - Math.cos(a) * len * 0.45, py - Math.sin(a) * len * 0.45); + activeCtx.lineTo(px + Math.cos(a) * len * 0.55, py + Math.sin(a) * len * 0.55); + activeCtx.stroke(); + } + activeCtx.restore(); + } + if (groundType === "blanket") { activeCtx.save(); activeCtx.strokeStyle = light > 0.42 ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.16)"; @@ -1099,6 +1193,7 @@ function render() { beginFieldTransform(); drawPlacementPreview(ctx, world); + drawOperationToolPreview(ctx, world); drawTerrainLayer(ctx, world, lighting, visibleRect); // Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap. @@ -1270,7 +1365,7 @@ function drawSelectedCard(ctx, world, lighting) { const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : ""; ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35); const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : ""; - ctx.fillText(`\u7dcf\u5408\u7684\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52); + ctx.fillText(`\u7dcf\u5408\u7684\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}/${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, x + 10, y + 52); ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68); ctx.restore(); } diff --git a/js/save_codec.js b/js/save_codec.js index 6b9f715..369deb7 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -2,24 +2,23 @@ (function (global) { const Snapshot = global.TarinaiSnapshot; + const SaveSchema = global.TarinaiSaveSchema; if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js"); + if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for save_codec.js"); const EXPORT_PREFIX = "たり"; const RAW_CODEC = "生"; const DEFLATE_CODEC = "縮"; - const BINARY_SCHEMA_VERSION = 5; - const MIN_BINARY_SCHEMA_VERSION = 4; - const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); - const GROUND_IDS = Object.freeze(["soil", "concrete", "blanket", "foot_massage", "ice"]); - const ITEM_TYPE_IDS = Object.freeze([ - "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", - "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", - "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", - "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet" - ]); - const STRUCTURE_TYPE_IDS = new Set([3, 4]); - const SERVING_FOOD_TYPE_IDS = new Set([7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 31]); - const PIN_TYPE_IDS = new Set([26, 27]); - const ROTATABLE_ITEM_TYPE_IDS = new Set([28, 29, 34, 35, 36, 37, 38]); + const { + BINARY_SCHEMA_VERSION, + MIN_BINARY_SCHEMA_VERSION, + FIELD_IDS, + GROUND_IDS, + ITEM_TYPE_IDS, + STRUCTURE_TYPE_IDS, + SERVING_FOOD_TYPE_IDS, + PIN_TYPE_IDS, + ROTATABLE_ITEM_TYPE_IDS, + } = SaveSchema; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -454,7 +453,7 @@ } function writeWorld(writer, w = []) { - writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 2, 4, 3]); + writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 3, 4, 3]); writer.u(w[3] || 0); // worldTick10 writer.s(w[5] || 0); // lastBirthAt10 can be negative writer.u(w[6] || 1); // generation @@ -463,7 +462,12 @@ writer.u(w[9] || 0); // birthSerial } function readWorld(reader) { - const [field, ground, mood, weather] = readBitFields(reader, [2, 2, 4, 3]); + const schema = reader.schemaVersion || BINARY_SCHEMA_VERSION; + const groundBits = schema >= 6 ? 3 : 2; + const [field, rawGround, mood, weather] = readBitFields(reader, [2, groundBits, 4, 3]); + // Schema 5 and earlier used [soil, concrete, blanket, foot_massage] in two bits. + // Schema 6 inserts the new high-growth dirt at index 1, so old indices are remapped here. + const ground = schema >= 6 ? rawGround : ([0, 2, 3, 4][Number(rawGround) | 0] ?? 0); return [field, ground, mood, reader.u(), weather, reader.s(), reader.u(), reader.u(), reader.str(), reader.u()]; } @@ -545,6 +549,8 @@ writer.str(extra[0] || ""); } else if (typeId === 6) { // duplicator writer.s(extra[0] ?? -1); + } else if (typeId === 39) { // rotator + writer.str(JSON.stringify(extra || [])); } else if (typeId === 21) { // ball writer.s(extra[0] || 0); writer.s(extra[1] || 0); } else if (typeId === 23) { // ant_nest @@ -567,6 +573,7 @@ if (typeId === 1) return [reader.u(), reader.s(), reader.s()]; if (typeId === 22) return [reader.str()]; if (typeId === 6) return [reader.s()]; + if (typeId === 39) return [reader.str()]; if (typeId === 21) return [reader.s(), reader.s()]; if (typeId === 23) return [reader.s(), reader.s()]; if (typeId === 24) return [reader.s()]; diff --git a/js/save_schema.js b/js/save_schema.js new file mode 100644 index 0000000..3ff1534 --- /dev/null +++ b/js/save_schema.js @@ -0,0 +1,79 @@ +"use strict"; + +// Layer: persistence/schema +// Owns stable save identifiers shared by snapshot_system.js and save_codec.js. +// Keep numeric order append-only while the current save format is compatible. +(function (global) { + const SNAPSHOT_VERSION = 5; + const BINARY_SCHEMA_VERSION = 7; + const MIN_BINARY_SCHEMA_VERSION = 4; + + const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); + const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice"]); + const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]); + const MOOD_IDS = Object.freeze(["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]); + const STATE_IDS = Object.freeze(["idle", "sleep", "panic", "sunbath", "fight", "seek_food", "seek_bed", "wander"]); + const POWER_MODE_IDS = Object.freeze(["", "protein", "niteropu"]); + const SIZE_MODE_IDS = Object.freeze(["", "giant_drug", "dwarf_drug"]); + const LIFE_MODE_IDS = Object.freeze(["", "mercury"]); + const PIN_STATE_IDS = Object.freeze(["loose", "lodged", "falling", "stuck"]); + const STAGE_IDS = Object.freeze(["fresh", "aging", "old", "dry"]); + const ITEM_TYPE_IDS = Object.freeze([ + "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", + "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", + "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", + "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator" + ]); + + function typeIds(names) { + return Object.freeze(new Set((names || []).map(name => ITEM_TYPE_IDS.indexOf(name)).filter(id => id >= 0))); + } + + const STRUCTURE_TYPE_IDS = typeIds(["grass_bed", "plushie"]); + const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]); + const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]); + const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator"]); + + function enumIndex(list, value, fallback = 0) { + const i = list.indexOf(String(value ?? "")); + return i >= 0 ? i : fallback; + } + + function enumValue(list, index, fallback = "") { + const i = Number(index) | 0; + return list[i] ?? fallback ?? list[0] ?? ""; + } + + function itemTypeId(type, fallback = -1) { + return enumIndex(ITEM_TYPE_IDS, type, fallback); + } + + function itemTypeValue(index, fallback = "") { + return enumValue(ITEM_TYPE_IDS, index, fallback); + } + + global.TarinaiSaveSchema = Object.freeze({ + SNAPSHOT_VERSION, + BINARY_SCHEMA_VERSION, + MIN_BINARY_SCHEMA_VERSION, + FIELD_IDS, + GROUND_IDS, + WEATHER_IDS, + MOOD_IDS, + STATE_IDS, + POWER_MODE_IDS, + SIZE_MODE_IDS, + LIFE_MODE_IDS, + PIN_STATE_IDS, + STAGE_IDS, + ITEM_TYPE_IDS, + STRUCTURE_TYPE_IDS, + SERVING_FOOD_TYPE_IDS, + PIN_TYPE_IDS, + ROTATABLE_ITEM_TYPE_IDS, + enumIndex, + enumValue, + itemTypeId, + itemTypeValue, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/sim_core.js b/js/sim_core.js index 6db8b22..ad60244 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -120,6 +120,40 @@ function scaleForGrowth(adultScale = 0.28, growth = 1) { return clamp((Number(adultScale) || 0.28) * childGrowthScaleFactor(growth), 0.10, 0.42); } + +function tarinaiBodySizeRatio(tarinai = null) { + const baseScale = 0.28; + let effectiveScale = Number(tarinai?.scale); + if (tarinai && typeof tarinai.effectiveScale === "function") effectiveScale = Number(tarinai.effectiveScale()); + if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) effectiveScale = baseScale; + return clamp(effectiveScale / baseScale, 0.28, 3.25); +} + +function tarinaiMaxEnergy(tarinai = null) { + return Math.round(clamp(100 * tarinaiBodySizeRatio(tarinai), 28, 240)); +} + +function clampTarinaiEnergy(tarinai = null) { + if (!tarinai) return 100; + const max = tarinaiMaxEnergy(tarinai); + tarinai.maxEnergy = max; + tarinai.energy = clamp(Number(tarinai.energy) || 0, 0, max); + return max; +} + +function tarinaiEnergyRatio(tarinai = null) { + const max = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai?.maxEnergy) || 100)); + return clamp((Number(tarinai?.energy) || 0) / max, 0, 1); +} + +function tarinaiMetabolicHungerScale(tarinai = null) { + if (!tarinai) return 1; + const attack = Math.max(0.15, typeof tarinai.outgoingDamage === "function" ? tarinai.outgoingDamage(1) : (tarinai.geneticStatRatio ? tarinai.geneticStatRatio("attack") : (Number(tarinai.genetics?.attackMul) || 1))); + const speed = Math.max(0.15, (tarinai.geneticStatRatio ? tarinai.geneticStatRatio("speed") : (Number(tarinai.genetics?.speedMul) || 1)) * (tarinai.itemSpeedMultiplier ? tarinai.itemSpeedMultiplier() : 1)); + const size = tarinaiBodySizeRatio(tarinai); + return clamp(attack * speed * size, 0.18, 8.0); +} + const PERSONALITIES = Object.freeze({}); const PERSONALITY_IDS = []; const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"]; @@ -622,7 +656,7 @@ class SpatialGrid { isDynamicItem(it) { if (!it || it.dead) return false; const type = it.type || ""; - if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true; + if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo" || (type === "rotator" && Math.abs(Number(it.rotatorSpeed || 0)) > 0.001)) return true; if ((it.dropTimer || 0) > 0) return true; if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true; if (it.isStructure && it.type === "plushie" && it.carriedById) return true; diff --git a/js/simulation.js b/js/simulation.js new file mode 100644 index 0000000..5ba0eae --- /dev/null +++ b/js/simulation.js @@ -0,0 +1,23 @@ +"use strict"; + +// Layer: simulation +// Single update entrypoint. World owns state; phase modules own the ordered update work. +(function (global) { + function update(worldRef, dt) { + const order = global.TarinaiSystemOrder; + if (order?.update) return order.update(worldRef, dt); + const phases = global.TarinaiWorldUpdatePhases; + if (!phases?.update) throw new Error("TarinaiWorldUpdatePhases is not available"); + return phases.update(worldRef, dt); + } + + function phases() { + return global.TarinaiWorldUpdatePhases?.phases || null; + } + + function systemOrder() { + return global.TarinaiSystemOrder?.names || null; + } + + global.TarinaiSimulation = Object.freeze({ update, phases, systemOrder }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_ambient_system.js b/js/simulation_ambient_system.js new file mode 100644 index 0000000..e274498 --- /dev/null +++ b/js/simulation_ambient_system.js @@ -0,0 +1,50 @@ +"use strict"; + +// Layer: simulation/system-ambient-spawn +// Owns low-frequency ambient Tarinai/grass/rain-water creation. +(function (global) { + function spawnAmbientTarinai(worldRef, dt) { + if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { + const spot = worldRef.edgeSpawnPoint(null, null, 42, true); + const t = worldRef.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, entryTimer: 2.0 }); + t.wanderAngle = spot.angle; + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); + worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); + } + } + + function spawnAmbientGrass(worldRef, dt) { + if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return; + const groundGrowthMul = Math.max(0, Number(global.TarinaiGround?.grassGrowthMultiplier?.(worldRef.groundType || "soil") ?? (worldRef.groundGrassMultiplier?.() ?? 1)) || 0); + if (groundGrowthMul <= 0) return; + if ((worldRef.canAddGrass?.(1) ?? true) && Math.random() < dt * 0.070 * groundGrowthMul) { + const spot = worldRef.findGrassPlantingSpot(rand(60, worldRef.w - 60), rand(60, worldRef.h - 60), { + allowOriginal: true, + minRadius: 28, + maxRadius: 140, + attempts: 18, + }); + if (spot) { + const sprout = new Item("grass", spot.x, spot.y); + global.TarinaiGrass?.setStage?.(sprout, 0, { force: true }); + if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn"); + else worldRef.items.push(sprout); + } + } + } + + function spawnRainWater(worldRef, dt) { + // Rain visuals are diagonal line streaks in render.js. This branch creates + // horizontal oval water items spawned by rain; it intentionally does not + // share the rain overlay renderer. + if (global.TarinaiWeatherSystem?.shouldDropRainWater?.(worldRef, dt)) { + const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef); + worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop); + worldRef.emit?.("rain:itemDropped", { item: drop, type: "water" }); + } + } + + global.TarinaiAmbientSimulationSystem = Object.freeze({ + phases: Object.freeze({ spawnAmbientTarinai, spawnAmbientGrass, spawnRainWater }), + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_creature_system.js b/js/simulation_creature_system.js new file mode 100644 index 0000000..d448f31 --- /dev/null +++ b/js/simulation_creature_system.js @@ -0,0 +1,70 @@ +"use strict"; + +// Layer: simulation/system-creatures +// Owns Tarinai update cadence, Tarinai movement invalidation, and creature +// collision/interactions scheduled after individual updates. +(function (global) { + const helpers = () => global.TarinaiSimulationRuntime?.helpers || {}; + const updatePolicy = () => global.TarinaiCreatureUpdatePolicy || {}; + + function updateCreatures(worldRef, dt) { + const h = helpers(); + const end = global.TarinaiPerf?.begin?.("update.tarinai") || null; + const visibleRect = h.updateVisibleWorldRect?.(worldRef, 160) || { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }; + try { + for (const t of worldRef.tarinai) { + if (!t || t.dead) continue; + const interval = updatePolicy().updateInterval?.(worldRef, t, visibleRect) ?? 0; + if (!Number.isFinite(interval)) continue; + if (interval <= 0) { + const runDt = Math.min(1.4, (t._updateAccum || 0) + dt); + t._updateAccum = 0; + if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt); + else t.update(runDt); + continue; + } + t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt); + t._nextLowFreqUpdateAt = Number.isFinite(t._nextLowFreqUpdateAt) ? t._nextLowFreqUpdateAt : (worldRef.time || 0) + interval * (0.65 + Math.random() * 0.7); + if ((worldRef.time || 0) >= t._nextLowFreqUpdateAt) { + const runDt = Math.max(dt, t._updateAccum || dt); + t._updateAccum = 0; + t._nextLowFreqUpdateAt = (worldRef.time || 0) + interval; + if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt); + else t.update(runDt); + } + } + } finally { + if (end) end(); + } + const now = worldRef.time || 0; + const bedConflictInterval = 3.2; + if (now >= (worldRef.nextBedConflictCheckAt || 0)) { + worldRef.nextBedConflictCheckAt = now + bedConflictInterval; + worldRef.workStats && (worldRef.workStats.bedConflictRuns = (worldRef.workStats.bedConflictRuns || 0) + 1); + worldRef.resolveOwnedBedSleepConflicts?.(); + } else { + worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1); + } + h.markSpatialDirtyIfMoved?.(worldRef, worldRef.tarinai, "tarinai-moved", 0.25); + worldRef.ensureSpatial?.("post-tarinai-update"); + worldRef.limitBallChasers(); + worldRef.resolveBallInteractions(dt); + const collisionInterval = 1.8; + if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { + worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval; + worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1); + worldRef.resolveTarinaiHighSpeedCollisions(dt); + } else { + worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); + } + } + + const phases = Object.freeze({ updateCreatures }); + const helpersApi = Object.freeze({ + updateInterval(worldRef, tarinai, visibleRect) { + return updatePolicy().updateInterval?.(worldRef, tarinai, visibleRect) ?? Infinity; + }, + }); + + global.TarinaiCreatureSimulationSystem = Object.freeze({ phases, helpers: helpersApi }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_effects_system.js b/js/simulation_effects_system.js new file mode 100644 index 0000000..494c062 --- /dev/null +++ b/js/simulation_effects_system.js @@ -0,0 +1,13 @@ +"use strict"; + +// Layer: simulation/system-effects +// Owns frame updates for transient visual/effect entities. +(function (global) { + function updateEffects(worldRef, dt) { + for (const ef of worldRef.effects) ef.update(dt); + } + + global.TarinaiEffectsSimulationSystem = Object.freeze({ + phases: Object.freeze({ updateEffects }), + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_environment_system.js b/js/simulation_environment_system.js new file mode 100644 index 0000000..6562ce1 --- /dev/null +++ b/js/simulation_environment_system.js @@ -0,0 +1,71 @@ +"use strict"; + +// Layer: simulation/system-environment +// Owns frame time, world clock, frame signals, weather/terrain preparation, +// phase-change notification, and diagnostics publication. +(function (global) { + function normalizeDelta(worldRef, dt) { + if (!worldRef || worldRef.paused) return null; + let frameDt = dt * worldRef.speed; + frameDt = Math.min(frameDt, 0.12 * worldRef.speed); + return frameDt; + } + + function updateClock(worldRef, dt) { + const prevTime = worldRef.time; + worldRef.time += dt; + const previousDay = worldRef.day || 1; + worldRef.day = Math.floor(worldRef.time / CONFIG.dayLength) + 1; + const prevHour = worldRef.hourOfDay ? worldRef.hourOfDay(prevTime) : 0; + const nowHour = worldRef.hourOfDay ? worldRef.hourOfDay(worldRef.time) : 0; + const crossedColonyEvalHour = (worldRef.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6); + const pendingColonyMoodEvaluation = crossedColonyEvalHour && worldRef.lastColonyMoodDay !== worldRef.day; + if (worldRef.day !== previousDay) { + for (const t of worldRef.tarinai || []) if (t) t.personalityDaily = { day: worldRef.day, total: 0, byKey: {}, byCause: {} }; + } + return { prevTime, previousDay, prevHour, nowHour, crossedColonyEvalHour, pendingColonyMoodEvaluation }; + } + + function updateFrameSignals(worldRef, dt) { + if (worldRef.pointer) worldRef.pointer.motion = Math.max(0, (worldRef.pointer.motion || 0) - dt * 2.4); + worldRef.shakeTimer = Math.max(0, (worldRef.shakeTimer || 0) - dt); + if ((worldRef.shakeTimer || 0) <= 0) { worldRef.shakeDuration = 0; worldRef.shakeStrength = 0; } + } + + function updateEnvironment(worldRef, dt) { + worldRef.updateWeather(dt); + worldRef.foodSpoilagePenalty = Math.max(0, (worldRef.foodSpoilagePenalty || 0) - dt * 0.010); + worldRef.updateTerrainDirtyState?.(dt); + } + + function prepareSpatialFrame(worldRef) { + worldRef.spatialRebuildsThisFrame = 0; + worldRef.spatialDirtyMarksThisFrame = 0; + worldRef.ensureSpatial?.("update-start"); + worldRef.beginFramePerformanceBudgets?.(); + } + + function emitPhaseChange(worldRef) { + const phase = worldRef.phaseName(); + if (phase !== worldRef.lastPhase) { + worldRef.lastPhase = phase; + worldRef.emit("world:phase", { world: worldRef, phase }); + } + } + + function finalizeDiagnostics(worldRef) { + worldRef.lastRuntimeDiagnostics = worldRef.runtimeDiagnostics?.() || null; + } + + const phases = Object.freeze({ + normalizeDelta, + updateClock, + updateFrameSignals, + updateEnvironment, + prepareSpatialFrame, + emitPhaseChange, + finalizeDiagnostics, + }); + + global.TarinaiEnvironmentSimulationSystem = Object.freeze({ phases }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_item_ant_system.js b/js/simulation_item_ant_system.js new file mode 100644 index 0000000..61c7426 --- /dev/null +++ b/js/simulation_item_ant_system.js @@ -0,0 +1,63 @@ +"use strict"; + +// Layer: simulation/system-items-ants +// Owns scheduled item updates, random grass growth, ball/ant spatial refresh, +// and item/ant spatial post-processing. +(function (global) { + const helpers = () => global.TarinaiSimulationRuntime?.helpers || {}; + + function updateRandomGrassGrowth(worldRef, dt) { + if (!worldRef || !global.TarinaiGrass) return false; + if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return false; + const groundGrowthMul = Math.max(0, Number(global.TarinaiGround?.grassGrowthMultiplier?.(worldRef.groundType || "soil") ?? (worldRef.groundGrassMultiplier?.() ?? 1)) || 0); + if (groundGrowthMul <= 0) return false; + worldRef.grassGrowthTimer = (worldRef.grassGrowthTimer || 0) + dt; + const field = worldRef.fieldType || "garden"; + const growthSpeedMul = 6.0 * groundGrowthMul; + const interval = (field === "park" ? 1.8 : (field === "cage" ? 3.2 : 2.4)) / Math.max(0.1, growthSpeedMul); + if (worldRef.grassGrowthTimer < interval) return false; + worldRef.grassGrowthTimer = 0; + const grasses = worldRef.itemsOfType ? worldRef.itemsOfType("grass") : (worldRef.items || []).filter(it => it && it.type === "grass"); + if (!grasses || !grasses.length) return false; + const tries = Math.min(12, grasses.length); + for (let i = 0; i < tries; i++) { + const it = grasses[Math.floor(Math.random() * grasses.length)]; + if (!it || it.dead || (it.amount || 0) <= 0) continue; + if (worldRef.grassBlockedAt?.(it.x, it.y, it)) { + if (global.TarinaiGrass.regress(it, 1) > 0) { + worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-blocked") || worldRef.markTerrainDirty?.("random-grass-blocked"); + worldRef.markItemBucketsDirty?.("random-grass-blocked"); + return true; + } + continue; + } + if (global.TarinaiGrass.advance(it, 1)) { + worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-grow") || worldRef.markTerrainDirty?.("random-grass-grow"); + return true; + } + } + return false; + } + + function updateItemsAndAnts(worldRef, dt) { + const h = helpers(); + const itemCountBefore = worldRef.items.length; + h.updateItemsScheduled?.(worldRef, dt); + updateRandomGrassGrowth(worldRef, dt); + const itemsMoved = h.markScheduledItemsMoved?.(worldRef, "items-moved", 0.25) || false; + if (worldRef.itemCounts?.ball) { + worldRef.ensureSpatial?.("ball-ball-collisions"); + worldRef.resolveBallBallCollisions(dt); + worldRef.markSpatialDirty?.("ball-ball-collisions"); + } + worldRef.updateAnts?.(dt); + const antsMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.ants || [], "ants-moved", 0.25) || false; + if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update"); + return { itemCountBefore, itemsMoved, antsMoved }; + } + + const phases = Object.freeze({ updateItemsAndAnts }); + const helpersApi = Object.freeze({ updateRandomGrassGrowth }); + + global.TarinaiItemAntSimulationSystem = Object.freeze({ phases, helpers: helpersApi }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_maintenance_system.js b/js/simulation_maintenance_system.js new file mode 100644 index 0000000..34f9977 --- /dev/null +++ b/js/simulation_maintenance_system.js @@ -0,0 +1,51 @@ +"use strict"; + +// Layer: simulation/system-maintenance +// Owns periodic compaction, counts refresh, grass limit enforcement, +// out-of-bounds cleanup, and draw-list invalidation. +(function (global) { + function runMaintenance(worldRef, dt, itemCountBefore) { + worldRef.compactTimer += dt; + const compactInterval = 2.5; + let itemsCompacted = false; + if (worldRef.compactTimer >= compactInterval) { + itemsCompacted = worldRef.compactItems(); + worldRef.compactEffects(); + worldRef.compactTarinai(); + worldRef.compactAnts?.(); + worldRef.compactTimer = 0; + } + + worldRef.countsTimer += dt; + const countsInterval = 2.0; + if (worldRef.countsTimer >= countsInterval || itemsCompacted || worldRef.items.length !== itemCountBefore) { + worldRef.updateItemCounts(); + worldRef.updateEffectCounts(); + worldRef.countsTimer = 0; + } + if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) { + worldRef.nextGrassLimitCheckAt = (worldRef.time || 0) + 3.0; + const removedGrass = worldRef.enforceGrassLimit?.("periodic-grass-limit") || 0; + if (removedGrass > 0) { + worldRef.compactItems(); + worldRef.updateItemCounts(); + } + } + + if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= worldRef.time) { + worldRef.outOfBoundsCheckAt = worldRef.time; + worldRef.sanitizeOutOfBounds(); + } + + worldRef.drawSortTimer += dt; + const sortInterval = 0.25; + if (worldRef.drawSortTimer >= sortInterval) { + worldRef.drawListDirty = true; + worldRef.drawSortTimer = 0; + } + } + + global.TarinaiMaintenanceSimulationSystem = Object.freeze({ + phases: Object.freeze({ runMaintenance }), + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_runtime_helpers.js b/js/simulation_runtime_helpers.js new file mode 100644 index 0000000..141c050 --- /dev/null +++ b/js/simulation_runtime_helpers.js @@ -0,0 +1,140 @@ +"use strict"; + +// Layer: simulation/helpers +// Shared, UI-free helpers used by the concrete simulation systems. +(function (global) { + const policy = () => global.TarinaiItemUpdatePolicy || {}; + + function forScheduledItems(worldRef, visitor) { + if (policy().forScheduledItems) return policy().forScheduledItems(worldRef, visitor); + if (!worldRef || typeof visitor !== "function") return 0; + let count = 0; + for (const it of worldRef.items || []) { + if (!it || it.dead || it.type === "grass") continue; + visitor(it); + count += 1; + } + return count; + } + + function itemUpdateInterval(item) { + const interval = policy().itemUpdateInterval?.(item); + return Number.isFinite(interval) ? interval : Infinity; + } + + function updateItemsScheduled(worldRef, dt) { + if (global.TarinaiItemRuntime?.updateScheduled) return global.TarinaiItemRuntime.updateScheduled(worldRef, dt); + if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt); + let ran = 0; + forScheduledItems(worldRef, (it) => { + const interval = itemUpdateInterval(it); + if (!Number.isFinite(interval)) return; + if (interval <= 0) { + if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(it, dt, worldRef); + else it.update?.(dt, worldRef); + ran += 1; + return; + } + it._scheduledUpdateDt = Math.min(10, (it._scheduledUpdateDt || 0) + dt); + if (it._scheduledUpdateDt < interval) return; + const scheduledDt = it._scheduledUpdateDt; + it._scheduledUpdateDt = 0; + if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(it, scheduledDt, worldRef); + else it.update?.(scheduledDt, worldRef); + ran += 1; + }); + return ran; + } + + function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) { + if (!worldRef || !Array.isArray(list) || !list.length) return false; + const thresholdSq = threshold * threshold; + let moved = false; + for (const entity of list) { + if (!entity || entity.dead) continue; + const x = Number.isFinite(entity.x) ? entity.x : 0; + const y = Number.isFinite(entity.y) ? entity.y : 0; + const oldX = entity._spatialTrackX; + const oldY = entity._spatialTrackY; + if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) { + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + continue; + } + const dx = x - oldX; + const dy = y - oldY; + if (dx * dx + dy * dy <= thresholdSq) continue; + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + } + if (moved) worldRef.markSpatialDirty?.(reason); + return moved; + } + + function markScheduledItemsMoved(worldRef, reason = "items-moved", threshold = 0.35) { + if (!worldRef) return false; + const thresholdSq = threshold * threshold; + let moved = false; + const tracked = global.TarinaiItemRuntime?.trackedDynamicItems?.(worldRef) || global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef); + const source = tracked && tracked.length ? tracked : null; + const visit = (entity) => { + const x = Number.isFinite(entity.x) ? entity.x : 0; + const y = Number.isFinite(entity.y) ? entity.y : 0; + const oldX = entity._spatialTrackX; + const oldY = entity._spatialTrackY; + if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) { + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + return; + } + const dx = x - oldX; + const dy = y - oldY; + if (dx * dx + dy * dy <= thresholdSq) return; + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + }; + if (source) { + for (const entity of source) if (entity && !entity.dead) visit(entity); + } else { + forScheduledItems(worldRef, visit); + } + if (moved) worldRef.markSpatialDirty?.(reason); + return moved; + } + + function updateVisibleWorldRect(worldRef, margin = 120) { + const scale = worldRef.viewScale ? worldRef.viewScale() : 1; + const vw = (worldRef.viewportW || worldRef.w || 1000) / Math.max(0.01, scale); + const vh = (worldRef.viewportH || worldRef.h || 720) / Math.max(0.01, scale); + const cameraX = worldRef.cameraX || 0; + const cameraY = worldRef.cameraY || 0; + return { left: cameraX - margin, top: cameraY - margin, right: cameraX + vw + margin, bottom: cameraY + vh + margin }; + } + + function pointInRect(x, y, rect) { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + } + + const helpers = Object.freeze({ + forScheduledItems, + itemUpdateInterval, + updateItemsScheduled, + markSpatialDirtyIfMoved, + markScheduledItemsMoved, + updateVisibleWorldRect, + pointInRect, + }); + + global.TarinaiSimulationRuntime = Object.freeze({ + helpers, + constants: Object.freeze({ + get REALTIME_ITEM_TYPES() { return policy().REALTIME_ITEM_TYPES || new Set(); }, + get PIN_ITEM_TYPES() { return policy().PIN_ITEM_TYPES || new Set(); }, + get SCHEDULED_ITEM_TYPES() { return policy().SCHEDULED_ITEM_TYPES || Object.freeze([]); }, + }), + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/simulation_systems.js b/js/simulation_systems.js new file mode 100644 index 0000000..f7cc536 --- /dev/null +++ b/js/simulation_systems.js @@ -0,0 +1,76 @@ +"use strict"; + +// Layer: simulation/systems-facade +// Owns the concrete phase registry boundary. The actual phase implementations +// are split across simulation_*_system.js files; this facade preserves the +// public TarinaiSimulationSystems contract for system_order.js and legacy tests. +(function (global) { + function phaseFrom(moduleName, phaseName) { + const fn = global[moduleName]?.phases?.[phaseName]; + if (typeof fn !== "function") throw new Error(`Tarinai simulation phase missing: ${moduleName}.${phaseName}`); + return fn; + } + + function normalizeDelta(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "normalizeDelta")(worldRef, dt); } + function updateClock(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "updateClock")(worldRef, dt); } + function updateFrameSignals(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "updateFrameSignals")(worldRef, dt); } + function updateEnvironment(worldRef, dt) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "updateEnvironment")(worldRef, dt); } + function prepareSpatialFrame(worldRef) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "prepareSpatialFrame")(worldRef); } + function updateItemsAndAnts(worldRef, dt) { return phaseFrom("TarinaiItemAntSimulationSystem", "updateItemsAndAnts")(worldRef, dt); } + function updateEffects(worldRef, dt) { return phaseFrom("TarinaiEffectsSimulationSystem", "updateEffects")(worldRef, dt); } + function updateCreatures(worldRef, dt) { return phaseFrom("TarinaiCreatureSimulationSystem", "updateCreatures")(worldRef, dt); } + function runMaintenance(worldRef, dt, itemCountBefore) { return phaseFrom("TarinaiMaintenanceSimulationSystem", "runMaintenance")(worldRef, dt, itemCountBefore); } + function emitPhaseChange(worldRef) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "emitPhaseChange")(worldRef); } + function spawnAmbientTarinai(worldRef, dt) { return phaseFrom("TarinaiAmbientSimulationSystem", "spawnAmbientTarinai")(worldRef, dt); } + function spawnAmbientGrass(worldRef, dt) { return phaseFrom("TarinaiAmbientSimulationSystem", "spawnAmbientGrass")(worldRef, dt); } + function spawnRainWater(worldRef, dt) { return phaseFrom("TarinaiAmbientSimulationSystem", "spawnRainWater")(worldRef, dt); } + function finalizeDiagnostics(worldRef) { return phaseFrom("TarinaiEnvironmentSimulationSystem", "finalizeDiagnostics")(worldRef); } + // finalizeDiagnostics publishes worldRef.lastRuntimeDiagnostics. + + function update(worldRef, dt) { + const frameDt = normalizeDelta(worldRef, dt); + if (frameDt === null) return; + updateClock(worldRef, frameDt); + updateFrameSignals(worldRef, frameDt); + updateEnvironment(worldRef, frameDt); + prepareSpatialFrame(worldRef); + const mobile = updateItemsAndAnts(worldRef, frameDt); + updateEffects(worldRef, frameDt); + updateCreatures(worldRef, frameDt); + runMaintenance(worldRef, frameDt, mobile.itemCountBefore); + emitPhaseChange(worldRef); + spawnAmbientTarinai(worldRef, frameDt); + spawnAmbientGrass(worldRef, frameDt); + spawnRainWater(worldRef, frameDt); + finalizeDiagnostics(worldRef); + } + + const phases = Object.freeze({ + normalizeDelta, + updateClock, + updateFrameSignals, + updateEnvironment, + prepareSpatialFrame, + updateItemsAndAnts, + updateEffects, + updateCreatures, + runMaintenance, + emitPhaseChange, + spawnAmbientTarinai, + spawnAmbientGrass, + spawnRainWater, + finalizeDiagnostics, + }); + + const helpers = Object.freeze({ + ...(global.TarinaiSimulationRuntime?.helpers || {}), + ...(global.TarinaiItemAntSimulationSystem?.helpers || {}), + ...(global.TarinaiCreatureSimulationSystem?.helpers || {}), + }); + + global.TarinaiSimulationSystems = Object.freeze({ + update, + phases, + helpers, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/snapshot_system.js b/js/snapshot_system.js index ccdb1b6..f22031c 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -1,34 +1,27 @@ "use strict"; (function (global) { + const SaveSchema = global.TarinaiSaveSchema; + if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for snapshot_system.js"); const SNAPSHOT_VERSION = 5; + if (SNAPSHOT_VERSION !== SaveSchema.SNAPSHOT_VERSION) throw new Error("snapshot schema version mismatch"); const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"]; const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]); - const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); - const GROUND_IDS = Object.freeze(["soil", "concrete", "blanket", "foot_massage", "ice"]); - const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]); - const MOOD_IDS = Object.freeze(["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]); - const STATE_IDS = Object.freeze(["idle", "sleep", "panic", "sunbath", "fight", "seek_food", "seek_bed", "wander"]); - const POWER_MODE_IDS = Object.freeze(["", "protein", "niteropu"]); - const SIZE_MODE_IDS = Object.freeze(["", "giant_drug", "dwarf_drug"]); - const LIFE_MODE_IDS = Object.freeze(["", "mercury"]); - const PIN_STATE_IDS = Object.freeze(["loose", "lodged", "falling", "stuck"]); - const STAGE_IDS = Object.freeze(["fresh", "aging", "old", "dry"]); - const ITEM_TYPE_IDS = Object.freeze([ - "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", - "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", - "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", - "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet" - ]); - - function enumIndex(list, value, fallback = 0) { - const i = list.indexOf(String(value ?? "")); - return i >= 0 ? i : fallback; - } - function enumValue(list, index, fallback = "") { - const i = Number(index) | 0; - return list[i] ?? fallback ?? list[0] ?? ""; - } + const { + FIELD_IDS, + GROUND_IDS, + WEATHER_IDS, + MOOD_IDS, + STATE_IDS, + POWER_MODE_IDS, + SIZE_MODE_IDS, + LIFE_MODE_IDS, + PIN_STATE_IDS, + STAGE_IDS, + ITEM_TYPE_IDS, + enumIndex, + enumValue, + } = SaveSchema; function spriteIds() { const list = typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []); return (list || []).map(s => s?.id).filter(Boolean); @@ -330,6 +323,12 @@ if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0]; if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)]; if (type === "signboard") return [item.text || ""]; + if (type === "rotator") return [ + q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), + q(item.rotatorSpeed, 1000), + q(item.rotatorThickness || 12, 10), + (Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0],[0,-52,0,52]]).slice(0, 96).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)]) + ]; if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)]; if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)]; if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)]; @@ -375,6 +374,19 @@ item.stage = enumValue(STAGE_IDS, extra[0], "fresh"); item.freshness = u(extra[1], 100, item.freshness || 1); item.fertility = u(extra[2], 100, item.fertility || 1); } else if (type === "signboard") { item.text = String(extra[0] || ""); + } else if (type === "rotator") { + let e = extra; + if (typeof e[0] === "string") { + try { e = JSON.parse(e[0]); } catch (_) { e = []; } + } + const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; + item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback); + item.rotatorSpeed = u(e[1], 1000, item.rotatorSpeed || 0); + item.rotatorThickness = Math.max(4, Math.min(34, u(e[2], 10, item.rotatorThickness || 12))); + item.rotatorSegments = (Array.isArray(e[3]) ? e[3] : [[-78,0,78,0],[0,-52,0,52]]).slice(0,96).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4); + if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]]; + const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8; + item.r = Math.max(item.r || 64, Math.min(460, extent)); } else if (type === "duplicator") { item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.foodLabel ? global.foodLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType); } else if (type === "ball") { diff --git a/js/system_order.js b/js/system_order.js new file mode 100644 index 0000000..0f6c70e --- /dev/null +++ b/js/system_order.js @@ -0,0 +1,41 @@ +"use strict"; + +// Layer: simulation/system-order +// Names the update order in one place. Prefer concrete simulation systems, +// with the legacy phase facade kept as a fallback for transitional builds. +(function (global) { + function phase(name) { + return global.TarinaiSimulationSystems?.phases?.[name] || global.TarinaiWorldUpdatePhases?.phases?.[name] || null; + } + + const SYSTEM_ORDER = Object.freeze([ + { id: "clock", run(worldRef, ctx) { phase("updateClock")?.(worldRef, ctx.dt); } }, + { id: "frame_signals", run(worldRef, ctx) { phase("updateFrameSignals")?.(worldRef, ctx.dt); } }, + { id: "environment", run(worldRef, ctx) { phase("updateEnvironment")?.(worldRef, ctx.dt); } }, + { id: "spatial_prepare", run(worldRef) { phase("prepareSpatialFrame")?.(worldRef); } }, + { id: "items_and_ants", run(worldRef, ctx) { ctx.mobile = phase("updateItemsAndAnts")?.(worldRef, ctx.dt) || null; } }, + { id: "effects", run(worldRef, ctx) { phase("updateEffects")?.(worldRef, ctx.dt); } }, + { id: "creatures", run(worldRef, ctx) { phase("updateCreatures")?.(worldRef, ctx.dt); } }, + { id: "maintenance", run(worldRef, ctx) { phase("runMaintenance")?.(worldRef, ctx.dt, ctx.mobile?.itemCountBefore ?? (worldRef.items || []).length); } }, + { id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } }, + { id: "ambient_tarinai", run(worldRef, ctx) { phase("spawnAmbientTarinai")?.(worldRef, ctx.dt); } }, + { id: "ambient_grass", run(worldRef, ctx) { phase("spawnAmbientGrass")?.(worldRef, ctx.dt); } }, + { id: "rain_water", run(worldRef, ctx) { phase("spawnRainWater")?.(worldRef, ctx.dt); } }, + { id: "diagnostics", run(worldRef) { phase("finalizeDiagnostics")?.(worldRef); } }, + ]); + + function update(worldRef, dt) { + const normalize = phase("normalizeDelta"); + if (!normalize) return global.TarinaiWorldUpdatePhases?.update?.(worldRef, dt); + const frameDt = normalize(worldRef, dt); + if (frameDt === null) return; + const ctx = { dt: frameDt, mobile: null }; + for (const system of SYSTEM_ORDER) system.run(worldRef, ctx); + } + + global.TarinaiSystemOrder = Object.freeze({ + SYSTEM_ORDER, + names: Object.freeze(SYSTEM_ORDER.map(system => system.id)), + update, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index ac3e48a..8566597 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -152,11 +152,11 @@ function createRestActionSpec() { canStart() { return true; }, start(t) { t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); - t.energy = clamp((t.energy || 0) + 1.6, 0, 100); + t.energy = clamp((t.energy || 0) + 1.6, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); return true; }, tick(t) { - t.energy = clamp((t.energy || 0) + 0.8, 0, 100); + t.energy = clamp((t.energy || 0) + 0.8, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); return true; }, }; diff --git a/js/tarinai_action_planner_system.js b/js/tarinai_action_planner_system.js new file mode 100644 index 0000000..7650eb9 --- /dev/null +++ b/js/tarinai_action_planner_system.js @@ -0,0 +1,21 @@ +"use strict"; + +// Boundary for ordinary need/action planning. The current implementation still +// delegates to the legacy planner body, but callers no longer depend on the +// Tarinai prototype owning that body. + +(function (global) { + function updateOne(tarinai, dt = 0) { + if (!tarinai || tarinai.dead) return false; + return Boolean(global.TarinaiNeedPlannerLegacySystem?.updateOne?.(tarinai, dt)); + } + + function updateWithThis(dt = 0) { + return updateOne(this, dt); + } + + global.TarinaiActionPlannerSystem = Object.freeze({ + updateOne, + updateWithThis, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_contact_item_system.js b/js/tarinai_contact_item_system.js new file mode 100644 index 0000000..886ca7e --- /dev/null +++ b/js/tarinai_contact_item_system.js @@ -0,0 +1,86 @@ +"use strict"; + +// Non-eating contact effects for water, sleep furniture, toys, stains, and traces. + +(function (global) { + function updateCandidate(tarinai, ctx, it, dt = 0) { + if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false; + const { foodActionActive = false } = ctx; + +if (!it || it.dead) return false; +const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it); +const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0); +if (d > contactReach) return false; + if (it.type === "water" || it.type === "water_bowl") { + tarinai.thought = it.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; + tarinai.target = it; + tarinai.applyWaterEffect(dt, it.type); + if ((tarinai.world?.time || 0) > (tarinai.nextDrinkSound || -999)) { + audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); + tarinai.nextDrinkSound = (tarinai.world?.time || 0) + 0.72; + } + if (it.type === "water") it.amount -= dt * 2.4; + if (tarinai.type === "teary" || tarinai.type === "cry") tarinai.affection += dt * 0.18; + } + + if (it.type === "stone") { + if (tarinai.state === "idle" && tarinai.stress > 48) tarinai.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; + applyNeedRelief(tarinai, { safety: -dt * 4, fulfill: -dt * 2 }); + tarinai.energy += dt * 0.18; + + } + + if (tarinai.isSleepFurniture(it)) { + if (tarinai.energy < 74 + 6 * tarinai.personalityProfile().sleep) { + const isEasyBed = it.type === "grass_bed"; + const occ = tarinai.world.bedOccupancy(it); + const comfort = tarinai.world.bedComfort(it); + it.use?.(tarinai, tarinai.world, isEasyBed ? { purpose: "sleep" } : undefined); + tarinai.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b")); + if (tarinai.world.time > tarinai.nextSleepBubbleAt) { + tarinai.nextSleepBubbleAt = tarinai.world.time + 4.5; + const crowded = occ > 5; + tarinai.world.spawnBubble(tarinai.x, tarinai.y - tarinai.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); + } + tarinai.energy += dt * (1.9 + comfort * 1.4); + if (occ > 5) applyNeedShock(tarinai, { safety: dt * 3 }); + it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); + } + } + + if (it.type === "ball") { + const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1); + if (tarinai.state === "play_ball" || tarinai.target === it || tarinai.shouldApplyPersonalityBehavior("openness", 1)) { + tarinai.goodMode = "smile"; + applyNeedRelief(tarinai, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) }); + tarinai.loneliness = clamp(tarinai.loneliness - dt * (0.4 + touch), 0, 110); + if (tarinai.state === "play_ball") tarinai.foodReactTimer = Math.max(tarinai.foodReactTimer, 0.10); + } + } + + if (it.type === "splat") { + const touch = clamp(1 - d / (tarinai.radius + it.r + 16), 0, 1); + if (touch > 0) { + applyNeedShock(tarinai, { safety: touch * dt * 5, health: touch * dt * 3 }); + if (touch > 0.06) tarinai.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); + } + } + + if (it.type === "trace") { + tarinai.loneliness -= dt * 0.25; + applyNeedShock(tarinai, { social: -dt * 1, safety: dt * 0.8 }); + } + return true; + } + + function updateOne(tarinai, ctx, dt = 0) { + if (!tarinai || tarinai.dead || !ctx) return false; + for (const it of ctx.foodCandidates || []) updateCandidate(tarinai, ctx, it, dt); + return true; + } + + global.TarinaiContactItemSystem = Object.freeze({ + updateCandidate, + updateOne, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_cursor_care_system.js b/js/tarinai_cursor_care_system.js new file mode 100644 index 0000000..bbae850 --- /dev/null +++ b/js/tarinai_cursor_care_system.js @@ -0,0 +1,40 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-system +// Owns pointer proximity care and cursor-heart effects. +(function (global) { + function legacyMethod(dt) { + const pointer = this.world?.pointer; + if (!pointer?.inside || !this.isAwakeCursorFriendly()) return; + const d = distXY(this.x, this.y, pointer.x, pointer.y); + const touch = this.radius * 1.35; + if (d > touch) return; + const p = clamp(1 - d / touch, 0, 1); + const relief = dt * (1.35 + 2.65 * p); + applyNeedRelief(this, { safety: -relief * 1.2, fulfill: -relief * 1.6 }); + this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted."); + this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5); + this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); + this.goodMode = "smile"; + if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) { + this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10); + for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { + this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { + vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" + })); + } + } + + } + + function updateOne(tarinai, dt) { + if (!tarinai || tarinai.dead) return false; + legacyMethod.call(tarinai, dt); + return true; + } + + global.TarinaiCursorCareSystem = Object.freeze({ + legacyMethod, + updateOne, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index aba1840..c917e82 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -62,7 +62,7 @@ if (this.zunchiDisease) { this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * (5 + 4 * this.zunchiDiseaseSeverity) }); - this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100); + this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); this.emitZunchiDiseaseEffect(dt); if (this.zunchiDiseaseSeverity <= 0.04) { @@ -298,7 +298,7 @@ applyWaterEffect(dt, source = "water") { const power = Math.max(0, dt || 0); if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -power * 12, safety: -power * 6 }); - this.energy = clamp(this.energy + power * 0.5, 0, 100); + this.energy = clamp(this.energy + power * 0.5, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); this.hunger = clamp(this.hunger - power * 0.2, 0, 115); this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100); if (this.zunchiDisease) { diff --git a/js/tarinai_food_interaction_system.js b/js/tarinai_food_interaction_system.js new file mode 100644 index 0000000..8e5892c --- /dev/null +++ b/js/tarinai_food_interaction_system.js @@ -0,0 +1,194 @@ +"use strict"; + +// Food, medicine, grass, ant-corpse, and zunchi eating branch for Tarinai item interaction. + +(function (global) { + function updateCandidate(tarinai, ctx, it, dt = 0) { + if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false; + const { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx; + +const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it); +const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0); +if (d > contactReach) return false; +const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type; +const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi"; + +if (((foodType === "sweet" && canSweetBite) || (foodType === "food" && (canBite || foodActionActive)) || duplicatorConsumable) && tarinai.foodItemHasServingLeft(it)) { + const eating = tarinai.consumeFoodServing(it); + if (eating > 0) { + const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type); + tarinai.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`); + tarinai.vx *= Math.pow(0.25, dt * 60); + tarinai.vy *= Math.pow(0.25, dt * 60); + const hungerRelief = tarinai.foodHungerReliefFor(foodType || it.type, eating, foodType === "sweet" ? 0.75 : 0.95); + tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(hungerRelief) : (tarinai.hunger -= hungerRelief); + tarinai.recoverHealth(eating * (foodType === "sweet" ? 0.28 : 0.46)); + tarinai.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012; + if (foodType === "sweet") { + applyNeedRelief(tarinai, { food: -eating * 0.4, fulfill: -eating * 0.2 }); + tarinai.fearTimer = Math.max(0, tarinai.fearTimer - eating * 0.035); + if (tarinai.explosionDisease) tarinai.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); + if (tarinai.fightDisease) tarinai.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); + } + tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0); + tarinai.eatCooldown = rand(0.96, 1.12); + tarinai.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)"); + tarinai.foodReactTimer = Math.max(tarinai.foodReactTimer, 0.16); + if (Math.random() < dt * 2.2) { + const mouth = tarinai.mouthPosition(it); + tarinai.world.spawnEatEffect(mouth.x, mouth.y, foodType === "sweet" ? "#78b957" : "#f1dfb7"); + } + tarinai.lastMealColor = foodType === "sweet" ? "#78b957" : "#f1dfb7"; + const fromDuplicator = isDuplicatorFoodSource(it); + tarinai.makePoop(normalPoopUnitsFor(foodType || it.type, eating)); + if (fromDuplicator) tarinai.mealCooldownUntil = Math.max(tarinai.mealCooldownUntil || 0, tarinai.world.time + 1.2); + if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.42; } + if (tarinai.world.time - tarinai.lastLog > 9 && Math.random() < 0.014) { + tarinai.world.log(`${tarinai.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [tarinai] }); + tarinai.lastLog = tarinai.world.time; + } + return true; + } +} + +if (canSweetBite && (foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && tarinai.foodItemHasServingLeft(it)) { + const eating = tarinai.consumeFoodServing(it); + if (eating <= 0) return false; + const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type); + tarinai.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`); + tarinai.vx *= Math.pow(0.25, dt * 60); + tarinai.vy *= Math.pow(0.25, dt * 60); + const hungerRelief = tarinai.foodHungerReliefFor(foodType || it.type, eating, 0.82); + tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(hungerRelief) : (tarinai.hunger -= hungerRelief); + tarinai.recoverHealth(eating * 0.24); + tarinai.affection += eating * 0.04; + const mealColors = { + love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", + protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", + mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f", + }; + const mealBubbles = { + love_mochi: ["\u2661", 2.4, "rgba(190,82,132,0.78)"], + fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"], + sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"], + }; + if (tarinai.applyParamItemEffect) tarinai.applyParamItemEffect(foodType || it.type, it, { + source: "eating", + nutrition: eating, + silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug", + }); + if (mealBubbles[foodType]) tarinai.bubble(...mealBubbles[foodType]); + tarinai.lastMealColor = mealColors[foodType] || "#41aa66"; + tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0); + tarinai.eatCooldown = rand(0.96, 1.12); + tarinai.foodReactTimer = Math.max(tarinai.foodReactTimer, 0.16); + if (Math.random() < dt * 2.2) { + const mouth = tarinai.mouthPosition(it); + tarinai.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (tarinai.lastMealColor || "#41aa66")))); + } + const fromDuplicator = isDuplicatorFoodSource(it); + tarinai.makePoop(normalPoopUnitsFor(foodType || it.type, eating)); + if (fromDuplicator) tarinai.mealCooldownUntil = Math.max(tarinai.mealCooldownUntil || 0, tarinai.world.time + 1.2); + if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.42; } + if (tarinai.world.time - tarinai.lastLog > 9 && Math.random() < 0.02) { + tarinai.world.log(`${tarinai.name}\u306f${toolLabel(foodType || it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [tarinai] }); + tarinai.lastLog = tarinai.world.time; + } + return true; +} + +if (canBite && it.type === "grass") { + const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); + tarinai.beginEating(it, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b"); + tarinai.grassEatTimer = Math.max(tarinai.grassEatTimer, 1.0); + tarinai.vx *= Math.pow(0.20, dt * 60); + tarinai.vy *= Math.pow(0.20, dt * 60); + it.amount -= bite; + it.eatenAmount = (it.eatenAmount || 0) + bite; + it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); + tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(bite * 0.46) : (tarinai.hunger -= bite * 0.46); + tarinai.recoverHealth(bite * 0.18); + applyNeedRelief(tarinai, { food: -dt * 2, fulfill: -dt * 1 }); + tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0); + tarinai.eatCooldown = rand(0.96, 1.14); + tarinai.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); + if (Math.random() < dt * 2.0) { + const mouth = tarinai.mouthPosition(it); + tarinai.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39"); + } + if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.55; } + tarinai.makePoop(normalPoopUnitsFor("grass", bite)); + if (tarinai.type === "jito") tarinai.affection += dt * 0.25; + return true; +} + +if (canBite && it.type === "ant_corpse") { + const bite = Math.min(it.amount, rand(3.2, 5.4)); + if (bite > 0) { + tarinai.beginEating(it, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b"); + tarinai.vx *= Math.pow(0.18, dt * 60); + tarinai.vy *= Math.pow(0.18, dt * 60); + it.amount -= bite; + tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(bite * 0.62) : (tarinai.hunger -= bite * 0.62); + tarinai.recoverHealth(bite * 0.12); + applyNeedShock(tarinai, { safety: bite * 0.12 }); + tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0); + tarinai.eatCooldown = rand(0.96, 1.16); + tarinai.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); + const mouth = tarinai.mouthPosition(it); + tarinai.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36"); + tarinai.lastMealColor = "#4a3f36"; + tarinai.makePoop(normalPoopUnitsFor("ant_corpse", bite)); + if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.62; } + return true; + } +} + +if (it.type === "zunchi") { + const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1); + if (!tarinai.isZunchiSlave) { + applyNeedShock(tarinai, { health: touch * dt * 7, safety: touch * dt * 4 }); + if (touch > 0.08) tarinai.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); + } + tarinai.zunchiStain = clamp((tarinai.zunchiStain || 0) + touch * dt * (tarinai.isZunchiSlave ? 5.2 : 9.5), 0, 100); + const emergencyHunger = (tarinai.hunger || 0) >= 104 || foodNeed >= 94; + const zunchiTooFresh = zunchiEatingBlocked(tarinai, it, tarinai.world) || (!emergencyHunger && !tarinai.isZunchiSlave && it.stage === "fresh" && (it.age || 0) < 150 && foodNeed < 94 && (tarinai.hunger || 0) < 104); + if (canZunchiBite && !zunchiTooFresh) { + const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(it.amount, (tarinai.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); + tarinai.beginEating(it, tarinai.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b"); + tarinai.vx *= Math.pow(0.18, dt * 60); + tarinai.vy *= Math.pow(0.18, dt * 60); + it.amount -= bite; + tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(bite * (tarinai.isZunchiSlave ? 0.66 : 0.18)) : (tarinai.hunger -= bite * (tarinai.isZunchiSlave ? 0.66 : 0.18)); + tarinai.recoverHealth(bite * (tarinai.isZunchiSlave ? 0.10 : 0.02)); + applyNeedShock(tarinai, { health: bite * (tarinai.isZunchiSlave ? 0.2 : 1.2), safety: bite * (tarinai.isZunchiSlave ? 0.1 : 0.6) }); + if (!tarinai.isZunchiSlave) tarinai.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); + tarinai.zunchiStain = clamp((tarinai.zunchiStain || 0) + bite * (tarinai.isZunchiSlave ? 0.75 : 1.75), 0, 100); + tarinai.lastMealColor = "#6b8d3f"; + tarinai.eatTimer = Math.max(tarinai.eatTimer, 1.0); + tarinai.eatCooldown = rand(1.0, 1.22); + tarinai.bubble("\u3082\u3050", 2.2, "rgba(74,91,50,0.78)"); + const mouth = tarinai.mouthPosition(it); + tarinai.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); + if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.7; } + return true; + } +} + return false; + } + + function updateOne(tarinai, ctx, dt = 0) { + if (!tarinai || tarinai.dead || !ctx) return false; + for (const it of ctx.foodCandidates || []) { + if (updateCandidate(tarinai, ctx, it, dt)) return true; + } + return false; + } + + global.TarinaiFoodInteractionSystem = Object.freeze({ + updateCandidate, + updateOne, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_forced_action_planner_system.js b/js/tarinai_forced_action_planner_system.js new file mode 100644 index 0000000..22aa534 --- /dev/null +++ b/js/tarinai_forced_action_planner_system.js @@ -0,0 +1,22 @@ +"use strict"; + +// Boundary for forced-action planning. The concrete forced-queue implementation +// remains in tarinai_forced_behavior.js / tarinai_needs_items.js; this module +// gives the need planner a stable dependency point. + +(function (global) { + function hasPending(tarinai, actionId = "") { + if (!tarinai) return false; + if (typeof tarinai.hasForcedBehavior === "function") return tarinai.hasForcedBehavior(actionId); + return false; + } + + function prepare(tarinai, dt = 0) { + return Boolean(tarinai && !tarinai.dead && hasPending(tarinai)); + } + + global.TarinaiForcedActionPlannerSystem = Object.freeze({ + hasPending, + prepare, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index 434524b..e5d681c 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -3,9 +3,20 @@ (function (global) { const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_identity_social.js"); + + function relationIdSetFor(tarinai) { + const ids = new Set(Object.keys(tarinai?.relationships || {})); + for (const other of tarinai?.world?.tarinai || []) { + if (!other || other === tarinai || !other.id) continue; + if (other.relationships?.[tarinai.id]) ids.add(other.id); + } + return ids; + } + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ get lack() { - return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100); + const healthLack = 100 * (1 - (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp((this.energy || 0) / Math.max(1, this.maxEnergy || 100), 0, 1))); + return clamp((this.hunger + this.loneliness + this.stress + healthLack) / 4, 0, 100); }, get mood() { @@ -152,14 +163,26 @@ rel.fightsLost = rel.fightsLost || 0; if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } this.relationCache = null; + const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14; + if (affinityDelta > 0 && rel.affinity >= threshold && other.relationTo) { + const reverse = other.relationTo(this.id); + const mirrored = Math.max(reverse.affinity || 0, threshold + 0.25, Math.min(rel.affinity, rel.affinity * 0.78)); + reverse.affinity = clamp(mirrored, -24, 40); + reverse.fear = clamp(Math.min(reverse.fear || 0, rel.fear || 0), 0, 40); + if (event && !reverse.lastEvent) { reverse.lastEvent = event; reverse.lastTime = this.world?.time || other.world?.time || 0; } + other.relationCache = null; + } }, strongestRelation(kind = "friend", liveOnly = true) { let bestId = null; let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD; - for (const [id, rel] of Object.entries(this.relationships || {})) { + for (const id of relationIdSetFor(this)) { + const rel = this.relationships?.[id] || relationDefaults(); if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue; - const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); + const other = this.world?.liveTarinaiById?.(id) || null; + const reverse = kind === "fear" ? null : (other?.relationships?.[this.id] || null); + const score = kind === "fear" ? (rel.fear || 0) : Math.max(rel.affinity || 0, reverse?.affinity || 0); if (score > bestScore) { bestId = id; bestScore = score; } } return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null; @@ -176,9 +199,12 @@ const cacheKey = `${minScore}:${liveOnly ? 1 : 0}`; if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount; let count = 0; - for (const [id, rel] of Object.entries(this.relationships || {})) { - if ((rel.affinity || 0) <= minScore) continue; - if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue; + for (const id of relationIdSetFor(this)) { + const rel = this.relationships?.[id] || relationDefaults(); + const other = this.world?.liveTarinaiById?.(id) || null; + const reverse = other?.relationships?.[this.id] || null; + if (Math.max(rel.affinity || 0, reverse?.affinity || 0) <= minScore) continue; + if (liveOnly && (!other || id === this.id)) continue; count += 1; } this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 }; @@ -198,10 +224,12 @@ if (cached && (!Number.isFinite(maxDist) || dist(this, cached) <= maxDist)) return cached; } let best = null, bestScore = minScore; - for (const [id, rel] of Object.entries(this.relationships || {})) { - const score = rel.affinity || 0; - if (score <= bestScore) continue; + for (const id of relationIdSetFor(this)) { + const rel = this.relationships?.[id] || relationDefaults(); const t = this.world.liveTarinaiById?.(id) || null; + const reverse = t?.relationships?.[this.id] || null; + const score = Math.max(rel.affinity || 0, reverse?.affinity || 0); + if (score <= bestScore) continue; if (!t) continue; if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; best = t; @@ -294,7 +322,9 @@ recoverHealth(amount) { if (this.dead) return; - this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100); + const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100); + this.maxEnergy = maxEnergy; + this.energy = clamp((this.energy || 0) + Math.max(0, amount || 0), 0, maxEnergy); }, familyJoined() { diff --git a/js/tarinai_item_effects.js b/js/tarinai_item_effects.js index 96e468f..63bab22 100644 --- a/js/tarinai_item_effects.js +++ b/js/tarinai_item_effects.js @@ -85,11 +85,29 @@ }, refreshEffectiveSize() { + const oldMax = Number.isFinite(this.maxEnergy) ? Math.max(1, this.maxEnergy) : 100; + const oldEnergy = Number.isFinite(this.energy) ? this.energy : oldMax; const mult = this.itemSizeMultiplier ? this.itemSizeMultiplier() : 1; this.radius = 56 * (this.scale || 0.28) * mult; + const nextMax = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : 100; + this.maxEnergy = nextMax; + const ratio = clamp(oldEnergy / oldMax, 0, 1.35); + this.energy = clamp(ratio * nextMax, 0, nextMax); return this.radius; }, + effectiveMaxEnergy() { + return typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100); + }, + + energyRatio() { + return typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp((this.energy || 0) / Math.max(1, this.maxEnergy || 100), 0, 1); + }, + + metabolicHungerMultiplier() { + return typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1; + }, + setPowerItemMode(mode = "") { this.ensureItemEffectState(); if (mode !== "protein" && mode !== "niteropu") return false; diff --git a/js/tarinai_item_interaction_context.js b/js/tarinai_item_interaction_context.js new file mode 100644 index 0000000..b2d4d0f --- /dev/null +++ b/js/tarinai_item_interaction_context.js @@ -0,0 +1,69 @@ +"use strict"; + +// Builds and finalizes Tarinai item-interaction scan context. +// This keeps scan radius, candidate collection, and post-interaction clamping in one place. + +(function (global) { + function create(tarinai, dt = 0) { + if (!tarinai || !tarinai.world) return null; + const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0; + const healthNeed = Number(tarinai.needRaw?.health ?? tarinai.needs?.health ?? 0) || 0; + const currentNeed = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai.behavior?.need; + const foodActionActive = currentNeed === "food" || ["seek_food", "eat", "seek_water"].includes(tarinai.state); + const healthActionActive = currentNeed === "health" || tarinai.state === "seek_food"; + const mealReady = (tarinai.mealCooldownUntil || 0) <= (tarinai.world?.time || 0) || tarinai.hunger > 92 || foodNeed > 84; + const wantsFood = foodActionActive || foodNeed >= needThreshold("food", "start") || tarinai.hunger > 70; + const wantsMedicine = healthActionActive || healthNeed >= needThreshold("health", "start"); + const canBite = !tarinai.isZunchiSlave && tarinai.eatCooldown <= 0 && mealReady && wantsFood && tarinai.hunger > 18; + const canSweetBite = !tarinai.isZunchiSlave && tarinai.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && tarinai.hunger > 7; + const canZunchiBite = tarinai.eatCooldown <= 0 && (tarinai.isZunchiSlave ? tarinai.hunger > 16 : (tarinai.hunger >= 102 || foodNeed >= 92)) && (tarinai.isZunchiSlave || !hasBetterFoodThanZunchiNearby(tarinai, tarinai.world, 260)); + const scanLimit = (tarinai.hunger >= 96 || foodNeed >= 84) ? 32 : 18; + const foodScanRadius = tarinai.radius + ((tarinai.hunger >= 96 || foodNeed >= 84) ? 104 : 56); + const nearbyFood = tarinai.world.nearbyFood?.(tarinai.x, tarinai.y, foodScanRadius, true) || []; + const nearbyItems = nearbyFood.length ? nearbyFood : tarinai.world.nearbyItems(tarinai.x, tarinai.y, foodScanRadius); + const foodCandidates = []; + if (tarinai.target && !tarinai.target.dead && (tarinai.target.type === "duplicator" || roleMatches(tarinai.target, "food") || roleMatches(tarinai.target, "medicine"))) foodCandidates.push(tarinai.target); + for (const it of nearbyItems) { + if (!it || it.dead || foodCandidates.includes(it)) continue; + if (!(it.type === "duplicator" || roleMatches(it, "food") || roleMatches(it, "medicine") || it.type === "water" || it.type === "water_bowl")) continue; + foodCandidates.push(it); + if (foodCandidates.length >= scanLimit) break; + } + if (foodCandidates.length > 1 && foodCandidates.length <= scanLimit) { + foodCandidates.sort((a, b) => foodPriorityScore(tarinai, b, b?.type === "duplicator" ? foodInteractionDistance(tarinai, b) : dist(tarinai, b)) - foodPriorityScore(tarinai, a, a?.type === "duplicator" ? foodInteractionDistance(tarinai, a) : dist(tarinai, a))); + } + return { + dt, + foodNeed, + healthNeed, + currentNeed, + foodActionActive, + healthActionActive, + mealReady, + wantsFood, + wantsMedicine, + canBite, + canSweetBite, + canZunchiBite, + scanLimit, + foodScanRadius, + nearbyFood, + nearbyItems, + foodCandidates, + }; + } + + function finalize(tarinai) { + if (!tarinai) return false; + tarinai.hunger = clamp(tarinai.hunger, 0, 115); + tarinai.energy = clamp(tarinai.energy, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100)); + tarinai.stress = applyGroundStressModifier(tarinai, calculateStressFromNeeds(tarinai.needRaw || tarinai.needs || createDefaultNeeds())); + tarinai.loneliness = clamp(tarinai.loneliness, 0, 110); + return true; + } + + global.TarinaiItemInteractionContext = Object.freeze({ + create, + finalize, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_item_interaction_system.js b/js/tarinai_item_interaction_system.js new file mode 100644 index 0000000..9b2cb48 --- /dev/null +++ b/js/tarinai_item_interaction_system.js @@ -0,0 +1,39 @@ +"use strict"; + +// Tarinai item interaction system. +// Orchestrates food/medicine consumption and non-eating item contact effects. +// The candidate loop intentionally preserves the legacy order: for each nearby +// candidate, try eating first; if nothing was consumed, apply contact effects +// for that same candidate before advancing to the next one. + +(function (global) { + function interactWithItems(dt = 0) { + const ctx = global.TarinaiItemInteractionContext?.create?.(this, dt); + if (!ctx) return false; + for (const item of ctx.foodCandidates || []) { + const consumed = Boolean(global.TarinaiFoodInteractionSystem?.updateCandidate?.(this, ctx, item, dt)); + if (consumed) { + global.TarinaiItemInteractionContext?.finalize?.(this); + return true; + } + global.TarinaiContactItemSystem?.updateCandidate?.(this, ctx, item, dt); + } + global.TarinaiItemInteractionContext?.finalize?.(this); + return true; + } + + function updateOne(tarinai, dt = 0) { + if (!tarinai || tarinai.dead) return false; + return interactWithItems.call(tarinai, dt); + } + + function updateWithThis(dt = 0) { + return updateOne(this, dt); + } + + global.TarinaiItemInteractionSystem = Object.freeze({ + interactWithItems, + updateOne, + updateWithThis, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_local_environment_system.js b/js/tarinai_local_environment_system.js new file mode 100644 index 0000000..759be35 --- /dev/null +++ b/js/tarinai_local_environment_system.js @@ -0,0 +1,52 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-system +// Owns local family/friend/grass/nest comfort environment effects. +(function (global) { + function legacyMethod(dt) { + const parent = this.parentToFollow ? this.parentToFollow() : null; + if (parent && dist(this, parent) < 96) { + applyNeedRelief(this, { social: -dt * 4, safety: -dt * 2 }); + this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); + this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family."); + } + const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null; + const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; + if (friend) { + applyNeedRelief(this, { social: -dt * 3, fulfill: -dt * 1 }); + this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends."); + } else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) { + this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends."); + } + let grassComfort = 0; + for (const it of this.world.nearbyItems(this.x, this.y, 105)) { + if (it.type !== "grass" || it.dead) continue; + const d = distXY(this.x, this.y, it.x, it.y); + const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2); + grassComfort += clamp(1 - d / 105, 0, 1) * lush; + } + let nestComfort = 0; + for (const it of this.world.nearbyItems(this.x, this.y, 120)) { + if (!it || it.dead || it.type !== "nest_box") continue; + nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1); + } + if (nestComfort > 0) applyNeedRelief(this, { sleep: -dt * Math.min(4, nestComfort * 3), safety: -dt * Math.min(5, nestComfort * 4) }); + if (grassComfort > 0) { + applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) }); + if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); + if (this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); + } + + } + + function updateOne(tarinai, dt) { + if (!tarinai || tarinai.dead) return false; + legacyMethod.call(tarinai, dt); + return true; + } + + global.TarinaiLocalEnvironmentSystem = Object.freeze({ + legacyMethod, + updateOne, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_need_planner_system.js b/js/tarinai_need_planner_system.js new file mode 100644 index 0000000..744cf6b --- /dev/null +++ b/js/tarinai_need_planner_system.js @@ -0,0 +1,22 @@ +"use strict"; + +// Public Tarinai need-planning entrypoint. This owns the resolveNeeds boundary +// used by Tarinai.prototype.resolveNeeds and by future planner decomposition. + +(function (global) { + function updateOne(tarinai, dt = 0) { + if (!tarinai || tarinai.dead) return false; + global.TarinaiForcedActionPlannerSystem?.prepare?.(tarinai, dt); + return Boolean(global.TarinaiActionPlannerSystem?.updateOne?.(tarinai, dt) + ?? global.TarinaiNeedPlannerLegacySystem?.updateOne?.(tarinai, dt)); + } + + function updateWithThis(dt = 0) { + return updateOne(this, dt); + } + + global.TarinaiNeedPlannerSystem = Object.freeze({ + updateOne, + updateWithThis, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 53df30a..ca6a452 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -62,10 +62,14 @@ function updateNeeds(tarinai, world, dt = 0) { const raw = createDefaultNeeds(); raw.food = (tarinai.hunger || 0) * 0.88; const sleepPhase = updateCircadianSleepPressure(tarinai, world, dt); - const energySleepNeed = Math.max(0, 100 - (tarinai.energy || 0)) * 0.22; + const energyMax = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100)); + tarinai.maxEnergy = energyMax; + const energyPct = clamp((Number(tarinai.energy) || 0) / energyMax, 0, 1); + const energyLack = (1 - energyPct) * 100; + const energySleepNeed = energyLack * 0.22; raw.sleep = energySleepNeed + sleepPhase.pressure; if (tarinai.sleepDisease) raw.sleep += 34; - raw.health = Math.max(0, (100 - (tarinai.energy || 100)) * 0.18 + (tarinai.zunchiStain || 0) * 0.32); + raw.health = Math.max(0, energyLack * 0.18 + (tarinai.zunchiStain || 0) * 0.32); if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) raw.health += 42; const lodgedBehavior = tarinai.currentLodgedPinBehavior?.() || null; if ((tarinai.hurtTimer || 0) > 0.04 || ((tarinai.stuckPushpinId || "") && (lodgedBehavior?.panicOnAttach || (lodgedBehavior?.damagePerTick || 0) > 0 || (lodgedBehavior?.damageOnAttach || 0) > 0))) raw.health += 36; @@ -547,9 +551,173 @@ function protectSleepSession(tarinai, world, needs) { return false; } +function resolveNeedsLegacy(dt) { + decayActionCooldowns(this, dt); + const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds(); + const needs = updateNeedsCached(this, this.world, dt); + synchronizeActionTextFromState(this); + if (processForcedBehaviorQueue(this, this.world, needs)) return; + if (protectSleepSession(this, this.world, needs)) return; + if (continueBuildPlan(this, this.world, dt)) return; + + const lockedTargetInvalid = this.target && this.target.dead; + const lockedBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior; + if ((this.behaviorLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { + if (continueCurrentBehavior(this, this.world, needs, dt)) return; + this.thought = lockedBehavior.reason || lockedBehavior.label || this.thought; + return; + } + + if (this.birthRitualTimer > 0.04) { + const partner = this.world.liveTarinaiById?.(this.birthPartnerId) || null; + this.setActionState?.("birth_ritual", { target: partner, reason: "へこへこしている" }); + synchronizeActionTextFromState(this); + return; + } + + if (this.intimidateTimer > 0.04) { + const target = this.world.liveTarinaiById?.(this.intimidateTargetId) || null; + this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); + if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" }); + this.bubble("!", 1.2); + return; + } + + if (this.fightTimer > 0.04) { + this.fightTargetIds = (this.fightTargetIds || []).filter(id => Boolean(this.world.liveTarinaiById?.(id))); + this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; + let best = null, bestD = Infinity; + for (const id of this.fightTargetIds) { + const t = this.world.liveTarinaiById?.(id); + if (!t) continue; + const d = dist(this, t); + if (d < bestD) { best = t; bestD = d; } + } + const maxFightContinueDistance = Math.max(150, (this.radius || 20) + (best?.radius || 20) + 116); + if (!best || bestD > maxFightContinueDistance) { + const rival = best || (this.fightTargetId ? this.world.liveTarinaiById?.(this.fightTargetId) : null); + this.fightTimer = 0; + this.fightTargetIds = []; + this.fightTargetId = null; + this.counterAttackFromId = null; + this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); + if (rival) this.world?.markFightPairCooldown?.(this, rival, CONFIG.fightCooldown + 6); + if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(this, e => e && e.id === "fight_rival"); + this.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true }); + return; + } + const reasonText = best ? `${best.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002` : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002"; + this.setActionState?.("fight", { target: best, reason: reasonText }); + if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" }); + this.bubble("!", 1.6); + return; + } + + if (this.defeatedTimer > 0.04) { + const opponent = this.world.liveTarinaiById?.(this.defeatedById); + applyNeedShock(this, { safety: 54, health: 16 }, opponent || null); + startNeedDrivenEmergencyReaction(this, opponent || null, "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b\u3002"); + if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; + return; + } else if (this.defeatedById && this.fightTimer <= 0.04) { + this.defeatedById = null; + this.fightWinnerId = null; + } + + if (this.stuckPushpinId) { + const pin = this.currentLodgedPin?.() || null; + const pinBehavior = this.currentLodgedPinBehavior?.() || null; + if (pin && pinBehavior?.panicOnAttach) { + this.needShock = this.needShock || {}; + this.needShock.safety = Math.max(this.needShock.safety || 0, 42); + this.needShock.health = Math.max(this.needShock.health || 0, 46); + this.lastNeedShockBreaker = pin; + this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24); + this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8); + const now = this.world?.time || 0; + if (now >= (this.nextPinPanicBubbleAt || 0)) { + this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); + this.nextPinPanicBubbleAt = now + 1.6; + } + this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + } + if (!pin || pin.pinState !== "lodged") this.stuckPushpinId = null; + } + + const breaker = this.lastNeedShockBreaker || null; + const safetyDelta = (this.needs?.safety || 0) - (beforeNeeds.safety || 0); + const healthDelta = (this.needs?.health || 0) - (beforeNeeds.health || 0); + const fulfillDelta = (this.needs?.fulfill || 0) - (beforeNeeds.fulfill || 0); + const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; + if (breaker && shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { + if (startNeedDrivenEmergencyReaction(this, breaker, "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002")) return; + if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null; + this.lastNeedShockBreaker = null; + return; + } + + if (continueCurrentBehavior(this, this.world, needs, dt)) return; + + if ((this.behaviorSwitchCooldownUntil || 0) > (this.world?.time || 0) && !this.hasForcedBehavior?.()) { + this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 0.25); + if (!(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior) && !["panic", "fight", "birth_ritual", "intimidate", "sleep"].includes(this.state)) this.setActionState?.("idle", { target: null, reason: this.thought || "\u5c11\u3057\u69d8\u5b50\u3092\u898b\u3066\u3044\u308b", sleeping: false, clearTarget: true }); + return; + } + + const foodNeedForLeisure = Number(needs?.food || 0) || 0; + const safetyNeedForLeisure = Number(needs?.safety || 0) || 0; + const sleepNeedForLeisure = Number(needs?.sleep || 0) || 0; + const sunbathActionForLeisure = actionById("sunbath"); + if (sunbathActionForLeisure + && this.canStartSunbath?.({ leisure: true }) + && foodNeedForLeisure < 58 + && safetyNeedForLeisure < 50 + && sleepNeedForLeisure < 68 + && Math.random() < Math.max(0.010, dt * 0.055)) { + const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathActionForLeisure) }; + startNeedAction(this, this.world, choice, sunbathActionForLeisure, "余裕があるので、日光浴している。", { leisure: true, priority: actionPriority(sunbathActionForLeisure), lockSeconds: 6.0 }); + return; + } + + const picked = choosePriorityNeedAction(needs, this, this.world); + if (!picked?.action) { + const sunbathAction = actionById("sunbath"); + if (sunbathAction && this.canStartSunbath?.({ leisure: true }) && Math.random() < Math.max(0.006, dt * 0.08)) { + const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathAction) }; + startNeedAction(this, this.world, choice, sunbathAction, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathAction), lockSeconds: 6.0 }); + return; + } + const choice = chooseTopNeedRandom(needs, this, this.world); + setBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reason: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 }); + clearBehavior(this); + this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 3.0); + this.goIdle?.("\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b"); + return; + } + const action = picked.action; + const choice = picked.choice; + const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action, this, this.world); + startNeedAction(this, this.world, choice, action, reasonText, { priority: actionPriority(action) }); + return; +} + + (function (global) { const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js"); + if (!global.TarinaiNeedPlannerLegacySystem) { + global.TarinaiNeedPlannerLegacySystem = Object.freeze({ + updateOne(tarinai, dt = 0) { + if (!tarinai || tarinai.dead) return false; + resolveNeedsLegacy.call(tarinai, dt); + return true; + }, + updateWithThis(dt = 0) { + return this && !this.dead ? (resolveNeedsLegacy.call(this, dt), true) : false; + }, + resolveNeedsLegacy, + }); + } Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ forceBehavior(actionId, options = {}) { return queueForcedTarinaiBehavior(this, actionId, options); @@ -836,296 +1004,26 @@ function protectSleepSession(tarinai, world, needs) { }, updateSunbath(dt) { - if ((this.sunbathTimer || 0) <= 0) return; - this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt); - this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt); - this.vx = 0; - this.vy = 0; - if ((this.sunbathFrameTimer || 0) <= 0) { - const prev = this._activeSunbathSpriteId || (this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant); - const next = this.randomSunbathSpriteId ? this.randomSunbathSpriteId(prev, { readyOnly: true }) : `sunbath_${1 + Math.floor(Math.random() * 3)}`; - if (next && next !== prev && (!this.isSunbathSpriteReady || this.isSunbathSpriteReady(next))) { - this._activeSunbathSpriteId = next; - this._lastRenderableSunbathSpriteId = next; - this.sunbathSpriteVariant = next; - this.visibleSpriteId = next; - this.spriteLockUntil = 0; - } else { - this.sunbathSpriteVariant = prev; - this.visibleSpriteId = prev; - } - this.sunbathFrameTimer = 0.5; - } - this.energy = clamp((this.energy || 0) + dt * 0.03, 0, 100); - if (!this.isSunbathTime() || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0; - if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { - this.finishSunbath?.(); - this._activeSunbathSpriteId = ""; - this.visibleSpriteId = ""; - this.spriteLockUntil = 0; - this.sunbathAnchorX = null; - this.sunbathAnchorY = null; - this.goIdle("\u65e5\u5149\u6d74\u3092\u7d42\u3048\u305f"); - } + return globalThis.TarinaiSunbathSystem?.updateOne(this, dt) || false; }, applyCursorContactCare(dt) { - const pointer = this.world?.pointer; - if (!pointer?.inside || !this.isAwakeCursorFriendly()) return; - const d = distXY(this.x, this.y, pointer.x, pointer.y); - const touch = this.radius * 1.35; - if (d > touch) return; - const p = clamp(1 - d / touch, 0, 1); - const relief = dt * (1.35 + 2.65 * p); - applyNeedRelief(this, { safety: -relief * 1.2, fulfill: -relief * 1.6 }); - this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted."); - this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5); - this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); - this.goodMode = "smile"; - if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) { - this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10); - for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { - this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { - vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" - })); - } - } + return globalThis.TarinaiCursorCareSystem?.updateOne(this, dt) || false; }, update(dt) { - if (this.dead) return; - this.prevX = this.x; - this.prevY = this.y; - - const minute = dt / 60; - this.age += dt; - this.reproductionTimer -= dt; - this.blink += dt; - this.eatTimer = Math.max(0, this.eatTimer - dt); - if (this.state === "eat" && this.eatTimer <= 0.02) { - this.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true }); + const legacyUpdate = globalThis.TarinaiLegacyUpdateSystem?.updateWithThis; + if (globalThis.TarinaiUpdatePipeline?.updateOne) { + return globalThis.TarinaiUpdatePipeline.updateOne(this, dt, { legacyUpdate }); } - this.eatCooldown = Math.max(0, this.eatCooldown - dt); - this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); - this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); - this.fearTimer = Math.max(0, this.fearTimer - dt); - this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); - this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); - this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); - this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); - this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); - this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt); - this.updateZunchiDisease(dt); - this.updateSpecialDiseases(dt); - if (this.updateItemEffects) this.updateItemEffects(dt); - if (this.dead) return; - this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); - this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); - if (this.fightMochiTimer > 0.04) { - applyNeedShock(this, { safety: dt * 4 }); - if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); + if (globalThis.TarinaiLegacyUpdateSystem?.updateOne) { + return globalThis.TarinaiLegacyUpdateSystem.updateOne(this, dt); } - if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; - if (this.birthRitualTimer > 0) { - const beforeBirthTimer = this.birthRitualTimer; - this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); - if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { - const partner = this.world.liveTarinaiById?.(this.birthPartnerId); - if (partner) this.world.finishBirthRitual(this, partner); - this.birthRitualLeader = false; - } - } - this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); - this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); - this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); - this.pinchThoughtTimer = Math.max(0, (this.pinchThoughtTimer || 0) - dt); - this.sunbathCooldown = Math.max(0, (this.sunbathCooldown || 0) - dt); - if (this.freezeSleepDiseaseMotion(dt)) { - this.checkLife(dt); - return; - } - this.applyCursorContactCare(dt); - const colonyMoodId = this.world?.colonyMood?.id || "relaxed"; - if (colonyMoodId === "weaklings" && !this.isZunchiSlave) applyNeedRelief(this, { safety: -dt * 2, fulfill: -dt * 1 }); - if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(this, { fulfill: -dt * 3 }); - if (colonyMoodId === "ant_overrun" && this.state !== "panic") { - const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead); - if (antSeen) { - if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30a2\u30ea\u3060", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); - else { this.setActionState?.("panic", { target: null, reason: "\u30a2\u30ea\u3060", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); } - } - } - const wasFighting = this.fightTimer > 0.04; - this.fightTimer = Math.max(0, this.fightTimer - dt); - this.fightCooldown = Math.max(0, this.fightCooldown - dt); - this.groundStumbleCooldown = Math.max(0, (this.groundStumbleCooldown || 0) - dt); - this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); - if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { - const winner = this.world.liveTarinaiById?.(this.defeatedById) || null; - this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); - this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); - this.hurtTimer = Math.max(this.hurtTimer, 1.8); - this.fallTimer = Math.max(this.fallTimer, 1.15); - this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer); - this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir; - this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45); - if (winner) this.world.resolveFightOutcome(winner, this); - this.fightTargetIds = []; - this.fightTargetId = null; - } - this.stretchTimer = Math.max(0, this.stretchTimer - dt); - this.grassEatTimer = Math.max(0, this.grassEatTimer - dt); - this.hurtTimer = Math.max(0, this.hurtTimer - dt); - this.fallTimer = Math.max(0, this.fallTimer - dt); - if ((this.world?.groundType || "soil") === "foot_massage" - && (this.groundStumbleCooldown || 0) <= 0.04 - && (this.fallTimer || 0) <= 0.04 - && !this.dead - && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(this.state || "")) - && Math.random() < dt * 0.055) { - const dir = Math.random() < 0.5 ? -1 : 1; - this.groundStumbleCooldown = rand(8.5, 16.0); - this.fallTimer = Math.max(this.fallTimer || 0, rand(0.86, 1.18)); - this.fallMax = Math.max(this.fallMax || 1.05, this.fallTimer); - this.fallDir = dir; - this.vx += dir * rand(26, 52); - this.vy += rand(-18, 18); - this.world?.spawnFallEffect?.(this.x, this.y + this.radius * 0.45); - this.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false }); - if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)"); - } - if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { - this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); - } - if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { - this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); - } - if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { - const side = this.facingDir(); - this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); - } - - this.tempTimer += dt; - if (this.tempTimer >= 0.55) { - this.tempComfort = this.world.temperatureAt(this.x, this.y); - this.tempTimer = 0; - } - const tempComfort = this.tempComfort ?? 0.62; - const tooCold = Math.max(0, 0.48 - tempComfort); - this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8); - this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); - this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); - // stress is derived from needs; no independent stress decay. - if (tooCold > 0) applyNeedShock(this, { health: tooCold * dt * 4, safety: tooCold * dt * 2 }); - - const phase = this.world.lightLevel(); - if (phase < 0.25) this.energy += dt * 0.07; - if (phase > 0.75) applyNeedShock(this, { health: dt * 1.2 }); - if (this.world.weather === "cloudy") { - applyNeedRelief(this, { safety: -dt * 1 }); - this.vx *= Math.pow(0.996, dt * 60); - this.vy *= Math.pow(0.996, dt * 60); - } else if (this.world.weather === "light_rain") { - this.energy += dt * 0.018; - applyNeedRelief(this, { health: -dt * 1, safety: -dt * 1 }); - } else if (this.world.weather === "sunny") { - this.vx *= Math.pow(1.001, dt * 60); - this.vy *= Math.pow(1.001, dt * 60); - } - - if (this.generation > 1 && this.syncGrowthScale) this.syncGrowthScale(); - - this.hunger = clamp(this.hunger, 0, 115); - this.loneliness = clamp(this.loneliness, 0, 110); - this.energy = clamp(this.energy, 0, 100); - this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds())); - - const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; - this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); - - if (this.freezeSleepDiseaseMotion(dt)) { - this.checkLife(dt); - return; - } - - this.behaviorLockTimer = Math.max(0, (this.behaviorLockTimer || 0) - dt); - this.aiTimer += dt; - const urgentFoodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0; - const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02 || this.hunger >= 96 || urgentFoodNeed >= 84; - const aiJitter = stableUnit(this.familyKey || this.id, "ai-interval") * 0.35; - const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter); - if (this.aiTimer >= aiInterval && this.world.spendScheduledWork?.("ai", urgentAi) !== false) { - const elapsedAiDt = Math.max(0.016, Math.min(this.aiTimer, urgentAi ? 0.75 : 1.50)); - const scanDt = Math.min(elapsedAiDt, 0.75); - this.aiTimer = 0; - // Needs intentionally advance by a fixed coarse step per AI update. - // Exact elapsed-time integration is unnecessary for this observation game - // and can over-amplify safety/fear transitions when updates are sparse. - const needStepDt = urgentAi ? 0.50 : 1.00; - this.resolveNeeds(needStepDt); - const sleepingNow = this.state === "sleep"; - if (!sleepingNow) { - this.reactToFreshZunchi(scanDt); - this.updateFacing(); - this.interactWithItems(scanDt); - this.interactWithOthers(scanDt); - } else { - this.updateFacing(); - } - } else { - this.updateFacing(); - } - this.envTimer += dt; - const envInterval = 3.0 + stableUnit(this.familyKey || this.id, "env-phase") * 1.4; - if (this.envTimer >= envInterval && this.world.spendScheduledWork?.("env", urgentAi || this.world.selected === this) !== false) { - const envDt = Math.min(this.envTimer, 1.2); - this.envTimer = 0; - this.applyEnvironment(envDt); - } - this.updateSunbath(dt); - this.maintainTargetProgress(dt); - this.move(dt); - if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt); - this.updateNestBoxPresence(dt); - if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); - const sleepingAtFrameEnd = this.state === "sleep" || this.sleeping; - if (wasSleepingAtFrameStart && !sleepingAtFrameEnd && !this.dead) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); - this.checkLife(dt); + return false; }, applyEnvironment(dt) { - const parent = this.parentToFollow ? this.parentToFollow() : null; - if (parent && dist(this, parent) < 96) { - applyNeedRelief(this, { social: -dt * 4, safety: -dt * 2 }); - this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); - this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family."); - } - const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null; - const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; - if (friend) { - applyNeedRelief(this, { social: -dt * 3, fulfill: -dt * 1 }); - this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends."); - } else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) { - this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends."); - } - let grassComfort = 0; - for (const it of this.world.nearbyItems(this.x, this.y, 105)) { - if (it.type !== "grass" || it.dead) continue; - const d = distXY(this.x, this.y, it.x, it.y); - const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2); - grassComfort += clamp(1 - d / 105, 0, 1) * lush; - } - let nestComfort = 0; - for (const it of this.world.nearbyItems(this.x, this.y, 120)) { - if (!it || it.dead || it.type !== "nest_box") continue; - nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1); - } - if (nestComfort > 0) applyNeedRelief(this, { sleep: -dt * Math.min(4, nestComfort * 3), safety: -dt * Math.min(5, nestComfort * 4) }); - if (grassComfort > 0) { - applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) }); - if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); - if (this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); - } + return globalThis.TarinaiLocalEnvironmentSystem?.updateOne(this, dt) || false; }, bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)", options = {}) { @@ -1252,153 +1150,7 @@ function protectSleepSession(tarinai, world, needs) { }, resolveNeeds(dt) { - decayActionCooldowns(this, dt); - const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds(); - const needs = updateNeedsCached(this, this.world, dt); - synchronizeActionTextFromState(this); - if (processForcedBehaviorQueue(this, this.world, needs)) return; - if (protectSleepSession(this, this.world, needs)) return; - if (continueBuildPlan(this, this.world, dt)) return; - - const lockedTargetInvalid = this.target && this.target.dead; - const lockedBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior; - if ((this.behaviorLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { - if (continueCurrentBehavior(this, this.world, needs, dt)) return; - this.thought = lockedBehavior.reason || lockedBehavior.label || this.thought; - return; - } - - if (this.birthRitualTimer > 0.04) { - const partner = this.world.liveTarinaiById?.(this.birthPartnerId) || null; - this.setActionState?.("birth_ritual", { target: partner, reason: "へこへこしている" }); - synchronizeActionTextFromState(this); - return; - } - - if (this.intimidateTimer > 0.04) { - const target = this.world.liveTarinaiById?.(this.intimidateTargetId) || null; - this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); - if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" }); - this.bubble("!", 1.2); - return; - } - - if (this.fightTimer > 0.04) { - this.fightTargetIds = (this.fightTargetIds || []).filter(id => Boolean(this.world.liveTarinaiById?.(id))); - this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; - let best = null, bestD = Infinity; - for (const id of this.fightTargetIds) { - const t = this.world.liveTarinaiById?.(id); - if (!t) continue; - const d = dist(this, t); - if (d < bestD) { best = t; bestD = d; } - } - const maxFightContinueDistance = Math.max(150, (this.radius || 20) + (best?.radius || 20) + 116); - if (!best || bestD > maxFightContinueDistance) { - const rival = best || (this.fightTargetId ? this.world.liveTarinaiById?.(this.fightTargetId) : null); - this.fightTimer = 0; - this.fightTargetIds = []; - this.fightTargetId = null; - this.counterAttackFromId = null; - this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); - if (rival) this.world?.markFightPairCooldown?.(this, rival, CONFIG.fightCooldown + 6); - if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(this, e => e && e.id === "fight_rival"); - this.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true }); - return; - } - const reasonText = best ? `${best.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002` : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002"; - this.setActionState?.("fight", { target: best, reason: reasonText }); - if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" }); - this.bubble("!", 1.6); - return; - } - - if (this.defeatedTimer > 0.04) { - const opponent = this.world.liveTarinaiById?.(this.defeatedById); - applyNeedShock(this, { safety: 54, health: 16 }, opponent || null); - startNeedDrivenEmergencyReaction(this, opponent || null, "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b\u3002"); - if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; - return; - } else if (this.defeatedById && this.fightTimer <= 0.04) { - this.defeatedById = null; - this.fightWinnerId = null; - } - - if (this.stuckPushpinId) { - const pin = this.currentLodgedPin?.() || null; - const pinBehavior = this.currentLodgedPinBehavior?.() || null; - if (pin && pinBehavior?.panicOnAttach) { - this.needShock = this.needShock || {}; - this.needShock.safety = Math.max(this.needShock.safety || 0, 42); - this.needShock.health = Math.max(this.needShock.health || 0, 46); - this.lastNeedShockBreaker = pin; - this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24); - this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8); - const now = this.world?.time || 0; - if (now >= (this.nextPinPanicBubbleAt || 0)) { - this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); - this.nextPinPanicBubbleAt = now + 1.6; - } - this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; - } - if (!pin || pin.pinState !== "lodged") this.stuckPushpinId = null; - } - - const breaker = this.lastNeedShockBreaker || null; - const safetyDelta = (this.needs?.safety || 0) - (beforeNeeds.safety || 0); - const healthDelta = (this.needs?.health || 0) - (beforeNeeds.health || 0); - const fulfillDelta = (this.needs?.fulfill || 0) - (beforeNeeds.fulfill || 0); - const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; - if (breaker && shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { - if (startNeedDrivenEmergencyReaction(this, breaker, "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002")) return; - if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null; - this.lastNeedShockBreaker = null; - return; - } - - if (continueCurrentBehavior(this, this.world, needs, dt)) return; - - if ((this.behaviorSwitchCooldownUntil || 0) > (this.world?.time || 0) && !this.hasForcedBehavior?.()) { - this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 0.25); - if (!(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior) && !["panic", "fight", "birth_ritual", "intimidate", "sleep"].includes(this.state)) this.setActionState?.("idle", { target: null, reason: this.thought || "\u5c11\u3057\u69d8\u5b50\u3092\u898b\u3066\u3044\u308b", sleeping: false, clearTarget: true }); - return; - } - - const foodNeedForLeisure = Number(needs?.food || 0) || 0; - const safetyNeedForLeisure = Number(needs?.safety || 0) || 0; - const sleepNeedForLeisure = Number(needs?.sleep || 0) || 0; - const sunbathActionForLeisure = actionById("sunbath"); - if (sunbathActionForLeisure - && this.canStartSunbath?.({ leisure: true }) - && foodNeedForLeisure < 58 - && safetyNeedForLeisure < 50 - && sleepNeedForLeisure < 68 - && Math.random() < Math.max(0.010, dt * 0.055)) { - const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathActionForLeisure) }; - startNeedAction(this, this.world, choice, sunbathActionForLeisure, "余裕があるので、日光浴している。", { leisure: true, priority: actionPriority(sunbathActionForLeisure), lockSeconds: 6.0 }); - return; - } - - const picked = choosePriorityNeedAction(needs, this, this.world); - if (!picked?.action) { - const sunbathAction = actionById("sunbath"); - if (sunbathAction && this.canStartSunbath?.({ leisure: true }) && Math.random() < Math.max(0.006, dt * 0.08)) { - const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathAction) }; - startNeedAction(this, this.world, choice, sunbathAction, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathAction), lockSeconds: 6.0 }); - return; - } - const choice = chooseTopNeedRandom(needs, this, this.world); - setBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reason: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 }); - clearBehavior(this); - this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 3.0); - this.goIdle?.("\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b"); - return; - } - const action = picked.action; - const choice = picked.choice; - const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action, this, this.world); - startNeedAction(this, this.world, choice, action, reasonText, { priority: actionPriority(action) }); - return; + return global.TarinaiNeedPlannerSystem?.updateOne?.(this, dt) ?? global.TarinaiNeedPlannerLegacySystem?.updateOne?.(this, dt); }, makePoop(force = 1) { @@ -1427,267 +1179,7 @@ function protectSleepSession(tarinai, world, needs) { }, interactWithItems(dt) { - const foodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0; - const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0; - const currentNeed = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need; - const foodActionActive = currentNeed === "food" || ["seek_food", "eat", "seek_water"].includes(this.state); - const healthActionActive = currentNeed === "health" || this.state === "seek_food"; - const mealReady = (this.mealCooldownUntil || 0) <= (this.world?.time || 0) || this.hunger > 92 || foodNeed > 84; - const wantsFood = foodActionActive || foodNeed >= needThreshold("food", "start") || this.hunger > 70; - const wantsMedicine = healthActionActive || healthNeed >= needThreshold("health", "start"); - const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && wantsFood && this.hunger > 18; - const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && this.hunger > 7; - const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger >= 102 || foodNeed >= 92)) && (this.isZunchiSlave || !hasBetterFoodThanZunchiNearby(this, this.world, 260)); - const scanLimit = (this.hunger >= 96 || foodNeed >= 84) ? 32 : 18; - const foodScanRadius = this.radius + ((this.hunger >= 96 || foodNeed >= 84) ? 104 : 56); - const nearbyFood = this.world.nearbyFood?.(this.x, this.y, foodScanRadius, true) || []; - const nearbyItems = nearbyFood.length ? nearbyFood : this.world.nearbyItems(this.x, this.y, foodScanRadius); - const foodCandidates = []; - if (this.target && !this.target.dead && (this.target.type === "duplicator" || roleMatches(this.target, "food") || roleMatches(this.target, "medicine"))) foodCandidates.push(this.target); - for (const it of nearbyItems) { - if (!it || it.dead || foodCandidates.includes(it)) continue; - if (!(it.type === "duplicator" || roleMatches(it, "food") || roleMatches(it, "medicine") || it.type === "water" || it.type === "water_bowl")) continue; - foodCandidates.push(it); - if (foodCandidates.length >= scanLimit) break; - } - if (foodCandidates.length > 1 && foodCandidates.length <= scanLimit) { - foodCandidates.sort((a, b) => foodPriorityScore(this, b, b?.type === "duplicator" ? foodInteractionDistance(this, b) : dist(this, b)) - foodPriorityScore(this, a, a?.type === "duplicator" ? foodInteractionDistance(this, a) : dist(this, a))); - } - for (const it of foodCandidates) { - const d = it?.type === "duplicator" ? foodInteractionDistance(this, it) : dist(this, it); - const contactReach = foodInteractionReach(this, it, "food") + (it === this.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0); - if (d > contactReach) continue; - const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type; - const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi"; - - if (((foodType === "sweet" && canSweetBite) || (foodType === "food" && (canBite || foodActionActive)) || duplicatorConsumable) && this.foodItemHasServingLeft(it)) { - const eating = this.consumeFoodServing(it); - if (eating > 0) { - const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type); - this.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`); - this.vx *= Math.pow(0.25, dt * 60); - this.vy *= Math.pow(0.25, dt * 60); - const hungerRelief = this.foodHungerReliefFor(foodType || it.type, eating, foodType === "sweet" ? 0.75 : 0.95); - this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); - this.recoverHealth(eating * (foodType === "sweet" ? 0.28 : 0.46)); - this.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012; - if (foodType === "sweet") { - applyNeedRelief(this, { food: -eating * 0.4, fulfill: -eating * 0.2 }); - this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); - if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); - if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); - } - this.eatTimer = Math.max(this.eatTimer, 1.0); - this.eatCooldown = rand(0.96, 1.12); - this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)"); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); - if (Math.random() < dt * 2.2) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "sweet" ? "#78b957" : "#f1dfb7"); - } - this.lastMealColor = foodType === "sweet" ? "#78b957" : "#f1dfb7"; - const fromDuplicator = isDuplicatorFoodSource(it); - this.makePoop(normalPoopUnitsFor(foodType || it.type, eating)); - if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } - if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) { - this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] }); - this.lastLog = this.world.time; - } - break; - } - } - - if (canSweetBite && (foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && this.foodItemHasServingLeft(it)) { - const eating = this.consumeFoodServing(it); - if (eating <= 0) continue; - const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type); - this.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`); - this.vx *= Math.pow(0.25, dt * 60); - this.vy *= Math.pow(0.25, dt * 60); - const hungerRelief = this.foodHungerReliefFor(foodType || it.type, eating, 0.82); - this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); - this.recoverHealth(eating * 0.24); - this.affection += eating * 0.04; - const mealColors = { - love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", - protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", - mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f", - }; - const mealBubbles = { - love_mochi: ["\u2661", 2.4, "rgba(190,82,132,0.78)"], - fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"], - sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"], - }; - if (this.applyParamItemEffect) this.applyParamItemEffect(foodType || it.type, it, { - source: "eating", - nutrition: eating, - silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug", - }); - if (mealBubbles[foodType]) this.bubble(...mealBubbles[foodType]); - this.lastMealColor = mealColors[foodType] || "#41aa66"; - this.eatTimer = Math.max(this.eatTimer, 1.0); - this.eatCooldown = rand(0.96, 1.12); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); - if (Math.random() < dt * 2.2) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (this.lastMealColor || "#41aa66")))); - } - const fromDuplicator = isDuplicatorFoodSource(it); - this.makePoop(normalPoopUnitsFor(foodType || it.type, eating)); - if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } - if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { - this.world.log(`${this.name}\u306f${toolLabel(foodType || it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); - this.lastLog = this.world.time; - } - break; - } - - if (canBite && it.type === "grass") { - const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); - const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); - this.beginEating(it, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b"); - this.grassEatTimer = Math.max(this.grassEatTimer, 1.0); - this.vx *= Math.pow(0.20, dt * 60); - this.vy *= Math.pow(0.20, dt * 60); - it.amount -= bite; - it.eatenAmount = (it.eatenAmount || 0) + bite; - it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); - this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46); - this.recoverHealth(bite * 0.18); - applyNeedRelief(this, { food: -dt * 2, fulfill: -dt * 1 }); - this.eatTimer = Math.max(this.eatTimer, 1.0); - this.eatCooldown = rand(0.96, 1.14); - this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); - if (Math.random() < dt * 2.0) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39"); - } - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; } - this.makePoop(normalPoopUnitsFor("grass", bite)); - if (this.type === "jito") this.affection += dt * 0.25; - break; - } - - if (canBite && it.type === "ant_corpse") { - const bite = Math.min(it.amount, rand(3.2, 5.4)); - if (bite > 0) { - this.beginEating(it, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b"); - this.vx *= Math.pow(0.18, dt * 60); - this.vy *= Math.pow(0.18, dt * 60); - it.amount -= bite; - this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62); - this.recoverHealth(bite * 0.12); - applyNeedShock(this, { safety: bite * 0.12 }); - this.eatTimer = Math.max(this.eatTimer, 1.0); - this.eatCooldown = rand(0.96, 1.16); - this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36"); - this.lastMealColor = "#4a3f36"; - this.makePoop(normalPoopUnitsFor("ant_corpse", bite)); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.62; } - break; - } - } - - if (it.type === "zunchi") { - const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - if (!this.isZunchiSlave) { - applyNeedShock(this, { health: touch * dt * 7, safety: touch * dt * 4 }); - if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); - } - this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100); - const emergencyHunger = (this.hunger || 0) >= 104 || foodNeed >= 94; - const zunchiTooFresh = zunchiEatingBlocked(this, it, this.world) || (!emergencyHunger && !this.isZunchiSlave && it.stage === "fresh" && (it.age || 0) < 150 && foodNeed < 94 && (this.hunger || 0) < 104); - if (canZunchiBite && !zunchiTooFresh) { - const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); - const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); - this.beginEating(it, this.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b"); - this.vx *= Math.pow(0.18, dt * 60); - this.vy *= Math.pow(0.18, dt * 60); - it.amount -= bite; - this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * (this.isZunchiSlave ? 0.66 : 0.18)) : (this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18)); - this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); - applyNeedShock(this, { health: bite * (this.isZunchiSlave ? 0.2 : 1.2), safety: bite * (this.isZunchiSlave ? 0.1 : 0.6) }); - if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); - this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100); - this.lastMealColor = "#6b8d3f"; - this.eatTimer = Math.max(this.eatTimer, 1.0); - this.eatCooldown = rand(1.0, 1.22); - this.bubble("\u3082\u3050", 2.2, "rgba(74,91,50,0.78)"); - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; } - break; - } - } - - if (it.type === "water" || it.type === "water_bowl") { - this.thought = it.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; - this.target = it; - this.applyWaterEffect(dt, it.type); - if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) { - audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); - this.nextDrinkSound = (this.world?.time || 0) + 0.72; - } - if (it.type === "water") it.amount -= dt * 2.4; - if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18; - } - - if (it.type === "stone") { - if (this.state === "idle" && this.stress > 48) this.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; - applyNeedRelief(this, { safety: -dt * 4, fulfill: -dt * 2 }); - this.energy += dt * 0.18; - - } - - if (this.isSleepFurniture(it)) { - if (this.energy < 74 + 6 * this.personalityProfile().sleep) { - const isEasyBed = it.type === "grass_bed"; - const occ = this.world.bedOccupancy(it); - const comfort = this.world.bedComfort(it); - it.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined); - this.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b")); - if (this.world.time > this.nextSleepBubbleAt) { - this.nextSleepBubbleAt = this.world.time + 4.5; - const crowded = occ > 5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); - } - this.energy += dt * (1.9 + comfort * 1.4); - if (occ > 5) applyNeedShock(this, { safety: dt * 3 }); - it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); - } - } - - if (it.type === "ball") { - const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - if (this.state === "play_ball" || this.target === it || this.shouldApplyPersonalityBehavior("openness", 1)) { - this.goodMode = "smile"; - applyNeedRelief(this, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) }); - this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); - if (this.state === "play_ball") this.foodReactTimer = Math.max(this.foodReactTimer, 0.10); - } - } - - if (it.type === "splat") { - const touch = clamp(1 - d / (this.radius + it.r + 16), 0, 1); - if (touch > 0) { - applyNeedShock(this, { safety: touch * dt * 5, health: touch * dt * 3 }); - if (touch > 0.06) this.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); - } - } - - if (it.type === "trace") { - this.loneliness -= dt * 0.25; - applyNeedShock(this, { social: -dt * 1, safety: dt * 0.8 }); - } - } - this.hunger = clamp(this.hunger, 0, 115); - this.energy = clamp(this.energy, 0, 100); - this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds())); - this.loneliness = clamp(this.loneliness, 0, 110); + return window.TarinaiItemInteractionSystem?.updateOne?.(this, dt); } })); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_render.js b/js/tarinai_render.js index f82189d..fab1132 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -548,7 +548,7 @@ drawMeter(baseY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); } if (hpActive) { - const pct = clamp(this.energy / 100, 0, 1); + const pct = typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp(this.energy / Math.max(1, this.maxEnergy || 100), 0, 1); drawMeter(baseY - (stressActive ? 11 : 0), pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); } } diff --git a/js/tarinai_runtime.js b/js/tarinai_runtime.js new file mode 100644 index 0000000..a4b07e2 --- /dev/null +++ b/js/tarinai_runtime.js @@ -0,0 +1,61 @@ +"use strict"; + +// Layer: entity-runtime/tarinai +// Owns the public Tarinai update boundary. Tarinai.prototype.update remains a +// compatibility facade; concrete behavior now routes through TarinaiUpdatePipeline +// when available, with the captured legacy update kept as a fallback. +(function (global) { + let legacyUpdate = null; + + function preferredLegacyUpdate(proto) { + const systemLegacy = global.TarinaiLegacyUpdateSystem?.updateWithThis; + if (typeof systemLegacy === "function") return systemLegacy; + return proto?.update || legacyUpdate; + } + + function installPrototypeBoundary() { + const proto = global.Tarinai?.prototype; + if (!proto || proto._tarinaiCreatureRuntimeInstalled) return false; + legacyUpdate = preferredLegacyUpdate(proto); + if (typeof legacyUpdate === "function" && !proto._tarinaiLegacyUpdate) { + Object.defineProperty(proto, "_tarinaiLegacyUpdate", { + value: legacyUpdate, + configurable: true, + writable: true, + }); + } + proto.update = function update(dt) { + return global.TarinaiCreatureRuntime.updateOne(this, dt); + }; + Object.defineProperty(proto, "_tarinaiCreatureRuntimeInstalled", { + value: true, + configurable: true, + writable: true, + }); + return true; + } + + function updateOne(tarinai, dt) { + const fn = tarinai?._tarinaiLegacyUpdate || legacyUpdate || global.TarinaiLegacyUpdateSystem?.updateWithThis; + if (!tarinai || tarinai.dead || typeof fn !== "function") return false; + if (global.TarinaiUpdatePipeline?.updateOne) { + return global.TarinaiUpdatePipeline.updateOne(tarinai, dt, { legacyUpdate: fn }) !== false; + } + fn.call(tarinai, dt); + return true; + } + + function updateBatch(tarinaiList, dt) { + if (!Array.isArray(tarinaiList)) return 0; + let ran = 0; + for (const t of tarinaiList) if (updateOne(t, dt)) ran += 1; + return ran; + } + + global.TarinaiCreatureRuntime = Object.freeze({ + installPrototypeBoundary, + updateOne, + updateBatch, + }); + installPrototypeBoundary(); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index a63a916..4324aac 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -215,9 +215,10 @@ const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72; const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; const crowdedSleep = bedCrowd > 5; - this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); + this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); - this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); + const sleepHungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1); + this.hunger = clamp(this.hunger + dt * 0.018 * sleepHungerScale, 0, 115); if (this.target && !this.target.dead && this.target.type === "nest_box") { this.updateNestBoxPresence(dt); this.vx = 0; diff --git a/js/tarinai_sunbath_system.js b/js/tarinai_sunbath_system.js new file mode 100644 index 0000000..38575ab --- /dev/null +++ b/js/tarinai_sunbath_system.js @@ -0,0 +1,51 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-system +// Owns active sunbath timer, sprite frame cycling, and recovery finish logic. +(function (global) { + function legacyMethod(dt) { + if ((this.sunbathTimer || 0) <= 0) return; + this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt); + this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt); + this.vx = 0; + this.vy = 0; + if ((this.sunbathFrameTimer || 0) <= 0) { + const prev = this._activeSunbathSpriteId || (this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant); + const next = this.randomSunbathSpriteId ? this.randomSunbathSpriteId(prev, { readyOnly: true }) : `sunbath_${1 + Math.floor(Math.random() * 3)}`; + if (next && next !== prev && (!this.isSunbathSpriteReady || this.isSunbathSpriteReady(next))) { + this._activeSunbathSpriteId = next; + this._lastRenderableSunbathSpriteId = next; + this.sunbathSpriteVariant = next; + this.visibleSpriteId = next; + this.spriteLockUntil = 0; + } else { + this.sunbathSpriteVariant = prev; + this.visibleSpriteId = prev; + } + this.sunbathFrameTimer = 0.5; + } + this.energy = clamp((this.energy || 0) + dt * 0.03, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); + if (!this.isSunbathTime() || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0; + if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { + this.finishSunbath?.(); + this._activeSunbathSpriteId = ""; + this.visibleSpriteId = ""; + this.spriteLockUntil = 0; + this.sunbathAnchorX = null; + this.sunbathAnchorY = null; + this.goIdle("\u65e5\u5149\u6d74\u3092\u7d42\u3048\u305f"); + } + + } + + function updateOne(tarinai, dt) { + if (!tarinai || tarinai.dead) return false; + legacyMethod.call(tarinai, dt); + return true; + } + + global.TarinaiSunbathSystem = Object.freeze({ + legacyMethod, + updateOne, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_legacy_system.js b/js/tarinai_update_legacy_system.js new file mode 100644 index 0000000..4147160 --- /dev/null +++ b/js/tarinai_update_legacy_system.js @@ -0,0 +1,226 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/legacy +// Compatibility owner for the former monolithic Tarinai.prototype.update body. +// The normal runtime path uses TarinaiUpdatePipeline concrete steps; this module +// exists only as a stable fallback while update steps continue to be refined. +(function (global) { + function legacyUpdate(dt) { + if (this.dead) return; + this.prevX = this.x; + this.prevY = this.y; + + const minute = dt / 60; + this.age += dt; + this.reproductionTimer -= dt; + this.blink += dt; + this.eatTimer = Math.max(0, this.eatTimer - dt); + if (this.state === "eat" && this.eatTimer <= 0.02) { + this.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true }); + } + this.eatCooldown = Math.max(0, this.eatCooldown - dt); + this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); + this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); + this.fearTimer = Math.max(0, this.fearTimer - dt); + this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); + this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); + this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); + this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); + this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); + this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt); + this.updateZunchiDisease(dt); + this.updateSpecialDiseases(dt); + if (this.updateItemEffects) this.updateItemEffects(dt); + if (this.dead) return; + this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); + this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); + if (this.fightMochiTimer > 0.04) { + applyNeedShock(this, { safety: dt * 4 }); + if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); + } + if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; + if (this.birthRitualTimer > 0) { + const beforeBirthTimer = this.birthRitualTimer; + this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); + if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { + const partner = this.world.liveTarinaiById?.(this.birthPartnerId); + if (partner) this.world.finishBirthRitual(this, partner); + this.birthRitualLeader = false; + } + } + this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); + this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); + this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); + this.pinchThoughtTimer = Math.max(0, (this.pinchThoughtTimer || 0) - dt); + this.sunbathCooldown = Math.max(0, (this.sunbathCooldown || 0) - dt); + if (this.freezeSleepDiseaseMotion(dt)) { + this.checkLife(dt); + return; + } + this.applyCursorContactCare(dt); + const colonyMoodId = this.world?.colonyMood?.id || "relaxed"; + if (colonyMoodId === "weaklings" && !this.isZunchiSlave) applyNeedRelief(this, { safety: -dt * 2, fulfill: -dt * 1 }); + if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(this, { fulfill: -dt * 3 }); + if (colonyMoodId === "ant_overrun" && this.state !== "panic") { + const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead); + if (antSeen) { + if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30a2\u30ea\u3060", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); + else { this.setActionState?.("panic", { target: null, reason: "\u30a2\u30ea\u3060", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); } + } + } + const wasFighting = this.fightTimer > 0.04; + this.fightTimer = Math.max(0, this.fightTimer - dt); + this.fightCooldown = Math.max(0, this.fightCooldown - dt); + this.groundStumbleCooldown = Math.max(0, (this.groundStumbleCooldown || 0) - dt); + this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); + if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { + const winner = this.world.liveTarinaiById?.(this.defeatedById) || null; + this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); + this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); + this.hurtTimer = Math.max(this.hurtTimer, 1.8); + this.fallTimer = Math.max(this.fallTimer, 1.15); + this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer); + this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir; + this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45); + if (winner) this.world.resolveFightOutcome(winner, this); + this.fightTargetIds = []; + this.fightTargetId = null; + } + this.stretchTimer = Math.max(0, this.stretchTimer - dt); + this.grassEatTimer = Math.max(0, this.grassEatTimer - dt); + this.hurtTimer = Math.max(0, this.hurtTimer - dt); + this.fallTimer = Math.max(0, this.fallTimer - dt); + if ((this.world?.groundType || "soil") === "foot_massage" + && (this.groundStumbleCooldown || 0) <= 0.04 + && (this.fallTimer || 0) <= 0.04 + && !this.dead + && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(this.state || "")) + && Math.random() < dt * 0.055) { + const dir = Math.random() < 0.5 ? -1 : 1; + this.groundStumbleCooldown = rand(8.5, 16.0); + this.fallTimer = Math.max(this.fallTimer || 0, rand(0.86, 1.18)); + this.fallMax = Math.max(this.fallMax || 1.05, this.fallTimer); + this.fallDir = dir; + this.vx += dir * rand(26, 52); + this.vy += rand(-18, 18); + this.world?.spawnFallEffect?.(this.x, this.y + this.radius * 0.45); + this.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false }); + if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)"); + } + if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { + this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); + } + if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { + this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); + } + if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { + const side = this.facingDir(); + this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); + } + + this.tempTimer += dt; + if (this.tempTimer >= 0.55) { + this.tempComfort = this.world.temperatureAt(this.x, this.y); + this.tempTimer = 0; + } + const tempComfort = this.tempComfort ?? 0.62; + const tooCold = Math.max(0, 0.48 - tempComfort); + const hungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1); + this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * hungerScale * (1 + tooCold * 0.8); + this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); + this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); + // stress is derived from needs; no independent stress decay. + if (tooCold > 0) applyNeedShock(this, { health: tooCold * dt * 4, safety: tooCold * dt * 2 }); + + const phase = this.world.lightLevel(); + if (phase < 0.25) this.energy += dt * 0.07; + if (phase > 0.75) applyNeedShock(this, { health: dt * 1.2 }); + if (this.world.weather === "cloudy") { + applyNeedRelief(this, { safety: -dt * 1 }); + this.vx *= Math.pow(0.996, dt * 60); + this.vy *= Math.pow(0.996, dt * 60); + } else if (this.world.weather === "light_rain") { + this.energy += dt * 0.018; + applyNeedRelief(this, { health: -dt * 1, safety: -dt * 1 }); + } else if (this.world.weather === "sunny") { + this.vx *= Math.pow(1.001, dt * 60); + this.vy *= Math.pow(1.001, dt * 60); + } + + if (this.generation > 1 && this.syncGrowthScale) this.syncGrowthScale(); + + this.hunger = clamp(this.hunger, 0, 115); + this.loneliness = clamp(this.loneliness, 0, 110); + this.energy = clamp(this.energy, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); + this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds())); + + const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; + this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); + + if (this.freezeSleepDiseaseMotion(dt)) { + this.checkLife(dt); + return; + } + + this.behaviorLockTimer = Math.max(0, (this.behaviorLockTimer || 0) - dt); + this.aiTimer += dt; + const urgentFoodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0; + const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02 || this.hunger >= 96 || urgentFoodNeed >= 84; + const aiJitter = stableUnit(this.familyKey || this.id, "ai-interval") * 0.35; + const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter); + if (this.aiTimer >= aiInterval && this.world.spendScheduledWork?.("ai", urgentAi) !== false) { + const elapsedAiDt = Math.max(0.016, Math.min(this.aiTimer, urgentAi ? 0.75 : 1.50)); + const scanDt = Math.min(elapsedAiDt, 0.75); + this.aiTimer = 0; + // Needs intentionally advance by a fixed coarse step per AI update. + // Exact elapsed-time integration is unnecessary for this observation game + // and can over-amplify safety/fear transitions when updates are sparse. + const needStepDt = urgentAi ? 0.50 : 1.00; + this.resolveNeeds(needStepDt); + const sleepingNow = this.state === "sleep"; + if (!sleepingNow) { + this.reactToFreshZunchi(scanDt); + this.updateFacing(); + this.interactWithItems(scanDt); + this.interactWithOthers(scanDt); + } else { + this.updateFacing(); + } + } else { + this.updateFacing(); + } + this.envTimer += dt; + const envInterval = 3.0 + stableUnit(this.familyKey || this.id, "env-phase") * 1.4; + if (this.envTimer >= envInterval && this.world.spendScheduledWork?.("env", urgentAi || this.world.selected === this) !== false) { + const envDt = Math.min(this.envTimer, 1.2); + this.envTimer = 0; + this.applyEnvironment(envDt); + } + this.updateSunbath(dt); + this.maintainTargetProgress(dt); + this.move(dt); + if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt); + this.updateNestBoxPresence(dt); + if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); + const sleepingAtFrameEnd = this.state === "sleep" || this.sleeping; + if (wasSleepingAtFrameStart && !sleepingAtFrameEnd && !this.dead) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); + this.checkLife(dt); + + } + + function updateOne(tarinai, dt) { + if (!tarinai || tarinai.dead) return false; + legacyUpdate.call(tarinai, dt); + return true; + } + + function updateWithThis(dt) { + return updateOne(this, dt); + } + + global.TarinaiLegacyUpdateSystem = Object.freeze({ + legacyUpdate, + updateOne, + updateWithThis, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_pipeline.js b/js/tarinai_update_pipeline.js new file mode 100644 index 0000000..47bb8a6 --- /dev/null +++ b/js/tarinai_update_pipeline.js @@ -0,0 +1,55 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/pipeline +// Owns the ordered Tarinai update pipeline. Runtime calls this instead of +// depending on the monolithic prototype update body. The legacy step is retained +// as a compatibility fallback while concrete steps are split out incrementally. +(function (global) { + const CONCRETE_STEP_NAMES = Object.freeze([ + "TarinaiFrameUpdateStep", + "TarinaiAiUpdateStep", + "TarinaiEnvironmentUpdateStep", + "TarinaiMovementUpdateStep", + "TarinaiHealthUpdateStep", + ]); + + function concreteSteps() { + return CONCRETE_STEP_NAMES.map(name => global[name]).filter(step => step && typeof step.update === "function"); + } + + function canRunConcretePipeline() { + return concreteSteps().length === CONCRETE_STEP_NAMES.length; + } + + function runConcrete(tarinai, dt, context) { + for (const step of concreteSteps()) { + const result = step.update(tarinai, dt, context) || {}; + if (result.done) return result.ok !== false; + } + return true; + } + + function runLegacy(tarinai, dt, context) { + const step = global.TarinaiLegacyUpdateStep; + if (step && typeof step.update === "function") return step.update(tarinai, dt, context).ok !== false; + const legacyUpdate = context.legacyUpdate; + if (typeof legacyUpdate !== "function") return false; + legacyUpdate.call(tarinai, dt); + return true; + } + + function updateOne(tarinai, dt, options = {}) { + if (!tarinai || tarinai.dead) return false; + const context = { + legacyUpdate: options.legacyUpdate, + wasSleepingAtFrameStart: false, + }; + return canRunConcretePipeline() ? runConcrete(tarinai, dt, context) : runLegacy(tarinai, dt, context); + } + + global.TarinaiUpdatePipeline = Object.freeze({ + stepNames: CONCRETE_STEP_NAMES, + canRunConcretePipeline, + updateOne, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_policy.js b/js/tarinai_update_policy.js new file mode 100644 index 0000000..57c6f00 --- /dev/null +++ b/js/tarinai_update_policy.js @@ -0,0 +1,29 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/policy +// Owns Tarinai update cadence decisions. The simulation creature system asks +// this policy when an individual should run at realtime vs low-frequency cadence. +(function (global) { + const helpers = () => global.TarinaiSimulationRuntime?.helpers || {}; + + function updateInterval(worldRef, tarinai, visibleRect) { + if (!tarinai || tarinai.dead) return Infinity; + if (worldRef.selected === tarinai) return 0; + if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || (tarinai.entryTimer || 0) > 0.01) return 0; + if (helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, visibleRect)) return 0; + const count = (worldRef.tarinai || []).length; + const tier = global.TarinaiPerf?.renderQualityTier?.() || "high"; + if (tier === "high" && count <= 36) return 0; + const farRect = helpers().updateVisibleWorldRect?.(worldRef, 520) || visibleRect; + return helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70; + } + + function shouldRunRealtime(worldRef, tarinai, visibleRect) { + return updateInterval(worldRef, tarinai, visibleRect) <= 0; + } + + global.TarinaiCreatureUpdatePolicy = Object.freeze({ + updateInterval, + shouldRunRealtime, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_step_ai.js b/js/tarinai_update_step_ai.js new file mode 100644 index 0000000..5e68921 --- /dev/null +++ b/js/tarinai_update_step_ai.js @@ -0,0 +1,41 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-step +// Owns low-frequency AI cadence, need resolution, item/social interaction scans, +// and facing refresh. Extracted from legacy Tarinai.prototype.update. +(function (global) { + function update(t, dt, context = {}) { + if (!t || t.dead) return { done: true }; + t.behaviorLockTimer = Math.max(0, (t.behaviorLockTimer || 0) - dt); + t.aiTimer += dt; + const urgentFoodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0; + const urgentAi = t.fightTimer > 0.04 || t.intimidateTimer > 0.04 || t.intimidatedTimer > 0.04 || t.birthRitualTimer > 0.04 || t.defeatedTimer > 0.04 || t.eatTimer > 0.02 || t.hurtTimer > 0.02 || t.hunger >= 96 || urgentFoodNeed >= 84; + const aiJitter = stableUnit(t.familyKey || t.id, "ai-interval") * 0.35; + context.urgentAi = urgentAi; + const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter); + if (t.aiTimer >= aiInterval && t.world.spendScheduledWork?.("ai", urgentAi) !== false) { + const elapsedAiDt = Math.max(0.016, Math.min(t.aiTimer, urgentAi ? 0.75 : 1.50)); + const scanDt = Math.min(elapsedAiDt, 0.75); + t.aiTimer = 0; + // Needs intentionally advance by a fixed coarse step per AI update. + // Exact elapsed-time integration is unnecessary for this observation game + // and can over-amplify safety/fear transitions when updates are sparse. + const needStepDt = urgentAi ? 0.50 : 1.00; + t.resolveNeeds(needStepDt); + const sleepingNow = t.state === "sleep"; + if (!sleepingNow) { + t.reactToFreshZunchi(scanDt); + t.updateFacing(); + TarinaiItemInteractionSystem.updateOne(t, scanDt); + t.interactWithOthers(scanDt); + } else { + t.updateFacing(); + } + } else { + t.updateFacing(); + } + return { done: false }; + } + + global.TarinaiAiUpdateStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_step_environment.js b/js/tarinai_update_step_environment.js new file mode 100644 index 0000000..7ea8871 --- /dev/null +++ b/js/tarinai_update_step_environment.js @@ -0,0 +1,21 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-step +// Owns low-frequency local environment effects around a Tarinai. +(function (global) { + function update(t, dt, context = {}) { + if (!t || t.dead) return { done: true }; + t.envTimer += dt; + const urgentAi = Boolean(context.urgentAi); + const envInterval = 3.0 + stableUnit(t.familyKey || t.id, "env-phase") * 1.4; + if (t.envTimer >= envInterval && t.world.spendScheduledWork?.("env", urgentAi || t.world.selected === t) !== false) { + const envDt = Math.min(t.envTimer, 1.2); + t.envTimer = 0; + if (global.TarinaiLocalEnvironmentSystem?.updateOne) global.TarinaiLocalEnvironmentSystem.updateOne(t, envDt); + else t.applyEnvironment?.(envDt); + } + return { done: false }; + } + + global.TarinaiEnvironmentUpdateStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js new file mode 100644 index 0000000..5ad3e27 --- /dev/null +++ b/js/tarinai_update_step_frame.js @@ -0,0 +1,169 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-step +// Owns per-frame timers, passive metabolism, disease/effect ticks, colony mood +// pressure, and early freeze exits. This is extracted from the legacy +// Tarinai.prototype.update body without changing behavior. +(function (global) { + function update(t, dt, context = {}) { + if (!t || t.dead) return { done: true }; + t.prevX = t.x; + t.prevY = t.y; + + const minute = dt / 60; + t.age += dt; + t.reproductionTimer -= dt; + t.blink += dt; + t.eatTimer = Math.max(0, t.eatTimer - dt); + if (t.state === "eat" && t.eatTimer <= 0.02) { + t.setActionState?.("idle", { target: null, reason: "食べ終えた", sleeping: false, clearTarget: true }); + } + t.eatCooldown = Math.max(0, t.eatCooldown - dt); + t.foodReactTimer = Math.max(0, t.foodReactTimer - dt); + t.surpriseTimer = Math.max(0, t.surpriseTimer - dt); + t.fearTimer = Math.max(0, t.fearTimer - dt); + t.intimidateTimer = Math.max(0, t.intimidateTimer - dt); + t.intimidatedTimer = Math.max(0, t.intimidatedTimer - dt); + t.blastSpinTimer = Math.max(0, (t.blastSpinTimer || 0) - dt); + t.postBirthPeaceTimer = Math.max(0, (t.postBirthPeaceTimer || 0) - dt); + t.hpBarTimer = Math.max(0, (t.hpBarTimer || 0) - dt); + t.stressBarTimer = Math.max(0, (t.stressBarTimer || 0) - dt); + t.updateZunchiDisease(dt); + t.updateSpecialDiseases(dt); + if (t.updateItemEffects) t.updateItemEffects(dt); + if (t.dead) return { done: true }; + t.loveMochiTimer = Math.max(0, (t.loveMochiTimer || 0) - dt); + t.fightMochiTimer = Math.max(0, (t.fightMochiTimer || 0) - dt); + if (t.fightMochiTimer > 0.04) { + applyNeedShock(t, { safety: dt * 4 }); + if (t.state !== "fight") t.fearTimer = Math.max(t.fearTimer || 0, 0.10); + } + if (t.intimidateTimer <= 0.04) t.intimidateTargetId = null; + if (t.birthRitualTimer > 0) { + const beforeBirthTimer = t.birthRitualTimer; + t.birthRitualTimer = Math.max(0, t.birthRitualTimer - dt); + if (beforeBirthTimer > 0 && t.birthRitualTimer <= 0.04 && t.birthRitualLeader) { + const partner = t.world.liveTarinaiById?.(t.birthPartnerId); + if (partner) t.world.finishBirthRitual(t, partner); + t.birthRitualLeader = false; + } + } + t.pokeFlashTimer = Math.max(0, t.pokeFlashTimer - dt); + t.focusPulseTimer = Math.max(0, (t.focusPulseTimer || 0) - dt); + t.cursorPetting = Math.max(0, t.cursorPetting - dt * 1.2); + t.pinchThoughtTimer = Math.max(0, (t.pinchThoughtTimer || 0) - dt); + t.sunbathCooldown = Math.max(0, (t.sunbathCooldown || 0) - dt); + if (t.freezeSleepDiseaseMotion(dt)) { + t.checkLife(dt); + return { done: true }; + } + if (global.TarinaiCursorCareSystem?.updateOne) global.TarinaiCursorCareSystem.updateOne(t, dt); + else t.applyCursorContactCare?.(dt); + const colonyMoodId = t.world?.colonyMood?.id || "relaxed"; + if (colonyMoodId === "weaklings" && !t.isZunchiSlave) applyNeedRelief(t, { safety: -dt * 2, fulfill: -dt * 1 }); + if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(t, { fulfill: -dt * 3 }); + if (colonyMoodId === "ant_overrun" && t.state !== "panic") { + const antSeen = (t.world?.nearbyAnts?.(t.x, t.y, 110, true) || []).some(a => a && !a.dead); + if (antSeen) { + if (t.enterPanic) t.enterPanic({ target: null, reason: "アリだ", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); + else { t.setActionState?.("panic", { target: null, reason: "アリだ", wake: true }); t.fearTimer = Math.max(t.fearTimer || 0, 1.1 * t.personalityProfile().fear); } + } + } + const wasFighting = t.fightTimer > 0.04; + t.fightTimer = Math.max(0, t.fightTimer - dt); + t.fightCooldown = Math.max(0, t.fightCooldown - dt); + t.groundStumbleCooldown = Math.max(0, (t.groundStumbleCooldown || 0) - dt); + t.defeatedTimer = Math.max(0, t.defeatedTimer - dt); + if (wasFighting && t.fightTimer <= 0.04 && t.defeatedById) { + const winner = t.world.liveTarinaiById?.(t.defeatedById) || null; + t.defeatedTimer = Math.max(t.defeatedTimer, rand(3.4, 5.2)); + t.fearTimer = Math.max(t.fearTimer, 1.0 * t.personalityProfile().fear); + t.hurtTimer = Math.max(t.hurtTimer, 1.8); + t.fallTimer = Math.max(t.fallTimer, 1.15); + t.fallMax = Math.max(t.fallMax || 1.15, t.fallTimer); + t.fallDir = winner ? (t.x < winner.x ? -1 : 1) : t.fallDir; + t.world.spawnFallEffect(t.x, t.y + t.radius * 0.45); + if (winner) t.world.resolveFightOutcome(winner, t); + t.fightTargetIds = []; + t.fightTargetId = null; + } + t.stretchTimer = Math.max(0, t.stretchTimer - dt); + t.grassEatTimer = Math.max(0, t.grassEatTimer - dt); + t.hurtTimer = Math.max(0, t.hurtTimer - dt); + t.fallTimer = Math.max(0, t.fallTimer - dt); + if ((t.world?.groundType || "soil") === "foot_massage" + && (t.groundStumbleCooldown || 0) <= 0.04 + && (t.fallTimer || 0) <= 0.04 + && !t.dead + && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(t.state || "")) + && Math.random() < dt * 0.055) { + const dir = Math.random() < 0.5 ? -1 : 1; + t.groundStumbleCooldown = rand(8.5, 16.0); + t.fallTimer = Math.max(t.fallTimer || 0, rand(0.86, 1.18)); + t.fallMax = Math.max(t.fallMax || 1.05, t.fallTimer); + t.fallDir = dir; + t.vx += dir * rand(26, 52); + t.vy += rand(-18, 18); + t.world?.spawnFallEffect?.(t.x, t.y + t.radius * 0.45); + t.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false }); + if ((t.world?.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)"); + } + if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && Math.random() < dt * 0.050) { + t.bubble("はっ...はっ...", 3.4, "rgba(58,48,63,0.80)"); + } + if (t.state === "idle" && t.stress < 78 && Math.random() < dt * 0.018) { + t.bubble(pick(["はう", "はうぅ", "はうっ", "はぅ"]), 4.2, "rgba(62,84,45,0.76)"); + } + if (t.hurtTimer > 0 && Math.random() < dt * 5.2) { + const side = t.facingDir(); + t.world.spawnBleedEffect(t.x - side * t.radius * rand(0.15, 0.42), t.y - t.radius * rand(0.08, 0.34)); + } + + t.tempTimer += dt; + if (t.tempTimer >= 0.55) { + t.tempComfort = t.world.temperatureAt(t.x, t.y); + t.tempTimer = 0; + } + const tempComfort = t.tempComfort ?? 0.62; + const tooCold = Math.max(0, 0.48 - tempComfort); + const hungerScale = t.metabolicHungerMultiplier ? t.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(t) : 1); + t.hunger += CONFIG.hungerPerMinute * minute * t.trait.hunger * hungerScale * (1 + tooCold * 0.8); + t.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - t.trait.social); + t.energy -= CONFIG.energyPerMinute * minute * t.trait.sleep * (t.state === "sleep" ? 0.025 : 1); + // stress is derived from needs; no independent stress decay. + if (tooCold > 0) applyNeedShock(t, { health: tooCold * dt * 4, safety: tooCold * dt * 2 }); + + const phase = t.world.lightLevel(); + if (phase < 0.25) t.energy += dt * 0.07; + if (phase > 0.75) applyNeedShock(t, { health: dt * 1.2 }); + if (t.world.weather === "cloudy") { + applyNeedRelief(t, { safety: -dt * 1 }); + t.vx *= Math.pow(0.996, dt * 60); + t.vy *= Math.pow(0.996, dt * 60); + } else if (t.world.weather === "light_rain") { + t.energy += dt * 0.018; + applyNeedRelief(t, { health: -dt * 1, safety: -dt * 1 }); + } else if (t.world.weather === "sunny") { + t.vx *= Math.pow(1.001, dt * 60); + t.vy *= Math.pow(1.001, dt * 60); + } + + if (t.generation > 1 && t.syncGrowthScale) t.syncGrowthScale(); + + t.hunger = clamp(t.hunger, 0, 115); + t.loneliness = clamp(t.loneliness, 0, 110); + t.energy = clamp(t.energy, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); + t.stress = applyGroundStressModifier(t, calculateStressFromNeeds(t.needRaw || t.needs || createDefaultNeeds())); + + context.wasSleepingAtFrameStart = t.state === "sleep" || t.sleeping; + t.awakeLockTimer = Math.max(0, (t.awakeLockTimer || 0) - dt); + + if (t.freezeSleepDiseaseMotion(dt)) { + t.checkLife(dt); + return { done: true }; + } + return { done: false }; + } + + global.TarinaiFrameUpdateStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_step_health.js b/js/tarinai_update_step_health.js new file mode 100644 index 0000000..3b60d98 --- /dev/null +++ b/js/tarinai_update_step_health.js @@ -0,0 +1,15 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-step +// Owns end-of-frame wake lock and life/death checks. +(function (global) { + function update(t, dt, context = {}) { + if (!t) return { done: true }; + const sleepingAtFrameEnd = t.state === "sleep" || t.sleeping; + if (context.wasSleepingAtFrameStart && !sleepingAtFrameEnd && !t.dead) t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 10); + t.checkLife(dt); + return { done: true }; + } + + global.TarinaiHealthUpdateStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_step_legacy.js b/js/tarinai_update_step_legacy.js new file mode 100644 index 0000000..fbcdb86 --- /dev/null +++ b/js/tarinai_update_step_legacy.js @@ -0,0 +1,14 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-step +// Fallback step for environments where the split update pipeline cannot run. +(function (global) { + function update(t, dt, context = {}) { + const legacyUpdate = context.legacyUpdate; + if (typeof legacyUpdate !== "function") return { done: true, ok: false }; + legacyUpdate.call(t, dt); + return { done: true, ok: true }; + } + + global.TarinaiLegacyUpdateStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js new file mode 100644 index 0000000..cd5030c --- /dev/null +++ b/js/tarinai_update_step_movement.js @@ -0,0 +1,19 @@ +"use strict"; + +// Layer: entity-runtime/tarinai/update-step +// Owns sunbath/runtime movement, collisions, and nest-box occupancy refresh. +(function (global) { + function update(t, dt) { + if (!t || t.dead) return { done: true }; + if (global.TarinaiSunbathSystem?.updateOne) global.TarinaiSunbathSystem.updateOne(t, dt); + else t.updateSunbath?.(dt); + t.maintainTargetProgress(dt); + t.move(dt); + if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(dt); + t.updateNestBoxPresence(dt); + if (t.world.resolveFenceCollision) t.world.resolveFenceCollision(t); + return { done: false }; + } + + global.TarinaiMovementUpdateStep = Object.freeze({ update }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/text_catalog.js b/js/text_catalog.js index 7c01555..aad96c1 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -26,7 +26,7 @@ const TEXT_CATALOG = { { title: "草、地面、天気", image: "assets/ui/tool_grass.webp", - text: "草は成長し、食料やかんたんベッドの材料になります。地面は土、コンクリート、ふわふわ毛布、足つぼ、氷を切り替えられ、草の発生やストレス増加に影響します。雨や時間帯も状態に関わります。", + text: "草は成長し、食料やかんたんベッドの材料になります。地面は砂、土、コンクリート、ふわふわ毛布、足つぼ、氷を切り替えられ、草の発生やストレス増加に影響します。雨や時間帯も状態に関わります。", }, { title: "性格と行動傾向", diff --git a/js/ui_bind.js b/js/ui_bind.js index e8851f4..00d19c1 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -174,14 +174,8 @@ function bindUI() { const id = node.getAttribute("data-family-tarinai-id"); const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead); if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; } - world.selected = t; - t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15); + window.TarinaiCommands?.dispatch?.(world, { type: "selection.focus", target: t, options: { pulse: 1.15 } }); selectTool("observe"); - if (Number.isFinite(t.x) && Number.isFinite(t.y)) { - world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; - world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; - world.clampCamera?.(); - } renderStats(); showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); }); @@ -216,11 +210,12 @@ function bindUI() { } const btn = e.target.closest("[data-selected-action=\"favorite\"]"); if (!btn || !world.selected || world.selected.dead) return; - world.selected.favorite = !world.selected.favorite; + const favResult = window.TarinaiCommands?.dispatch?.(world, { type: "selection.favorite.toggle" }); + if (!favResult?.ok) return; uiCache.selectedSnapshot = ""; renderSelected(); render(); - showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002"); + showToast(favResult.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002"); }); ui.selectedInfo?.addEventListener("change", (e) => { const input = e.target.closest("[data-selected-name]"); @@ -234,7 +229,7 @@ function bindUI() { if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); } }); ui.selectedCloseBtn?.addEventListener("click", () => { - world.selected = null; + window.TarinaiCommands?.dispatch?.(world, { type: "selection.clear" }); uiCache.showSelectedEmpty = false; uiCache.selectedSnapshot = ""; uiCache.selectedEmpty = false; @@ -244,9 +239,10 @@ function bindUI() { ui.pauseBtn?.addEventListener("click", () => { audio.uiClick?.(); - world.paused = !world.paused; + const pauseResult = window.TarinaiCommands?.dispatch?.(world, { type: "simulation.paused.toggle" }); + const paused = pauseResult?.ok ? pauseResult.paused : world.paused; const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0); - if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { + if (archiveAutoUpdateEnabled() && paused && (uiCache.archivePendingWhileRunning || archiveStale)) { uiCache.archivePendingWhileRunning = false; scheduleArchiveWindowRender(); } @@ -254,9 +250,7 @@ function bindUI() { }); ui.speedBtn?.addEventListener("click", () => { audio.uiClick?.(); - const speeds = [1, 2, 4]; - const i = speeds.indexOf(world.speed); - world.speed = speeds[(i + 1) % speeds.length]; + window.TarinaiCommands?.dispatch?.(world, { type: "simulation.speed.cycle", speeds: [1, 2, 4] }); syncTopButtons(); }); ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); openEcologyDialog(); }); diff --git a/js/ui_charts.js b/js/ui_charts.js index ae68bfa..66afff7 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -154,7 +154,7 @@ function chartSeries(mode = "population") { if (mode === "objects") return [ { key: "obj_grass", label: "草", color: "#5b9d61" }, { key: "obj_zunchi", label: "ずんち", color: "#6f8a3f" }, - { key: "obj_trace", label: "足跡", color: "#8a7054" }, + { key: "obj_trace", label: "死骸", color: "#8a7054" }, { key: "obj_grass_bed", label: "かんたんベッド", color: "#4f9bc4" }, { key: "obj_water", label: "雨滴", color: "#5aa8d6" }, ]; diff --git a/js/ui_ground.js b/js/ui_ground.js index 2cc7d14..e08e3e6 100644 --- a/js/ui_ground.js +++ b/js/ui_ground.js @@ -16,7 +16,7 @@ function groundDef(worldRef = null) { const worldObj = activeWorld(worldRef); const id = worldObj?.groundType || "soil"; - return global.TarinaiGround?.definition?.(id) || (typeof GROUND_TYPES !== "undefined" ? GROUND_TYPES[id] || GROUND_TYPES.soil : { id: "soil", label: "土", description: "いつもの地面" }); + return global.TarinaiGround?.definition?.(id) || (typeof GROUND_TYPES !== "undefined" ? GROUND_TYPES[id] || GROUND_TYPES.soil : { id: "soil", label: "砂", description: "普通の地面。" }); } function groundTip(worldRef = null) { @@ -28,7 +28,7 @@ function updateGroundUI(worldRef = null) { const uiRef = global.ui || {}; const def = groundDef(worldRef); - const label = def.label || "土"; + const label = def.label || "砂"; const statEl = uiRef.statGroundType || document.getElementById("statGroundType"); const btnEl = uiRef.groundTypeBtn || document.getElementById("groundTypeBtn"); if (statEl) setTextIfChanged(statEl, "groundType", label); diff --git a/js/ui_input_mouse.js b/js/ui_input_mouse.js index 638b928..35b181f 100644 --- a/js/ui_input_mouse.js +++ b/js/ui_input_mouse.js @@ -64,7 +64,7 @@ } const p = ctx.screenToWorldEvent(e); if (!p.inside) return; - world.handleClick(p.x, p.y); + window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); renderStats(); }); diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 9de30c3..918fdbc 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -5,7 +5,7 @@ if (tool === "water_hose") return "crosshair"; if (tool === "pinch") return "grab"; if (tool === "observe") return "default"; - if (tool === "poke" || tool === "delete") return "pointer"; + if (tool === "poke" || tool === "delete" || tool === "area_delete") return "pointer"; return "crosshair"; } diff --git a/js/ui_input_touch.js b/js/ui_input_touch.js index aa628fd..9cda290 100644 --- a/js/ui_input_touch.js +++ b/js/ui_input_touch.js @@ -109,13 +109,13 @@ } else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) { const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); if (p.inside) { - world.handleClick(p.x, p.y); + window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); renderStats(); } } else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) { const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); if (p.inside) { - world.handleClick(p.x, p.y); + window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); renderStats(); } } diff --git a/js/ui_layout_dialogs.js b/js/ui_layout_dialogs.js index 2013293..b7aa9e6 100644 --- a/js/ui_layout_dialogs.js +++ b/js/ui_layout_dialogs.js @@ -125,12 +125,13 @@ function resizeFieldFromWheel(e) { } function selectTool(tool) { - world.tool = tool; + const result = window.TarinaiCommands?.dispatch?.(world, { type: "tool.select", toolId: tool }); + const activeTool = result?.toolId || world.tool || tool; for (const btn of uiCache.toolButtons) { - btn.classList.toggle("selected", btn.dataset.tool === tool); + btn.classList.toggle("selected", btn.dataset.tool === activeTool); } syncToolSizeBadges(); - canvas.style.cursor = tool === "observe" ? "default" : (tool === "pinch" ? "grab" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair")); + canvas.style.cursor = activeTool === "observe" ? "default" : (activeTool === "pinch" ? "grab" : (activeTool === "poke" || activeTool === "delete" ? "pointer" : "crosshair")); } function showToast(text) { diff --git a/js/ui_log.js b/js/ui_log.js index 8a3211e..2a9f365 100644 --- a/js/ui_log.js +++ b/js/ui_log.js @@ -138,9 +138,16 @@ function relationIconHtml(id) { function relationEntries(t, kind = "friend") { const rows = []; const liveIds = new Set((world?.tarinai || []).filter(o => o && !o.dead && o !== t).map(o => o.id)); - for (const [id, rel] of Object.entries(t?.relationships || {})) { + const ids = new Set(Object.keys(t?.relationships || {})); + for (const other of world?.tarinai || []) { + if (other && other !== t && other.id && other.relationships?.[t?.id]) ids.add(other.id); + } + for (const id of ids) { if (!liveIds.has(id)) continue; - const score = kind === "enemy" ? (rel.fear || 0) : (rel.affinity || 0); + const rel = t?.relationships?.[id] || relationDefaults(); + const other = world?.tarinai?.find(o => o && o.id === id) || null; + const reverse = other?.relationships?.[t?.id] || null; + const score = kind === "enemy" ? Math.max(rel.fear || 0, reverse?.fear || 0) : Math.max(rel.affinity || 0, reverse?.affinity || 0); const threshold = kind === "enemy" ? 0.5 : FRIEND_AFFINITY_THRESHOLD; if (score <= threshold) continue; rows.push({ id, score, name: relationDisplayName(world, id) }); @@ -164,14 +171,8 @@ function relationListHtml(t, kind = "friend") { function focusTarinaiById(id) { const t = world?.tarinai?.find(o => o && !o.dead && (o.id === id || o.familyKey === id)); if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; } - world.selected = t; - t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25); + window.TarinaiCommands?.dispatch?.(world, { type: "selection.focus", target: t, options: { pulse: 1.25 } }); selectTool("observe"); - if (Number.isFinite(t.x) && Number.isFinite(t.y)) { - world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; - world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; - world.clampCamera?.(); - } uiCache.selectedSnapshot = ""; renderSelected(); renderStats(); diff --git a/js/ui_selected.js b/js/ui_selected.js index 47c861a..75b89e4 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -271,7 +271,7 @@ function renderSelected() { const healthHtml = [ `
\u884c\u52d5${escapeHtml(reasonLabel(t))}
`, `
${needRowsHtml}
`, - selectedInfoRowHtml("\u4f53\u529b", fmt(t.energy), "energy"), + selectedInfoRowHtml("\u4f53\u529b", `${fmt(t.energy)} / ${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, "energy"), selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"), ].join(""); const itemEffectHtml = itemEffectSummary.length diff --git a/js/ui_tools.js b/js/ui_tools.js index 67a0dc7..2821f67 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -96,14 +96,10 @@ function toolSizeFor(tool) { function cycleToolSize(tool) { if (!SCALABLE_TOOLS.has(tool)) return false; - if (!world.toolSizes) world.toolSizes = {}; - const current = toolSizeFor(tool); - const idx = SIZE_ORDER.indexOf(current); - const next = SIZE_ORDER[(idx + 1 + SIZE_ORDER.length) % SIZE_ORDER.length]; - world.toolSizes[tool] = next; - world.toolSize = next; + const result = window.TarinaiCommands?.dispatch?.(world, { type: "tool.size.cycle", toolId: tool, order: SIZE_ORDER }); + if (!result?.ok) return false; syncToolSizeBadges(); - showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[next] || next}`); + showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[result.size] || result.size}`); return true; } diff --git a/js/version.js b/js/version.js index 24f9d36..9ba662a 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "16.07.32"; - const APP_BUILD = "fan-magnet-gate-ice-rotation-v3"; + const APP_VERSION = "16.07.50"; + const APP_BUILD = "boundary-cleanup-v15"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_bootstrap.js b/js/world_bootstrap.js deleted file mode 100644 index b447f7b..0000000 --- a/js/world_bootstrap.js +++ /dev/null @@ -1,11 +0,0 @@ -"use strict"; - -let world; -try { - world = new World(); - if (typeof window !== "undefined") window.world = world; - if (typeof globalThis !== "undefined") globalThis.world = world; -} catch (error) { - if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error); - throw error; -} diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 02aa22b..91fdecb 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -797,7 +797,7 @@ ball.lastKickerId = t.id; t.vx -= nx * Math.min(22, impulse * 0.10); t.vy -= ny * Math.min(22, impulse * 0.10); - t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100); + t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 }); diff --git a/js/world_environment.js b/js/world_environment.js index 136fc54..cbe65da 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -27,6 +27,72 @@ return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s }; } + + + function sanitizeRotatorSegmentsLocal(item) { + const raw = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : []; + const out = []; + const clampCoord = (v) => Math.max(-420, Math.min(420, Number(v) || 0)); + for (const seg of raw) { + if (!Array.isArray(seg) || seg.length < 4) continue; + const x1 = clampCoord(seg[0]); + const y1 = clampCoord(seg[1]); + const x2 = clampCoord(seg[2]); + const y2 = clampCoord(seg[3]); + if (Math.hypot(x2 - x1, y2 - y1) < 4) continue; + out.push([x1, y1, x2, y2]); + if (out.length >= 96) break; + } + if (!out.length) out.push([-78, 0, 78, 0], [0, -52, 0, 52]); + return out; + } + + function rotatorExtentLocal(item) { + let maxD = 48; + for (const seg of sanitizeRotatorSegmentsLocal(item)) { + maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])); + } + return Math.min(460, maxD + Math.max(8, Number(item?.rotatorThickness || 12) || 12) + 8); + } + + function rotatorWorldSegmentsLocal(item) { + const angle = angleForItemLocal(item); + const c = Math.cos(angle); + const ss = Math.sin(angle); + const cx = Number(item?.x) || 0; + const cy = Number(item?.y) || 0; + return sanitizeRotatorSegmentsLocal(item).map(([x1, y1, x2, y2]) => [ + cx + x1 * c - y1 * ss, + cy + x1 * ss + y1 * c, + cx + x2 * c - y2 * ss, + cy + x2 * ss + y2 * c, + ]); + } + + function rotatorSegmentRectLocal(item, x1, y1, x2, y2) { + const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1)); + const angle = Math.atan2(y2 - y1, x2 - x1); + const halfW = len / 2; + const halfH = Math.max(4, Math.min(34, Number(item?.rotatorThickness || 12) || 12)) / 2; + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle); + return { + left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom, + cx, cy, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true, + type: "rotator", item, rotator: true, restitution: 0.72, + angularVelocity: Number(item?.rotatorSpeed || 0) || 0, + centerX: Number(item?.x) || 0, centerY: Number(item?.y) || 0, + }; + } + + function rotatorObstacleRectsLocal(item) { + if (!item || item.dead || item.type !== "rotator") return []; + const rects = []; + for (const seg of rotatorWorldSegmentsLocal(item)) rects.push(rotatorSegmentRectLocal(item, seg[0], seg[1], seg[2], seg[3])); + return rects; + } + function rectLocalPoint(r, x, y) { const dx = x - (r.cx ?? 0); const dy = y - (r.cy ?? 0); @@ -236,6 +302,35 @@ }; }, + + rotatorSegments(it) { + return sanitizeRotatorSegmentsLocal(it); + }, + + rotatorWorldSegments(it) { + return rotatorWorldSegmentsLocal(it); + }, + + rotatorExtent(it) { + return rotatorExtentLocal(it); + }, + + rotatorObstacleRects(it) { + return rotatorObstacleRectsLocal(it); + }, + + rotatorBoundsAabb(it) { + const rects = rotatorObstacleRectsLocal(it); + if (!rects.length) return null; + return { + left: Math.min(...rects.map(r => r.left)), + right: Math.max(...rects.map(r => r.right)), + top: Math.min(...rects.map(r => r.top)), + bottom: Math.max(...rects.map(r => r.bottom)), + type: "rotator", item: it, + }; + }, + solidObstacleRects(it) { if (!it || it.dead) return []; if (this.isFenceType(it.type)) { @@ -247,6 +342,7 @@ const rect = this.deviceRect?.(it); return rect ? [{ ...rect, type: it.type, item: it }] : []; } + if (it.type === "rotator") return this.rotatorObstacleRects?.(it) || []; if (it.type === "nest_box") return this.nestBoxSolidRects(it); return []; }, @@ -267,16 +363,33 @@ nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { const rects = []; + const seen = new Set(); let checked = 0; - for (const it of this.nearbyItems(x, y, radius)) { - if (!it || it === exclude || it.dead) continue; - if (include && !include(it)) continue; + const addRectsFor = (it) => { + if (!it || it === exclude || it.dead || seen.has(it)) return false; + if (include && !include(it)) return false; const partRects = this.solidObstacleRects(it); - if (!partRects.length) continue; + if (!partRects.length) return false; + seen.add(it); checked += 1; for (const rect of partRects) rects.push(rect); + return true; + }; + for (const it of this.nearbyItems(x, y, radius)) { + addRectsFor(it); if (checked >= maxChecks) break; } + // Large custom rotators can extend well beyond their center cell; scan them + // explicitly so their blades keep colliding even when the hub is far away. + if (checked < maxChecks) { + for (const it of this.items || []) { + if (!it || it.type !== "rotator" || it.dead || seen.has(it)) continue; + const reach = this.rotatorExtent?.(it) || Math.max(60, it.r || 64); + if (distXY(x, y, it.x, it.y) > radius + reach + 36) continue; + addRectsFor(it); + if (checked >= maxChecks) break; + } + } return rects; }, @@ -372,6 +485,21 @@ t.lastBounceFenceEffectAt = this.time || 0; this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" })); } + } else if (r.rotator) { + const omega = clamp(Number(r.angularVelocity || 0) || 0, -8, 8); + const rx = t.x - (r.centerX ?? r.item?.x ?? r.cx ?? 0); + const ry = t.y - (r.centerY ?? r.item?.y ?? r.cy ?? 0); + const tvx = -ry * omega; + const tvy = rx * omega; + t.vx = clamp((t.vx || 0) * 0.78 + tvx * 0.52 + nx * 42, -680, 680); + t.vy = clamp((t.vy || 0) * 0.78 + tvy * 0.52 + ny * 42, -680, 680); + t.impulseVx = clamp((t.impulseVx || 0) + tvx * 0.10, -360, 360); + t.impulseVy = clamp((t.impulseVy || 0) + tvy * 0.10, -360, 360); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12); + if ((this.time || 0) >= (t.lastRotatorHitEffectAt || -999) + 0.10) { + t.lastRotatorHitEffectAt = this.time || 0; + this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: "rgba(175,116,230,0.42)" })); + } } else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; else t.vy *= -0.18; return true; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 1d2e3bc..30c2e13 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -148,8 +148,286 @@ } + function ensureRotatorEditor() { + let dialog = document.getElementById("rotatorEditor"); + if (dialog) return dialog; + dialog = document.createElement("div"); + dialog.id = "rotatorEditor"; + dialog.className = "rotator-editor hidden"; + dialog.innerHTML = ` + `; + document.body.appendChild(dialog); + return dialog; + } + + function cloneSegmentsForRotator(item) { + const src = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]]; + return src.filter(seg => Array.isArray(seg) && seg.length >= 4).slice(0, 96).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]); + } + + function openRotatorEditor(item, worldRef) { + if (!item || item.type !== "rotator") return false; + const dialog = ensureRotatorEditor(); + const canvas = dialog.querySelector("#rotatorCanvas"); + const ctx = canvas?.getContext?.("2d"); + const speed = dialog.querySelector("#rotatorSpeed"); + const speedNum = dialog.querySelector("#rotatorSpeedNum"); + const thick = dialog.querySelector("#rotatorThickness"); + const thickNum = dialog.querySelector("#rotatorThicknessNum"); + const apply = dialog.querySelector("#rotatorApply"); + const cancel = dialog.querySelector("#rotatorCancel"); + const closeBtn = dialog.querySelector("#rotatorEditorClose"); + const undo = dialog.querySelector("#rotatorUndo"); + const clear = dialog.querySelector("#rotatorClear"); + if (!canvas || !ctx) return false; + let mode = "line"; + let segments = cloneSegmentsForRotator(item); + let draft = null; + let drawing = false; + const cx = canvas.width / 2; + const cy = canvas.height / 2; + const deg = Math.round((Number(item.rotatorSpeed || 0) || 0) * 180 / Math.PI); + const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 12) || 12)); + if (speed) speed.value = String(Math.max(-360, Math.min(360, deg))); + if (speedNum) speedNum.value = String(deg); + if (thick) thick.value = String(initialThickness); + if (thickNum) thickNum.value = String(initialThickness); + const toLocal = (e) => { + const rect = canvas.getBoundingClientRect(); + const sx = canvas.width / Math.max(1, rect.width); + const sy = canvas.height / Math.max(1, rect.height); + return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy }; + }; + const cleanSegments = () => { + const out = []; + for (const seg of segments) { + const x1 = Math.max(-420, Math.min(420, Number(seg[0]) || 0)); + const y1 = Math.max(-420, Math.min(420, Number(seg[1]) || 0)); + const x2 = Math.max(-420, Math.min(420, Number(seg[2]) || 0)); + const y2 = Math.max(-420, Math.min(420, Number(seg[3]) || 0)); + if (Math.hypot(x2 - x1, y2 - y1) >= 4) out.push([x1, y1, x2, y2]); + if (out.length >= 96) break; + } + if (!out.length) out.push([-78, 0, 78, 0]); + segments = out; + }; + const draw = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.fillStyle = "#f7f3ea"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.translate(cx, cy); + ctx.strokeStyle = "rgba(100,80,60,0.16)"; + ctx.lineWidth = 1; + for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); } + for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); } + ctx.strokeStyle = "rgba(70,60,50,0.38)"; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke(); + const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "rgba(127,92,190,0.92)"; + ctx.lineWidth = lw; + ctx.beginPath(); + for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); } + ctx.stroke(); + ctx.strokeStyle = "rgba(255,255,255,0.58)"; + ctx.lineWidth = Math.max(1.3, lw * 0.20); + ctx.beginPath(); + for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); } + ctx.stroke(); + if (draft) { + ctx.setLineDash([7, 5]); + ctx.strokeStyle = "rgba(60,130,210,0.85)"; + ctx.lineWidth = Math.max(2, lw * 0.45); + ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke(); + ctx.setLineDash([]); + } + ctx.fillStyle = "#f5edff"; + ctx.strokeStyle = "#6d4ca0"; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + }; + const setMode = (next) => { + mode = next === "free" ? "free" : "line"; + for (const btn of dialog.querySelectorAll("[data-rotator-mode]")) btn.classList.toggle("active", btn.dataset.rotatorMode === mode); + }; + const setTemplate = (name) => { + if (name === "bar") segments = [[-130, 0, 130, 0]]; + else if (name === "cross") segments = [[-118, 0, 118, 0], [0, -90, 0, 90]]; + else if (name === "circle") { + segments = []; + const n = 18, r = 108; + for (let i = 0; i < n; i += 1) { + const a = i / n * Math.PI * 2; + const b = (i + 1) / n * Math.PI * 2; + segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]); + } + } + cleanSegments(); draw(); + }; + const syncPair = (a, b) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; draw(); }; b.oninput = () => { a.value = b.value; draw(); }; }; + syncPair(speed, speedNum); + syncPair(thick, thickNum); + dialog.querySelectorAll("[data-rotator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.rotatorMode); }); + dialog.querySelectorAll("[data-rotator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.rotatorTemplate); }); + canvas.onpointerdown = (e) => { + canvas.setPointerCapture?.(e.pointerId); + const p = toLocal(e); + drawing = true; + draft = [p.x, p.y, p.x, p.y]; + if (mode === "free") segments.push([p.x, p.y, p.x, p.y]); + draw(); + }; + canvas.onpointermove = (e) => { + if (!drawing || !draft) return; + const p = toLocal(e); + if (mode === "free") { + const last = segments[segments.length - 1]; + if (!last) return; + const lx = last[2], ly = last[3]; + if (Math.hypot(p.x - lx, p.y - ly) >= 5) { + last[2] = p.x; last[3] = p.y; + if (segments.length < 96) segments.push([p.x, p.y, p.x, p.y]); + } + } else { + draft[2] = p.x; draft[3] = p.y; + } + draw(); + }; + canvas.onpointerup = (e) => { + if (!drawing || !draft) return; + drawing = false; + const p = toLocal(e); + if (mode === "line") { + const seg = [draft[0], draft[1], p.x, p.y]; + if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg); + } + draft = null; + cleanSegments(); + draw(); + }; + if (undo) undo.onclick = () => { segments.pop(); cleanSegments(); draw(); }; + if (clear) clear.onclick = () => { segments = []; draft = null; draw(); }; + const close = () => { + dialog.classList.add("hidden"); + canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null; + }; + if (apply) apply.onclick = () => { + cleanSegments(); + item.rotatorSegments = segments.map(seg => seg.slice(0, 4)); + item.rotatorSpeed = (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180; + item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); + const extent = worldRef?.rotatorExtent?.(item); + if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent)); + item.rotatorEditorOpenAt = worldRef?.time || 0; + worldRef?.markSpatialDirty?.("rotator-edited"); + worldRef.drawListDirty = true; + worldRef?.log?.("回転体の設計を変更した。", "observe"); + global.render?.(); + close(); + }; + if (cancel) cancel.onclick = close; + if (closeBtn) closeBtn.onclick = close; + dialog.onclick = (e) => { if (e.target === dialog) close(); }; + setMode(mode); + draw(); + dialog.classList.remove("hidden"); + return true; + } + + function copyableItemAt(worldRef, x, y) { + const it = worldRef?.findDeleteToolTargetAt?.(x, y); + if (!it || it.dead || it.type === "trace" || it.type === "splat" || it.type === "ant_corpse") return null; + if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") return null; + return it; + } + + function makeCopyBufferForItem(item) { + if (!item) return null; + const data = { + type: item.type, + r: Number(item.r || itemRadiusFor?.(item.type, 12) || 12), + amount: Math.max(1, Number(item.amount || itemAmountFor?.(item.type, 80) || 80)), + angle: Number(item.angle || 0) || 0, + toolSize: item.toolSize || "medium", + foodServingScale: Number(item.foodServingScale || 1) || 1, + foodServingsMax: Number(item.foodServingsMax || 0) || 0, + foodServingsRemaining: Number(item.foodServingsRemaining || 0) || 0, + }; + if (item.type === "signboard") data.text = item.text || ""; + if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen); + if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; } + if (item.type === "rotator") { + data.rotatorSpeed = Number(item.rotatorSpeed || 0) || 0; + data.rotatorThickness = Number(item.rotatorThickness || 12) || 12; + data.rotatorSegments = cloneSegmentsForRotator(item); + } + return data; + } + + function applyCopyBufferToItem(item, data) { + if (!item || !data) return item; + item.r = Number(data.r || item.r || itemRadiusFor?.(item.type, 12) || 12); + item.amount = Math.max(1, Number(data.amount || item.amount || 1)); + if (typeof isRotatableItemType === "function" && isRotatableItemType(item.type)) item.angle = Number(data.angle || 0) || 0; + if (item.type === "signboard") item.text = String(data.text || ""); + if (item.type === "gate_fence") item.gateOpen = Boolean(data.gateOpen); + if (item.type === "duplicator") { + item.storedFoodType = String(data.storedFoodType || ""); + item.storedFoodLabel = String(data.storedFoodLabel || (item.storedFoodType ? toolLabel(item.storedFoodType) : "")); + if (item.roles) item.roles.food = Boolean(item.storedFoodType); + } + if (item.type === "rotator") { + item.rotatorSpeed = Number(data.rotatorSpeed || 0) || 0; + item.rotatorThickness = Math.max(4, Math.min(34, Number(data.rotatorThickness || 12) || 12)); + item.rotatorSegments = Array.isArray(data.rotatorSegments) ? data.rotatorSegments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item); + item.r = Math.max(item.r || 64, Math.min(460, Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8)); + } + if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) { + item.toolSize = data.toolSize || item.toolSize || "medium"; + item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1; + item.foodServingsMax = Math.max(1, Number(data.foodServingsMax || item.foodServingsMax || item.amount || 1)); + item.foodServingsRemaining = Math.max(1, Number(data.foodServingsRemaining || item.foodServingsRemaining || item.amount || 1)); + item.amount = item.foodServingsRemaining; + } + if (typeof isPinType === "function" && isPinType(item.type)) { + item.pinState = "loose"; + item.pinTargetId = ""; + item.vx = 0; item.vy = 0; item.spinVelocity = 0; + } + return item; + } + + function isEditableItem(item) { - return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence"); + return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator"); } function findEditableItemAt(worldRef, x, y) { @@ -167,6 +445,7 @@ function openEditableItemEditor(item, worldRef) { if (!isEditableItem(item)) return false; if (item.type === "signboard") return openSignboardEditor(item, worldRef); + if (item.type === "rotator") return openRotatorEditor(item, worldRef); if (item.type === "gate_fence") { item.gateOpen = !item.gateOpen; item.gateLastToggleAt = worldRef?.time || 0; @@ -249,6 +528,42 @@ return directSetDuplicatorAt(this, x, y, itemType); }, + copyPreviewItemAt(x, y) { + if (!this.copyBuffer?.type) return null; + const item = new Item(this.copyBuffer.type, x, y); + applyCopyBufferToItem(item, this.copyBuffer); + item.x = x; item.y = y; + return item; + }, + + useCopyToolAt(x, y) { + if (!this.copyBuffer) { + const target = copyableItemAt(this, x, y); + if (!target) { showToast("コピーできるものがありません。"); return true; } + this.copyBuffer = makeCopyBufferForItem(target); + showToast(`${toolLabel(target.type)}をコピーしました。`); + this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe"); + return true; + } + const item = this.copyPreviewItemAt?.(x, y); + if (item) { + const placed = this.placeItem(item, false); + if (placed) { + const label = toolLabel(item.type); + this.log?.(`${label}を貼り付けた。`, "observe"); + showToast(`${label}を貼り付けました。`); + return true; + } + } + const target = copyableItemAt(this, x, y); + if (target) { + this.copyBuffer = makeCopyBufferForItem(target); + showToast(`${toolLabel(target.type)}をコピーしました。`); + this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe"); + } + return true; + }, + fencePlacementBlocked(item) { if (!item || !this.isFenceType(item.type)) return false; const rect = this.fenceRect(item); @@ -268,7 +583,7 @@ importantPlacementOverlapBlocked(item) { if (!item || item.dead) return true; - const important = new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "nest_box", "water"]); + const important = new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator", "nest_box", "water"]); if (!important.has(item.type)) return false; const placingGrass = item.type === "grass"; const itemRects = this.solidObstacleRects(item); @@ -387,7 +702,7 @@ const original = this.placementProbeItem(item, start.x, start.y); if (allowOriginal && !this.placementBlocked(original)) return start; - const solid = this.isFenceType(item.type) || item.type === "nest_box" || item.type === "fan" || item.type === "magnet"; + const solid = this.isFenceType(item.type) || item.type === "nest_box" || item.type === "fan" || item.type === "magnet" || item.type === "rotator"; const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2)); const count = attempts || (solid ? 72 : 36); const golden = Math.PI * (3 - Math.sqrt(5)); @@ -471,10 +786,10 @@ handleClick(x, y) { const tool = this.tool; - if (!isPlacementTool(tool) && !["delete", "poke", "pinch", "water_hose", "new"].includes(tool)) { + if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "new"].includes(tool)) { const editable = findEditableItemAt(this, x, y); if (editable && openEditableItemEditor(editable, this)) { - this.selected = null; + global.TarinaiCommands?.setSelection?.(this, null) || (this.selected = null); return; } } @@ -486,11 +801,11 @@ if (!found) { const editable = findEditableItemAt(this, x, y); if (editable && openEditableItemEditor(editable, this)) { - this.selected = null; + global.TarinaiCommands?.setSelection?.(this, null) || (this.selected = null); return; } } - this.selected = found; + global.TarinaiCommands?.setSelection?.(this, found) || (this.selected = found); return; } @@ -522,9 +837,14 @@ return; } + if (tool === "copy") { this.useCopyToolAt?.(x, y); return; } if (this.useToolAt?.(x, y)) return; const itemType = toolItemType(tool); + if (itemType === "rotator") { + const editable = findEditableItemAt(this, x, y); + if (editable?.type === "rotator" && openEditableItemEditor(editable, this)) return; + } if (itemType && this.directSetDuplicatorAt?.(x, y, itemType)) return; if (itemType) { let px = x, py = y; diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js index 6302660..7ec623f 100644 --- a/js/world_tool_actions.js +++ b/js/world_tool_actions.js @@ -38,8 +38,52 @@ return true; }, + areaDeleteRadius() { + return 138; + }, + + deleteItemsInArea(x, y) { + const radius = this.areaDeleteRadius?.() || 138; + const candidates = []; + for (let i = (this.items || []).length - 1; i >= 0; i -= 1) { + const it = this.items[i]; + if (!it || it.dead) continue; + if (isPinType(it.type) && it.pinState === "lodged") continue; + const hitR = Math.max(8, it.r || itemRadiusFor?.(it.type, 12) || 12); + const d = distXY(x, y, it.x, it.y); + if (d <= radius + hitR * 0.85) candidates.push(it); + } + if (!candidates.length) { showToast("範囲内に消せるものがありません。"); return true; } + let removedCount = 0; + const kindCounts = new Map(); + for (const target of candidates) { + if (!target || target.dead) continue; + const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, target, { + reason: "area-delete-tool", + userReason: target.type === "grass_bed" ? "ベッドがなくなった" : "置きものがなくなった", + wake: true, + }); + if (!removed) continue; + removedCount += 1; + const label = toolLabel(target.type); + kindCounts.set(label, (kindCounts.get(label) || 0) + 1); + } + if (!removedCount) { showToast("範囲内に消せるものがありません。"); return true; } + audio.delete?.(); + this.compactItems?.(); + this.updateItemCounts?.(); + this.rebuildSpatial?.(); + this.drawListDirty = true; + if (typeof global.render === "function") global.render(); + const detail = [...kindCounts.entries()].slice(0, 4).map(([label, count]) => `${label}${count > 1 ? `×${count}` : ""}`).join("、"); + this.log?.(`範囲削除で${removedCount}個消した${detail ? `(${detail})` : ""}。`, "observe"); + showToast(`${removedCount}個消しました。`); + return true; + }, + useToolAt(x, y) { if (this.tool === "delete") return this.deleteItemAt(x, y); + if (this.tool === "area_delete") return this.deleteItemsInArea(x, y); return false; }, })); diff --git a/js/world_update.js b/js/world_update.js index 528339e..3a440ed 100644 --- a/js/world_update.js +++ b/js/world_update.js @@ -132,9 +132,9 @@ }, update(dt) { - const phases = window.TarinaiWorldUpdatePhases; - if (!phases?.update) throw new Error("TarinaiWorldUpdatePhases is not available"); - phases.update(this, dt); + const simulation = window.TarinaiSimulation; + if (!simulation?.update) throw new Error("TarinaiSimulation is not available; TarinaiWorldUpdatePhases cannot be reached"); + return simulation.update(this, dt); }, sanitizeOutOfBounds() { diff --git a/js/world_update_phases.js b/js/world_update_phases.js index e2ceb29..ebdcebe 100644 --- a/js/world_update_phases.js +++ b/js/world_update_phases.js @@ -1,410 +1,39 @@ "use strict"; +// Layer: simulation/phase-facade +// Backward-compatible phase names. Concrete update work lives in +// js/simulation_systems.js so system_order.js can evolve without World owning +// the phase implementation details. (function (global) { - const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "fan", "magnet"]); - const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); - const SCHEDULED_ITEM_TYPES = [ - "ball", "genkotsu", "firecracker", "pushpin", "oshibyo", - "plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food", - "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", - "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "water_bowl", - ]; - - function forScheduledItems(worldRef, visitor) { - if (!worldRef || typeof visitor !== "function") return 0; - let count = 0; - if (!worldRef.itemsOfType) { - for (const it of worldRef.items || []) { - if (!it || it.dead || it.type === "grass") continue; - visitor(it); - count += 1; - } - return count; - } - for (const type of SCHEDULED_ITEM_TYPES) { - const bucket = worldRef.itemsOfType(type); - for (const it of bucket || []) { - if (!it || it.dead) continue; - visitor(it); - count += 1; - } - } - return count; + function systems() { + const impl = global.TarinaiSimulationSystems; + if (!impl?.phases) throw new Error("TarinaiSimulationSystems is not available"); + return impl; } - function itemUpdateInterval(item) { - if (!item || item.dead) return Infinity; - if (REALTIME_ITEM_TYPES.has(item.type)) return 0; - if (PIN_ITEM_TYPES.has(item.type)) return 0; - if (item.isStructure && item.type === "plushie") return 0; - if (item.isStructure && item.type === "grass_bed") return 3.0; - if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0; - if (item.type === "zunchi") return 2.4; - if (item.type === "grass") return Infinity; - if (item.type === "duplicator") return 0.5; - if (item.type === "water") return 1.8; - if (item.type === "trace" || item.type === "splat") return 1.2; - if (item.type === "ant_corpse") return 3.5; - if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0; - return Infinity; + function phase(name) { + const fn = systems().phases?.[name]; + if (typeof fn !== "function") throw new Error(`Tarinai simulation phase is not available: ${name}`); + return fn; } - function updateItemsScheduled(worldRef, dt) { - if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt); - let ran = 0; - forScheduledItems(worldRef, (it) => { - const interval = itemUpdateInterval(it); - if (!Number.isFinite(interval)) return; - if (interval <= 0) { - it.update(dt, worldRef); - ran += 1; - return; - } - it._scheduledUpdateDt = Math.min(10, (it._scheduledUpdateDt || 0) + dt); - if (it._scheduledUpdateDt < interval) return; - const scheduledDt = it._scheduledUpdateDt; - it._scheduledUpdateDt = 0; - it.update(scheduledDt, worldRef); - ran += 1; - }); - return ran; - } - - function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) { - if (!worldRef || !Array.isArray(list) || !list.length) return false; - const thresholdSq = threshold * threshold; - let moved = false; - for (const entity of list) { - if (!entity || entity.dead) continue; - const x = Number.isFinite(entity.x) ? entity.x : 0; - const y = Number.isFinite(entity.y) ? entity.y : 0; - const oldX = entity._spatialTrackX; - const oldY = entity._spatialTrackY; - if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) { - entity._spatialTrackX = x; - entity._spatialTrackY = y; - moved = true; - continue; - } - const dx = x - oldX; - const dy = y - oldY; - if (dx * dx + dy * dy <= thresholdSq) continue; - entity._spatialTrackX = x; - entity._spatialTrackY = y; - moved = true; - } - if (moved) worldRef.markSpatialDirty?.(reason); - return moved; - } - - function markScheduledItemsMoved(worldRef, reason = "items-moved", threshold = 0.35) { - if (!worldRef) return false; - const thresholdSq = threshold * threshold; - let moved = false; - const tracked = global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef); - const source = tracked && tracked.length ? tracked : null; - const visit = (entity) => { - const x = Number.isFinite(entity.x) ? entity.x : 0; - const y = Number.isFinite(entity.y) ? entity.y : 0; - const oldX = entity._spatialTrackX; - const oldY = entity._spatialTrackY; - if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) { - entity._spatialTrackX = x; - entity._spatialTrackY = y; - moved = true; - return; - } - const dx = x - oldX; - const dy = y - oldY; - if (dx * dx + dy * dy <= thresholdSq) return; - entity._spatialTrackX = x; - entity._spatialTrackY = y; - moved = true; - }; - if (source) { - for (const entity of source) if (entity && !entity.dead) visit(entity); - } else { - forScheduledItems(worldRef, visit); - } - if (moved) worldRef.markSpatialDirty?.(reason); - return moved; - } - - function updateRandomGrassGrowth(worldRef, dt) { - if (!worldRef || !global.TarinaiGrass) return false; - if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return false; - worldRef.grassGrowthTimer = (worldRef.grassGrowthTimer || 0) + dt; - const field = worldRef.fieldType || "garden"; - const growthSpeedMul = 6.0; - const interval = (field === "park" ? 1.8 : (field === "cage" ? 3.2 : 2.4)) / growthSpeedMul; - if (worldRef.grassGrowthTimer < interval) return false; - worldRef.grassGrowthTimer = 0; - const grasses = worldRef.itemsOfType ? worldRef.itemsOfType("grass") : (worldRef.items || []).filter(it => it && it.type === "grass"); - if (!grasses || !grasses.length) return false; - const tries = Math.min(12, grasses.length); - for (let i = 0; i < tries; i++) { - const it = grasses[Math.floor(Math.random() * grasses.length)]; - if (!it || it.dead || (it.amount || 0) <= 0) continue; - if (worldRef.grassBlockedAt?.(it.x, it.y, it)) { - if (global.TarinaiGrass.regress(it, 1) > 0) { - worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-blocked") || worldRef.markTerrainDirty?.("random-grass-blocked"); - worldRef.markItemBucketsDirty?.("random-grass-blocked"); - return true; - } - continue; - } - if (global.TarinaiGrass.advance(it, 1)) { - worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-grow") || worldRef.markTerrainDirty?.("random-grass-grow"); - return true; - } - } - return false; - } - - function normalizeDelta(worldRef, dt) { - if (!worldRef || worldRef.paused) return null; - let frameDt = dt * worldRef.speed; - frameDt = Math.min(frameDt, 0.12 * worldRef.speed); - return frameDt; - } - - function updateClock(worldRef, dt) { - const prevTime = worldRef.time; - worldRef.time += dt; - const previousDay = worldRef.day || 1; - worldRef.day = Math.floor(worldRef.time / CONFIG.dayLength) + 1; - const prevHour = worldRef.hourOfDay ? worldRef.hourOfDay(prevTime) : 0; - const nowHour = worldRef.hourOfDay ? worldRef.hourOfDay(worldRef.time) : 0; - const crossedColonyEvalHour = (worldRef.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6); - const pendingColonyMoodEvaluation = crossedColonyEvalHour && worldRef.lastColonyMoodDay !== worldRef.day; - if (worldRef.day !== previousDay) { - for (const t of worldRef.tarinai || []) if (t) t.personalityDaily = { day: worldRef.day, total: 0, byKey: {}, byCause: {} }; - } - return { prevTime, previousDay, prevHour, nowHour, crossedColonyEvalHour, pendingColonyMoodEvaluation }; - } - - function updateFrameSignals(worldRef, dt) { - if (worldRef.pointer) worldRef.pointer.motion = Math.max(0, (worldRef.pointer.motion || 0) - dt * 2.4); - worldRef.shakeTimer = Math.max(0, (worldRef.shakeTimer || 0) - dt); - if ((worldRef.shakeTimer || 0) <= 0) { worldRef.shakeDuration = 0; worldRef.shakeStrength = 0; } - } - - function updateEnvironment(worldRef, dt) { - worldRef.updateWeather(dt); - worldRef.foodSpoilagePenalty = Math.max(0, (worldRef.foodSpoilagePenalty || 0) - dt * 0.010); - worldRef.updateTerrainDirtyState?.(dt); - } - - function prepareSpatialFrame(worldRef) { - worldRef.spatialRebuildsThisFrame = 0; - worldRef.spatialDirtyMarksThisFrame = 0; - worldRef.ensureSpatial?.("update-start"); - worldRef.beginFramePerformanceBudgets?.(); - } - - function updateItemsAndAnts(worldRef, dt) { - const itemCountBefore = worldRef.items.length; - updateItemsScheduled(worldRef, dt); - updateRandomGrassGrowth(worldRef, dt); - const itemsMoved = markScheduledItemsMoved(worldRef, "items-moved", 0.25); - if (worldRef.itemCounts?.ball) { - worldRef.ensureSpatial?.("ball-ball-collisions"); - worldRef.resolveBallBallCollisions(dt); - worldRef.markSpatialDirty?.("ball-ball-collisions"); - } - worldRef.updateAnts?.(dt); - const antsMoved = markSpatialDirtyIfMoved(worldRef, worldRef.ants || [], "ants-moved", 0.25); - if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update"); - return { itemCountBefore, itemsMoved, antsMoved }; - } - - function updateEffects(worldRef, dt) { - for (const ef of worldRef.effects) ef.update(dt); - } - - - function updateVisibleWorldRect(worldRef, margin = 120) { - const scale = worldRef.viewScale ? worldRef.viewScale() : 1; - const vw = (worldRef.viewportW || worldRef.w || 1000) / Math.max(0.01, scale); - const vh = (worldRef.viewportH || worldRef.h || 720) / Math.max(0.01, scale); - const cameraX = worldRef.cameraX || 0; - const cameraY = worldRef.cameraY || 0; - return { left: cameraX - margin, top: cameraY - margin, right: cameraX + vw + margin, bottom: cameraY + vh + margin }; - } - - function pointInRect(x, y, rect) { - return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; - } - - function tarinaiUpdateInterval(worldRef, tarinai, visibleRect) { - if (!tarinai || tarinai.dead) return Infinity; - if (worldRef.selected === tarinai) return 0; - if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || (tarinai.entryTimer || 0) > 0.01) return 0; - if (pointInRect(tarinai.x || 0, tarinai.y || 0, visibleRect)) return 0; - const count = (worldRef.tarinai || []).length; - const tier = global.TarinaiPerf?.renderQualityTier?.() || "high"; - if (tier === "high" && count <= 36) return 0; - const farRect = updateVisibleWorldRect(worldRef, 520); - return pointInRect(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70; - } - - function updateCreatures(worldRef, dt) { - const end = global.TarinaiPerf?.begin?.("update.tarinai") || null; - const visibleRect = updateVisibleWorldRect(worldRef, 160); - try { - for (const t of worldRef.tarinai) { - if (!t || t.dead) continue; - const interval = tarinaiUpdateInterval(worldRef, t, visibleRect); - if (!Number.isFinite(interval)) continue; - if (interval <= 0) { - const runDt = Math.min(1.4, (t._updateAccum || 0) + dt); - t._updateAccum = 0; - t.update(runDt); - continue; - } - t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt); - t._nextLowFreqUpdateAt = Number.isFinite(t._nextLowFreqUpdateAt) ? t._nextLowFreqUpdateAt : (worldRef.time || 0) + interval * (0.65 + Math.random() * 0.7); - if ((worldRef.time || 0) >= t._nextLowFreqUpdateAt) { - const runDt = Math.max(dt, t._updateAccum || dt); - t._updateAccum = 0; - t._nextLowFreqUpdateAt = (worldRef.time || 0) + interval; - t.update(runDt); - } - } - } finally { - if (end) end(); - } - const now = worldRef.time || 0; - const bedConflictInterval = 3.2; - if (now >= (worldRef.nextBedConflictCheckAt || 0)) { - worldRef.nextBedConflictCheckAt = now + bedConflictInterval; - worldRef.workStats && (worldRef.workStats.bedConflictRuns = (worldRef.workStats.bedConflictRuns || 0) + 1); - worldRef.resolveOwnedBedSleepConflicts?.(); - } else { - worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1); - } - markSpatialDirtyIfMoved(worldRef, worldRef.tarinai, "tarinai-moved", 0.25); - worldRef.ensureSpatial?.("post-tarinai-update"); - worldRef.limitBallChasers(); - worldRef.resolveBallInteractions(dt); - const collisionInterval = 1.8; - if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { - worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval; - worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1); - worldRef.resolveTarinaiHighSpeedCollisions(dt); - } else { - worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); - } - } - - function runMaintenance(worldRef, dt, itemCountBefore) { - worldRef.compactTimer += dt; - const compactInterval = 2.5; - let itemsCompacted = false; - if (worldRef.compactTimer >= compactInterval) { - itemsCompacted = worldRef.compactItems(); - worldRef.compactEffects(); - worldRef.compactTarinai(); - worldRef.compactAnts?.(); - worldRef.compactTimer = 0; - } - - worldRef.countsTimer += dt; - const countsInterval = 2.0; - if (worldRef.countsTimer >= countsInterval || itemsCompacted || worldRef.items.length !== itemCountBefore) { - worldRef.updateItemCounts(); - worldRef.updateEffectCounts(); - worldRef.countsTimer = 0; - } - if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) { - worldRef.nextGrassLimitCheckAt = (worldRef.time || 0) + 3.0; - const removedGrass = worldRef.enforceGrassLimit?.("periodic-grass-limit") || 0; - if (removedGrass > 0) { - worldRef.compactItems(); - worldRef.updateItemCounts(); - } - } - - if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= worldRef.time) { - worldRef.outOfBoundsCheckAt = worldRef.time; - worldRef.sanitizeOutOfBounds(); - } - - worldRef.drawSortTimer += dt; - const sortInterval = 0.25; - if (worldRef.drawSortTimer >= sortInterval) { - worldRef.drawListDirty = true; - worldRef.drawSortTimer = 0; - } - } - - function emitPhaseChange(worldRef) { - const phase = worldRef.phaseName(); - if (phase !== worldRef.lastPhase) { - worldRef.lastPhase = phase; - worldRef.emit("world:phase", { world: worldRef, phase }); - } - } - - function spawnAmbientTarinai(worldRef, dt) { - if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { - const spot = worldRef.edgeSpawnPoint(null, null, 42, true); - const t = worldRef.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, entryTimer: 2.0 }); - t.wanderAngle = spot.angle; - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); - worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); - } - } - - function spawnAmbientGrass(worldRef, dt) { - if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return; - if ((worldRef.canAddGrass?.(1) ?? true) && Math.random() < dt * 0.070) { - const spot = worldRef.findGrassPlantingSpot(rand(60, worldRef.w - 60), rand(60, worldRef.h - 60), { - allowOriginal: true, - minRadius: 28, - maxRadius: 140, - attempts: 18, - }); - if (spot) { - const sprout = new Item("grass", spot.x, spot.y); - global.TarinaiGrass?.setStage?.(sprout, 0, { force: true }); - if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn"); - else worldRef.items.push(sprout); - } - } - } - - function spawnRainWater(worldRef, dt) { - // Rain visuals are diagonal line streaks in render.js. This branch creates - // horizontal oval water items spawned by rain; it intentionally does not - // share the rain overlay renderer. - if (global.TarinaiWeatherSystem?.shouldDropRainWater?.(worldRef, dt)) { - const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef); - worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop); - worldRef.emit?.("rain:itemDropped", { item: drop, type: "water" }); - } - } - - function finalizeDiagnostics(worldRef) { - worldRef.lastRuntimeDiagnostics = worldRef.runtimeDiagnostics?.() || null; - } + function normalizeDelta(worldRef, dt) { return phase("normalizeDelta")(worldRef, dt); } + function updateClock(worldRef, dt) { return phase("updateClock")(worldRef, dt); } + function updateFrameSignals(worldRef, dt) { return phase("updateFrameSignals")(worldRef, dt); } + function updateEnvironment(worldRef, dt) { return phase("updateEnvironment")(worldRef, dt); } + function prepareSpatialFrame(worldRef) { return phase("prepareSpatialFrame")(worldRef); } + function updateItemsAndAnts(worldRef, dt) { return phase("updateItemsAndAnts")(worldRef, dt); } + function updateEffects(worldRef, dt) { return phase("updateEffects")(worldRef, dt); } + function updateCreatures(worldRef, dt) { return phase("updateCreatures")(worldRef, dt); } + function runMaintenance(worldRef, dt, itemCountBefore) { return phase("runMaintenance")(worldRef, dt, itemCountBefore); } + function emitPhaseChange(worldRef) { return phase("emitPhaseChange")(worldRef); } + function spawnAmbientTarinai(worldRef, dt) { return phase("spawnAmbientTarinai")(worldRef, dt); } + function spawnAmbientGrass(worldRef, dt) { return phase("spawnAmbientGrass")(worldRef, dt); } + function spawnRainWater(worldRef, dt) { return phase("spawnRainWater")(worldRef, dt); } + function finalizeDiagnostics(worldRef) { return phase("finalizeDiagnostics")(worldRef); } function update(worldRef, dt) { - const frameDt = normalizeDelta(worldRef, dt); - if (frameDt === null) return; - updateClock(worldRef, frameDt); - updateFrameSignals(worldRef, frameDt); - updateEnvironment(worldRef, frameDt); - prepareSpatialFrame(worldRef); - const mobile = updateItemsAndAnts(worldRef, frameDt); - updateEffects(worldRef, frameDt); - updateCreatures(worldRef, frameDt); - runMaintenance(worldRef, frameDt, mobile.itemCountBefore); - emitPhaseChange(worldRef); - spawnAmbientTarinai(worldRef, frameDt); - spawnAmbientGrass(worldRef, frameDt); - spawnRainWater(worldRef, frameDt); - finalizeDiagnostics(worldRef); + return systems().update(worldRef, dt); } global.TarinaiWorldUpdatePhases = Object.freeze({ @@ -426,4 +55,4 @@ finalizeDiagnostics, }), }); -})(window); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_view.js b/js/world_view.js index f6773ea..cfcd498 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -121,11 +121,11 @@ }, groundDefinition() { - return window.TarinaiGround?.definition?.(this.groundType || "soil") || GROUND_TYPES?.[this.groundType || "soil"] || GROUND_TYPES?.soil || { id: "soil", label: "土", stressMultiplier: 1, grassMultiplier: 1 }; + return window.TarinaiGround?.definition?.(this.groundType || "soil") || GROUND_TYPES?.[this.groundType || "soil"] || GROUND_TYPES?.soil || { id: "soil", label: "砂", stressMultiplier: 1, grassMultiplier: 1 }; }, groundLabel() { - return window.TarinaiGround?.label?.(this.groundType || "soil") || this.groundDefinition().label || "土"; + return window.TarinaiGround?.label?.(this.groundType || "soil") || this.groundDefinition().label || "砂"; }, groundStressMultiplier() { @@ -136,6 +136,10 @@ return window.TarinaiGround?.grassMultiplier?.(this.groundType || "soil") ?? 1; }, + groundGrassGrowthMultiplier() { + return window.TarinaiGround?.grassGrowthMultiplier?.(this.groundType || "soil") ?? this.groundGrassMultiplier?.() ?? 1; + }, + setGroundType(id = "soil", opts = {}) { const next = window.TarinaiGround?.exists?.(id) ? id : "soil"; const previous = this.groundType || "soil"; @@ -152,10 +156,10 @@ if (typeof renderSelected === "function") renderSelected(); if (typeof document !== "undefined") { const groundTextEl = document.getElementById("statGroundType"); - if (groundTextEl) groundTextEl.textContent = this.groundLabel?.() || "土"; + if (groundTextEl) groundTextEl.textContent = this.groundLabel?.() || "砂"; } if (!opts.silent) { - const label = this.groundLabel?.() || "土"; + const label = this.groundLabel?.() || "砂"; this.log?.(`じめん: ${label}`); if (typeof showToast === "function") showToast(`じめんを ${label} にしました。`); } diff --git a/scripts/regression_check.py b/scripts/regression_check.py index b80a1e0..37ea021 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -281,9 +281,10 @@ def check_runtime_diagnostics_hooks() -> None: debug_tools = read_text(ROOT / "js/debug_tools.js") world_update = read_text(ROOT / "js/world_update.js") world_update_phases = read_text(ROOT / "js/world_update_phases.js") + simulation_systems = read_text(ROOT / "js/simulation_systems.js") if "runtimeDiagnostics()" not in spatial_budget: fail("World.runtimeDiagnostics hook missing") - if "lastRuntimeDiagnostics" not in (world_update + world_update_phases): + if "lastRuntimeDiagnostics" not in (world_update + world_update_phases + simulation_systems): fail("world update phases do not publish lastRuntimeDiagnostics") if "spatial rebuild" not in debug_tools or "behavior forced" not in debug_tools: fail("debug overlay is missing runtime diagnostic lines") @@ -359,7 +360,7 @@ def check_item_bucket_polish() -> None: fail("debug overlay is missing item bucket/id-map diagnostics") routed_files = { "js/world_placement_log.js": "addItem?.(item", - "js/world_update_phases.js": "addItem?.(drop", + "js/simulation_ambient_system.js": "addItem?.(drop", "js/world_combat_effects.js": "addItem?.(it", "js/ants.js": "addItem?.(corpse", "js/tarinai_social_move_life.js": "addItem?.(new Item(\"trace\"", @@ -503,6 +504,14 @@ def check_phase_and_input_split() -> None: data = manifest() js_files = data["js"] required = [ + "js/simulation_runtime_helpers.js", + "js/simulation_environment_system.js", + "js/simulation_item_ant_system.js", + "js/simulation_effects_system.js", + "js/simulation_creature_system.js", + "js/simulation_maintenance_system.js", + "js/simulation_ambient_system.js", + "js/simulation_systems.js", "js/world_update_phases.js", "js/ui_input_shared.js", "js/ui_input_touch.js", @@ -517,10 +526,11 @@ def check_phase_and_input_split() -> None: touch = read_text(ROOT / "js/ui_input_touch.js") mouse = read_text(ROOT / "js/ui_input_mouse.js") shared = read_text(ROOT / "js/ui_input_shared.js") - if "TarinaiWorldUpdatePhases" not in world_update or "function update(worldRef, dt)" not in phases: + systems = read_text(ROOT / "js/simulation_systems.js") + if "TarinaiSimulation" not in world_update or "function update(worldRef, dt)" not in phases: fail("World.update is not delegated to world_update_phases.js") for token in ["updateClock", "updateEnvironment", "updateItemsAndAnts", "updateCreatures", "runMaintenance", "spawnRainWater"]: - if token not in phases: + if token not in phases or token not in systems: fail(f"world update phase missing: {token}") if "touchstart" in ui_bind or "mousedown" in ui_bind or "touchmove" in ui_bind: fail("ui_bind.js still owns field pointer input handlers") @@ -591,6 +601,625 @@ def check_processing_split() -> None: fail(f"tarinai_needs_items.js still owns extracted processing: {token}") ok("item and Tarinai processing split present") + +def command_dispatcher_smoke() -> None: + source = read_text(ROOT / "js/command_dispatcher.js") + js = f""" +const vm = require('vm'); +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.World = class World {{}}; +context.scalableToolIds = () => ['grass', 'sweet']; +context.normalizedToolSizeFor = (world, tool) => (world.toolSizes && world.toolSizes[tool]) || world.toolSize || 'medium'; +vm.createContext(context); +vm.runInContext({source!r}, context, {{ filename: 'command_dispatcher.js' }}); +const events = []; +const world = new context.World(); +world.tool = 'observe'; +world.toolSize = 'medium'; +world.toolSizes = {{}}; +world.speed = 1; +world.paused = false; +world.w = 1000; +world.h = 700; +world.viewportW = 500; +world.viewportH = 300; +world.clampCamera = () => {{ world.clamped = true; }}; +world.emit = (type, payload) => events.push([type, payload]); +world.handleClick = (x, y) => {{ world.clicked = [x, y]; }}; +const target = {{ x: 420, y: 260, dead: false }}; +if (!context.TarinaiCommands.dispatch(world, {{ type: 'tool.select', toolId: 'grass' }}).ok || world.tool !== 'grass') throw new Error('tool select failed'); +if (!context.TarinaiCommands.dispatch(world, {{ type: 'tool.size.cycle', toolId: 'grass', order: ['small', 'medium', 'large'] }}).ok || world.toolSize !== 'large') throw new Error('tool size failed'); +if (!context.TarinaiCommands.dispatch(world, {{ type: 'selection.focus', target }}).ok || world.selected !== target || world.cameraX !== 170 || world.cameraY !== 110) throw new Error('focus failed'); +if (!context.TarinaiCommands.dispatch(world, {{ type: 'selection.favorite.toggle' }}).ok || target.favorite !== true) throw new Error('favorite failed'); +if (!context.TarinaiCommands.dispatch(world, {{ type: 'simulation.paused.toggle' }}).ok || world.paused !== true) throw new Error('pause failed'); +if (!context.TarinaiCommands.dispatch(world, {{ type: 'simulation.speed.cycle', speeds: [1, 2, 4] }}).ok || world.speed !== 2) throw new Error('speed failed'); +if (!context.TarinaiCommands.dispatch(world, {{ type: 'pointer.primary', x: 7, y: 9 }}).ok || world.clicked[0] !== 7 || world.clicked[1] !== 9) throw new Error('pointer failed'); +if (typeof world.dispatchCommand !== 'function' || !world.dispatchCommand({{ type: 'selection.clear' }}).ok || world.selected !== null) throw new Error('World facade failed'); +if (!events.some(([type]) => type === 'selection:changed')) throw new Error('selection event missing'); +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("command dispatcher smoke test failed") + ok("command dispatcher smoke test passed") + + +def check_system_order_boundary() -> None: + data = manifest() + js_files = data["js"] + required = [ + "js/simulation_runtime_helpers.js", + "js/simulation_environment_system.js", + "js/simulation_item_ant_system.js", + "js/simulation_effects_system.js", + "js/simulation_creature_system.js", + "js/simulation_maintenance_system.js", + "js/simulation_ambient_system.js", + "js/simulation_systems.js", + "js/world_update_phases.js", + "js/system_order.js", + "js/simulation.js", + "js/world_update.js", + ] + for rel in required: + if rel not in js_files: + fail(f"simulation boundary module missing from manifest: {rel}") + split_order = [ + "js/simulation_runtime_helpers.js", + "js/simulation_environment_system.js", + "js/simulation_item_ant_system.js", + "js/simulation_effects_system.js", + "js/simulation_creature_system.js", + "js/simulation_maintenance_system.js", + "js/simulation_ambient_system.js", + "js/simulation_systems.js", + "js/world_update_phases.js", + "js/system_order.js", + "js/simulation.js", + "js/world_update.js", + ] + if [rel for rel in js_files if rel in split_order] != split_order: + fail("simulation boundary load order is invalid") + system_order = read_text(ROOT / "js/system_order.js") + simulation = read_text(ROOT / "js/simulation.js") + systems_source = read_text(ROOT / "js/simulation_systems.js") + if "TarinaiSimulationSystems" not in systems_source or "const phases = Object.freeze" not in systems_source: + fail("concrete simulation systems facade missing exported phase registry") + concrete_modules = { + "js/simulation_runtime_helpers.js": "TarinaiSimulationRuntime", + "js/simulation_environment_system.js": "TarinaiEnvironmentSimulationSystem", + "js/simulation_item_ant_system.js": "TarinaiItemAntSimulationSystem", + "js/simulation_effects_system.js": "TarinaiEffectsSimulationSystem", + "js/simulation_creature_system.js": "TarinaiCreatureSimulationSystem", + "js/simulation_maintenance_system.js": "TarinaiMaintenanceSimulationSystem", + "js/simulation_ambient_system.js": "TarinaiAmbientSimulationSystem", + } + for rel, token in concrete_modules.items(): + if token not in read_text(ROOT / rel): + fail(f"concrete simulation split module missing export: {rel}") + if "TarinaiSimulationSystems" not in read_text(ROOT / "js/world_update_phases.js"): + fail("world_update_phases.js is not delegating to the concrete simulation systems module") + for token in ["const SYSTEM_ORDER", "clock", "environment", "items_and_ants", "creatures", "maintenance", "diagnostics"]: + if token not in system_order: + fail(f"system order token missing: {token}") + if "TarinaiSystemOrder" not in simulation or "systemOrder" not in simulation: + fail("simulation.js does not expose the named system order boundary") + ok("named simulation system order present") + + +def simulation_boundary_smoke() -> None: + sources = {rel: read_text(ROOT / rel) for rel in [ + "js/simulation_runtime_helpers.js", + "js/simulation_environment_system.js", + "js/simulation_item_ant_system.js", + "js/simulation_effects_system.js", + "js/simulation_creature_system.js", + "js/simulation_maintenance_system.js", + "js/simulation_ambient_system.js", + "js/simulation_systems.js", + "js/world_update_phases.js", + "js/system_order.js", + "js/simulation.js", + ]} + js = f""" +const vm = require('vm'); +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +vm.createContext(context); +for (const [filename, source] of Object.entries({sources!r})) {{ + vm.runInContext(source, context, {{ filename }}); +}} +if (!context.TarinaiSimulationSystems?.phases?.updateClock) throw new Error('simulation systems missing'); +if (!context.TarinaiWorldUpdatePhases?.phases?.updateClock) throw new Error('phase facade missing'); +if (!context.TarinaiSystemOrder?.names?.includes('clock')) throw new Error('system order missing'); +if (!context.TarinaiSimulation?.systemOrder) throw new Error('simulation facade missing'); +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("simulation boundary smoke test failed") + ok("simulation boundary smoke test passed") + + + +def check_update_policy_boundaries() -> None: + data = manifest() + js_files = data["js"] + required = ["js/item_update_policy.js", "js/tarinai_update_policy.js"] + for rel in required: + if rel not in js_files: + fail(f"update policy boundary module missing from manifest: {rel}") + if not (js_files.index("js/item_lifecycle_runtime.js") < js_files.index("js/item_update_policy.js") < js_files.index("js/item_runtime.js")): + fail("item update policy load order is invalid") + if not (js_files.index("js/simulation_runtime_helpers.js") < js_files.index("js/tarinai_update_policy.js") < js_files.index("js/simulation_creature_system.js")): + fail("Tarinai update policy load order is invalid") + item_policy = read_text(ROOT / "js/item_update_policy.js") + tarinai_policy = read_text(ROOT / "js/tarinai_update_policy.js") + scheduler = read_text(ROOT / "js/item_update_scheduler.js") + sim_helpers = read_text(ROOT / "js/simulation_runtime_helpers.js") + creature_system = read_text(ROOT / "js/simulation_creature_system.js") + required_tokens = [ + (item_policy, "global.TarinaiItemUpdatePolicy"), + (item_policy, "itemUpdateInterval"), + (item_policy, "forScheduledItems"), + (tarinai_policy, "global.TarinaiCreatureUpdatePolicy"), + (tarinai_policy, "updateInterval"), + (scheduler, "TarinaiItemUpdatePolicy"), + (sim_helpers, "TarinaiItemUpdatePolicy"), + (creature_system, "TarinaiCreatureUpdatePolicy"), + ] + for source, token in required_tokens: + if token not in source: + fail(f"update policy boundary token missing: {token}") + duplicated_item_policy_sources = [scheduler, sim_helpers] + for source in duplicated_item_policy_sources: + if "const REALTIME_ITEM_TYPES" in source or "const PIN_ITEM_TYPES" in source or "SCHEDULED_ITEM_TYPES =" in source: + fail("item update policy constants are duplicated outside item_update_policy.js") + if "function tarinaiUpdateInterval" in creature_system: + fail("Tarinai update cadence still lives inside simulation_creature_system.js") + js = f""" +const vm = require('vm'); +const fs = require('fs'); +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.isServingFoodType = (type) => type === 'food'; +context.TarinaiItemRuntime = {{ + updateOne(item, dt) {{ item.runs = (item.runs || 0) + 1; item.lastDt = dt; return true; }} +}}; +vm.createContext(context); +for (const file of ['js/item_update_policy.js', 'js/item_update_scheduler.js']) {{ + vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }}); +}} +const world = {{ time: 0, items: [{{ type: 'ball' }}, {{ type: 'food' }}, {{ type: 'grass' }}], ensureItemBuckets() {{}}, itemBucketRebuildsTotal: 0 }}; +if (context.TarinaiItemUpdateScheduler.run(world, 0.1) < 1) throw new Error('realtime item did not run'); +world.time = 5.1; +if (context.TarinaiItemUpdateScheduler.run(world, 0.1) < 1) throw new Error('scheduled item did not run'); +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("update policy scheduler smoke test failed") + ok("update policy boundaries present") + + +def check_entity_runtime_boundary() -> None: + data = manifest() + js_files = data["js"] + required = ["js/item_runtime.js", "js/tarinai_runtime.js"] + for rel in required: + if rel not in js_files: + fail(f"entity runtime boundary module missing from manifest: {rel}") + if not (js_files.index("js/item_lifecycle_runtime.js") < js_files.index("js/item_runtime.js") < js_files.index("js/structure_lifecycle.js")): + fail("item runtime boundary load order is invalid") + if not (js_files.index("js/tarinai_needs_items.js") < js_files.index("js/tarinai_social_move_life.js") < js_files.index("js/tarinai_runtime.js") < js_files.index("js/tarinai_render.js")): + fail("Tarinai runtime boundary load order is invalid") + item_runtime = read_text(ROOT / "js/item_runtime.js") + tarinai_runtime = read_text(ROOT / "js/tarinai_runtime.js") + item_scheduler = read_text(ROOT / "js/item_update_scheduler.js") + sim_helpers = read_text(ROOT / "js/simulation_runtime_helpers.js") + creature_system = read_text(ROOT / "js/simulation_creature_system.js") + required_tokens = [ + (item_runtime, "global.TarinaiItemRuntime"), + (item_runtime, "installPrototypeBoundary"), + (item_runtime, "updateScheduled"), + (tarinai_runtime, "global.TarinaiCreatureRuntime"), + (tarinai_runtime, "updateBatch"), + (item_scheduler, "TarinaiItemRuntime?.updateOne"), + (sim_helpers, "TarinaiItemRuntime?.updateScheduled"), + (creature_system, "TarinaiCreatureRuntime?.updateOne"), + ] + for source, token in required_tokens: + if token not in source: + fail(f"entity runtime boundary token missing: {token}") + ok("entity update runtime boundary present") + + +def check_tarinai_update_pipeline_boundary() -> None: + data = manifest() + js_files = data["js"] + required = [ + "js/tarinai_sunbath_system.js", + "js/tarinai_cursor_care_system.js", + "js/tarinai_local_environment_system.js", + "js/tarinai_update_legacy_system.js", + "js/tarinai_forced_action_planner_system.js", + "js/tarinai_action_planner_system.js", + "js/tarinai_need_planner_system.js", + "js/tarinai_item_interaction_context.js", + "js/tarinai_food_interaction_system.js", + "js/tarinai_contact_item_system.js", + "js/tarinai_item_interaction_system.js", + "js/tarinai_update_step_frame.js", + "js/tarinai_update_step_ai.js", + "js/tarinai_update_step_environment.js", + "js/tarinai_update_step_movement.js", + "js/tarinai_update_step_health.js", + "js/tarinai_update_step_legacy.js", + "js/tarinai_update_pipeline.js", + ] + for rel in required: + if rel not in js_files: + fail(f"Tarinai update pipeline module missing from manifest: {rel}") + expected_order = [ + "js/tarinai_needs_items.js", + "js/tarinai_forced_action_planner_system.js", + "js/tarinai_action_planner_system.js", + "js/tarinai_need_planner_system.js", + "js/tarinai_item_interaction_context.js", + "js/tarinai_food_interaction_system.js", + "js/tarinai_contact_item_system.js", + "js/tarinai_item_interaction_system.js", + "js/tarinai_sunbath_system.js", + "js/tarinai_cursor_care_system.js", + "js/tarinai_local_environment_system.js", + "js/tarinai_update_legacy_system.js", + "js/tarinai_social_move_life.js", + "js/tarinai_update_step_frame.js", + "js/tarinai_update_step_ai.js", + "js/tarinai_update_step_environment.js", + "js/tarinai_update_step_movement.js", + "js/tarinai_update_step_health.js", + "js/tarinai_update_step_legacy.js", + "js/tarinai_update_pipeline.js", + "js/tarinai_runtime.js", + ] + if [rel for rel in js_files if rel in expected_order] != expected_order: + fail("Tarinai update pipeline load order is invalid") + exports = { + "js/tarinai_sunbath_system.js": "global.TarinaiSunbathSystem", + "js/tarinai_cursor_care_system.js": "global.TarinaiCursorCareSystem", + "js/tarinai_local_environment_system.js": "global.TarinaiLocalEnvironmentSystem", + "js/tarinai_update_legacy_system.js": "global.TarinaiLegacyUpdateSystem", + "js/tarinai_forced_action_planner_system.js": "global.TarinaiForcedActionPlannerSystem", + "js/tarinai_action_planner_system.js": "global.TarinaiActionPlannerSystem", + "js/tarinai_need_planner_system.js": "global.TarinaiNeedPlannerSystem", + "js/tarinai_item_interaction_context.js": "global.TarinaiItemInteractionContext", + "js/tarinai_food_interaction_system.js": "global.TarinaiFoodInteractionSystem", + "js/tarinai_contact_item_system.js": "global.TarinaiContactItemSystem", + "js/tarinai_item_interaction_system.js": "global.TarinaiItemInteractionSystem", + "js/tarinai_update_step_frame.js": "global.TarinaiFrameUpdateStep", + "js/tarinai_update_step_ai.js": "global.TarinaiAiUpdateStep", + "js/tarinai_update_step_environment.js": "global.TarinaiEnvironmentUpdateStep", + "js/tarinai_update_step_movement.js": "global.TarinaiMovementUpdateStep", + "js/tarinai_update_step_health.js": "global.TarinaiHealthUpdateStep", + "js/tarinai_update_step_legacy.js": "global.TarinaiLegacyUpdateStep", + "js/tarinai_update_pipeline.js": "global.TarinaiUpdatePipeline", + } + for rel, token in exports.items(): + if token not in read_text(ROOT / rel): + fail(f"Tarinai update pipeline export missing: {rel}") + runtime = read_text(ROOT / "js/tarinai_runtime.js") + if "TarinaiUpdatePipeline.updateOne" not in runtime or "legacyUpdate: fn" not in runtime: + fail("Tarinai runtime does not route through TarinaiUpdatePipeline with legacy fallback") + + needs_items = read_text(ROOT / "js/tarinai_needs_items.js") + legacy_system = read_text(ROOT / "js/tarinai_update_legacy_system.js") + sunbath_system = read_text(ROOT / "js/tarinai_sunbath_system.js") + cursor_system = read_text(ROOT / "js/tarinai_cursor_care_system.js") + local_env_system = read_text(ROOT / "js/tarinai_local_environment_system.js") + interaction_system = read_text(ROOT / "js/tarinai_item_interaction_system.js") + if "TarinaiLegacyUpdateSystem.updateOne" not in needs_items or "const minute = dt / 60" in needs_items: + fail("Tarinai.prototype.update is not a thin facade over the update pipeline/legacy system") + if "TarinaiItemInteractionSystem?.updateOne" not in needs_items or "const foodNeed = Number(this.needRaw?.food" in needs_items: + fail("Tarinai.prototype.interactWithItems is not a thin facade over the interaction system") + for facade_token in ["TarinaiSunbathSystem?.updateOne", "TarinaiCursorCareSystem?.updateOne", "TarinaiLocalEnvironmentSystem?.updateOne"]: + if facade_token not in needs_items: + fail(f"Tarinai prototype subsystem facade missing: {facade_token}") + for token in ["const minute = dt / 60", "this.resolveNeeds(needStepDt)", "this.applyEnvironment(envDt)", "this.checkLife(dt)"]: + if token not in legacy_system: + fail(f"Tarinai legacy update system missing extracted monolith token: {token}") + interaction_context = read_text(ROOT / "js/tarinai_item_interaction_context.js") + food_interaction = read_text(ROOT / "js/tarinai_food_interaction_system.js") + contact_interaction = read_text(ROOT / "js/tarinai_contact_item_system.js") + for source, token in [ + (sunbath_system, "this.sunbathFrameTimer"), + (cursor_system, "this.nextCursorHeartAt"), + (local_env_system, "grassComfort"), + (interaction_context, "const foodNeed = Number(tarinai.needRaw?.food"), + (food_interaction, "tarinai.applyParamItemEffect"), + (contact_interaction, "tarinai.applyWaterEffect(dt, it.type)"), + ]: + if token not in source: + fail(f"Tarinai update subsystem missing extracted token: {token}") + if "TarinaiItemInteractionContext?.create" not in interaction_system or "TarinaiFoodInteractionSystem?.updateCandidate" not in interaction_system or "TarinaiContactItemSystem?.updateCandidate" not in interaction_system: + fail("Tarinai item interaction orchestrator does not delegate to context/food/contact candidate subsystems") + if "for (const item of ctx.foodCandidates" not in interaction_system: + fail("Tarinai item interaction orchestrator does not preserve per-candidate interaction order") + if "TarinaiNeedPlannerSystem?.updateOne" not in needs_items or "function resolveNeedsLegacy" not in needs_items: + fail("Tarinai resolveNeeds is not routed through planner system with legacy fallback") + if "TarinaiNeedPlannerLegacySystem" not in needs_items: + fail("Tarinai need planner legacy system export missing") + if "TarinaiItemInteractionSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_ai.js"): + fail("Tarinai AI step does not delegate item interactions to subsystem") + if "TarinaiCursorCareSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_frame.js"): + fail("Tarinai frame step does not delegate cursor care to subsystem") + if "TarinaiLocalEnvironmentSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_environment.js"): + fail("Tarinai environment step does not delegate local environment to subsystem") + if "TarinaiSunbathSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_movement.js"): + fail("Tarinai movement step does not delegate sunbath to subsystem") + + pipeline = read_text(ROOT / "js/tarinai_update_pipeline.js") + for token in ["TarinaiFrameUpdateStep", "TarinaiAiUpdateStep", "TarinaiEnvironmentUpdateStep", "TarinaiMovementUpdateStep", "TarinaiHealthUpdateStep", "TarinaiLegacyUpdateStep"]: + if token not in pipeline: + fail(f"Tarinai update pipeline missing step token: {token}") + js = f""" +const vm = require('vm'); +const fs = require('fs'); +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.CONFIG = {{ hungerPerMinute: 60, lonelinessPerMinute: 60, energyPerMinute: 60 }}; +context.clamp = (v, a, b) => Math.max(a, Math.min(b, v)); +context.rand = (a, b) => (a + b) / 2; +context.pick = (arr) => arr[0]; +context.stableUnit = () => 0.5; +context.applyNeedShock = () => undefined; +context.applyNeedRelief = () => undefined; +context.calculateStressFromNeeds = () => 0; +context.applyGroundStressModifier = (t, v) => v; +context.createDefaultNeeds = () => ({{ food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }}); +context.tarinaiMaxEnergy = () => 100; +context.tarinaiMetabolicHungerScale = () => 1; +vm.createContext(context); +for (const file of {required!r}) {{ + vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }}); +}} +const calls = []; +const world = {{ + time: 1, + groundType: 'soil', + weather: 'cloudy', + colonyMood: {{ id: 'relaxed' }}, + selected: null, + spendScheduledWork() {{ return true; }}, + temperatureAt() {{ return 0.62; }}, + lightLevel() {{ return 0.5; }}, + spawnBleedEffect() {{ calls.push('bleed'); }}, + resolveFenceCollision() {{ calls.push('fence'); }}, +}}; +const t = {{ + world, dead: false, x: 10, y: 12, vx: 0, vy: 0, radius: 20, + age: 0, reproductionTimer: 10, blink: 0, eatTimer: 0, eatCooldown: 0, foodReactTimer: 0, + surpriseTimer: 0, fearTimer: 0, intimidateTimer: 0, intimidatedTimer: 0, stress: 0, + hpBarTimer: 0, stressBarTimer: 0, loveMochiTimer: 0, fightMochiTimer: 0, + birthRitualTimer: 0, pokeFlashTimer: 0, cursorPetting: 0, pinchThoughtTimer: 0, + sunbathCooldown: 0, fightTimer: 0, fightCooldown: 0, defeatedTimer: 0, stretchTimer: 0, + grassEatTimer: 0, hurtTimer: 0, fallTimer: 0, tempTimer: 0, tempComfort: 0.62, + hunger: 10, loneliness: 0, energy: 100, trait: {{ hunger: 1, social: 1, sleep: 1 }}, + state: 'idle', aiTimer: 0, envTimer: 0, needs: context.createDefaultNeeds(), needRaw: context.createDefaultNeeds(), + updateZunchiDisease() {{ calls.push('zunchiDisease'); }}, + updateSpecialDiseases() {{ calls.push('specialDisease'); }}, + freezeSleepDiseaseMotion() {{ return false; }}, + applyCursorContactCare() {{ calls.push('cursorCare'); }}, + personalityProfile() {{ return {{ fear: 1 }}; }}, + updateFacing() {{ calls.push('facing'); }}, + updateSunbath() {{ calls.push('sunbath'); }}, + maintainTargetProgress() {{ calls.push('targetProgress'); }}, + move() {{ calls.push('move'); }}, + updateNestBoxPresence() {{ calls.push('nestBox'); }}, + checkLife() {{ calls.push('checkLife'); }}, +}}; +if (!context.TarinaiUpdatePipeline.canRunConcretePipeline()) throw new Error('pipeline cannot run concrete steps'); +if (!context.TarinaiUpdatePipeline.updateOne(t, 0.016, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('pipeline update failed'); +for (const expected of ['zunchiDisease', 'specialDisease', 'facing', 'targetProgress', 'move', 'nestBox', 'fence', 'checkLife']) {{ + if (!calls.includes(expected)) throw new Error('missing pipeline call: ' + expected); +}} +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("Tarinai update pipeline smoke test failed") + ok("Tarinai update pipeline boundary present") + + + +def check_item_lifecycle_pipeline_boundary() -> None: + data = manifest() + js_files = data["js"] + dynamic_required = [ + "js/item_dynamic_tool_system.js", + "js/item_dynamic_ball_system.js", + "js/item_dynamic_duplicator_system.js", + "js/item_dynamic_pin_system.js", + "js/item_dynamic_zunchi_system.js", + "js/item_dynamic_system.js", + ] + required = [ + "js/item_lifecycle_support.js", + "js/item_lifecycle_legacy_system.js", + *dynamic_required, + "js/item_lifecycle_decay_system.js", + "js/item_lifecycle_growth_system.js", + "js/item_lifecycle_step_frame.js", + "js/item_lifecycle_step_decay.js", + "js/item_lifecycle_step_dynamic.js", + "js/item_lifecycle_step_growth.js", + "js/item_lifecycle_step_legacy.js", + "js/item_lifecycle_pipeline.js", + ] + for rel in required: + if rel not in js_files: + fail(f"Item lifecycle pipeline module missing from manifest: {rel}") + expected_order = [ + "js/item_lifecycle_support.js", + "js/item_lifecycle_legacy_system.js", + "js/item_lifecycle_runtime.js", + "js/item_update_policy.js", + *dynamic_required, + "js/item_lifecycle_decay_system.js", + "js/item_lifecycle_growth_system.js", + "js/item_lifecycle_step_frame.js", + "js/item_lifecycle_step_decay.js", + "js/item_lifecycle_step_dynamic.js", + "js/item_lifecycle_step_growth.js", + "js/item_lifecycle_step_legacy.js", + "js/item_lifecycle_pipeline.js", + "js/item_runtime.js", + ] + if [rel for rel in js_files if rel in expected_order] != expected_order: + fail("Item lifecycle pipeline load order is invalid") + exports = { + "js/item_lifecycle_support.js": "global.TarinaiItemLifecycleRuntimeSupport", + "js/item_lifecycle_legacy_system.js": "global.TarinaiItemLifecycleLegacySystem", + "js/item_dynamic_tool_system.js": "global.TarinaiItemDynamicToolSystem", + "js/item_dynamic_ball_system.js": "global.TarinaiItemDynamicBallSystem", + "js/item_dynamic_duplicator_system.js": "global.TarinaiItemDynamicDuplicatorSystem", + "js/item_dynamic_pin_system.js": "global.TarinaiItemDynamicPinSystem", + "js/item_dynamic_zunchi_system.js": "global.TarinaiItemDynamicZunchiSystem", + "js/item_dynamic_system.js": "global.TarinaiItemDynamicSystem", + "js/item_lifecycle_decay_system.js": "global.TarinaiItemDecaySystem", + "js/item_lifecycle_growth_system.js": "global.TarinaiItemGrowthSystem", + "js/item_lifecycle_step_frame.js": "global.TarinaiItemFrameLifecycleStep", + "js/item_lifecycle_step_decay.js": "global.TarinaiItemDecayLifecycleStep", + "js/item_lifecycle_step_dynamic.js": "global.TarinaiItemDynamicLifecycleStep", + "js/item_lifecycle_step_growth.js": "global.TarinaiItemGrowthLifecycleStep", + "js/item_lifecycle_step_legacy.js": "global.TarinaiItemLegacyLifecycleStep", + "js/item_lifecycle_pipeline.js": "global.TarinaiItemLifecyclePipeline", + } + for rel, token in exports.items(): + if token not in read_text(ROOT / rel): + fail(f"Item lifecycle pipeline export missing: {rel}") + runtime = read_text(ROOT / "js/item_runtime.js") + if "TarinaiItemLifecyclePipeline.updateOne" not in runtime or "legacyUpdate: fn" not in runtime: + fail("item runtime does not route through TarinaiItemLifecyclePipeline with legacy fallback") + pipeline = read_text(ROOT / "js/item_lifecycle_pipeline.js") + for token in ["TarinaiItemFrameLifecycleStep", "TarinaiItemDecayLifecycleStep", "TarinaiItemDynamicLifecycleStep", "TarinaiItemGrowthLifecycleStep", "TarinaiItemLegacyLifecycleStep"]: + if token not in pipeline: + fail(f"Item lifecycle pipeline missing step token: {token}") + dynamic_step = read_text(ROOT / "js/item_lifecycle_step_dynamic.js") + dynamic_system = read_text(ROOT / "js/item_dynamic_system.js") + if "TarinaiItemDynamicSystem?.update" not in dynamic_step: + fail("Item dynamic lifecycle step does not delegate to TarinaiItemDynamicSystem") + for token in ["TarinaiItemDynamicToolSystem", "TarinaiItemDynamicBallSystem", "TarinaiItemDynamicDuplicatorSystem", "TarinaiItemDynamicPinSystem", "TarinaiItemDynamicZunchiSystem"]: + if token not in dynamic_system: + fail(f"Item dynamic system missing subsystem token: {token}") + for rel in ["js/item_dynamic_tool_system.js", "js/item_dynamic_ball_system.js", "js/item_dynamic_duplicator_system.js", "js/item_dynamic_pin_system.js", "js/item_dynamic_zunchi_system.js"]: + body = read_text(ROOT / rel) + for token in ["item.updateFan", "item.updateMagnet", "item.updateRotator", "item.updateBall", "item.updateDuplicator", "item.updatePushpin", "item.updateZunchiMotion", "item.updateZunchi"]: + if token in body: + fail(f"dynamic subsystem still delegates to prototype method {token} in {rel}") + lifecycle_runtime = read_text(ROOT / "js/item_lifecycle_runtime.js") + lifecycle_support = read_text(ROOT / "js/item_lifecycle_support.js") + lifecycle_decay = read_text(ROOT / "js/item_lifecycle_step_decay.js") + lifecycle_growth = read_text(ROOT / "js/item_lifecycle_step_growth.js") + if "TarinaiItemLifecycleRuntimeSupport" not in lifecycle_support or "TarinaiDuplicatorRuntime" not in lifecycle_support: + fail("item lifecycle support export missing") + if "TarinaiItemDecaySystem?.update" not in lifecycle_decay or "TarinaiItemGrowthSystem?.update" not in lifecycle_growth: + fail("item lifecycle decay/growth steps are not thin system adapters") + for helper_token in ["function fanAffectsPoint", "function duplicatorLoadTypeForItem", "function duplicatorApplyStoredPin"]: + if helper_token in lifecycle_runtime: + fail(f"support helper still lives in item_lifecycle_runtime.js: {helper_token}") + for token in ["updateFan(dt, worldRef) { return window.TarinaiItemDynamicToolSystem", "updateBall(dt, worldRef) { return window.TarinaiItemDynamicBallSystem", "attachPushpin(t, worldRef"]: + if token not in lifecycle_runtime: + fail(f"prototype compatibility facade missing: {token}") + js = f""" +const vm = require('vm'); +const fs = require('fs'); +const context = {{ console, globalThis: null }}; +context.globalThis = context; +context.window = context; +context.isServingFoodType = (type) => type === 'food'; +context.passiveFoodDecayInterval = () => 0.5; +context.passiveFoodDecayRate = () => 0.1; +context.PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING = 0.055; +context.TarinaiItemRegistry = {{ food: {{ hygienePenalty() {{ return 0.05; }} }} }}; +context.isPinType = (type) => type === 'pushpin'; +context.normalizeGrassStage = (item) => {{ item.normalized = true; }}; +context.stableUnit = () => 0.5; +vm.createContext(context); +for (const file of {required!r}) {{ + vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }}); +}} +if (!context.TarinaiItemLifecyclePipeline.canRunConcretePipeline()) throw new Error('item pipeline cannot run concrete steps'); +const events = []; +const world = {{ + itemDropImpact(item) {{ events.push(['drop', item.type]); }}, + registerFoodSpoilage(value, item) {{ events.push(['spoil', value, item.type]); }}, + markTerrainDirty(reason) {{ events.push(['terrain', reason]); }}, + emit(type, payload) {{ events.push(['emit', type, payload.type]); }}, +}}; +const food = {{ type: 'food', dead: false, age: 0, dropTimer: 0.1, dropImpactDone: false, foodServingsRemaining: 1, amount: 1, passiveFoodDecayTimer: 0 }}; +if (!context.TarinaiItemLifecyclePipeline.updateOne(food, 0.6, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('food update failed'); +if (food.age !== 0.6 || !food.dropImpactDone || !(food.foodServingsRemaining < 1) || !events.some(([kind]) => kind === 'spoil')) throw new Error('frame/decay step did not run'); +context.CONFIG = {{ worldPadding: 30 }}; +context.clamp = (v, min, max) => Math.max(min, Math.min(max, v)); +context.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by); +const dynamic = {{ + type: 'ball', dead: false, age: 0, x: 80, y: 80, prevX: 80, prevY: 80, + vx: 12, vy: 0, r: 18, spin: 0, spinVelocity: 0 +}}; +const ballWorld = {{ ...world, w: 500, h: 500, nearbyItems() {{ return []; }} }}; +context.TarinaiItemLifecyclePipeline.updateOne(dynamic, 0.25, ballWorld, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }}); +if (!(dynamic.x > 80) || !(dynamic.spin > 0)) throw new Error('dynamic step did not run'); +const grass = {{ type: 'grass', dead: false, age: 0 }}; +context.TarinaiItemLifecyclePipeline.updateOne(grass, 0.1, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }}); +if (!grass.normalized) throw new Error('growth step did not run'); +""" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: + f.write(js) + tmp = Path(f.name) + try: + result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True) + finally: + tmp.unlink(missing_ok=True) + if result.returncode != 0: + sys.stderr.write(result.stdout) + sys.stderr.write(result.stderr) + fail("Item lifecycle pipeline smoke test failed") + ok("Item lifecycle pipeline boundary present") + def main() -> None: data = manifest() check_files_exist(data) @@ -610,7 +1239,14 @@ def main() -> None: check_item_bucket_polish() check_current_save_format() check_phase_and_input_split() + check_system_order_boundary() + simulation_boundary_smoke() + check_entity_runtime_boundary() + check_item_lifecycle_pipeline_boundary() + check_tarinai_update_pipeline_boundary() + check_update_policy_boundaries() check_processing_split() + command_dispatcher_smoke() check_legacy_behavior_refs() diff --git a/service-worker.js b/service-worker.js index 580011b..6d47b7e 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,11 +1,11 @@ "use strict"; -const APP_VERSION = "16.07.32"; +const APP_VERSION = "16.07.50"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. const coreScriptNames = [ - "version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "world_update_phases", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "restore_coordinator", "save_codec", "save_storage", "save_system", "patch_helpers", "freeze_store", "freeze_snapshot_adapter", "freeze_panel", "freeze_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" + "version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_lifecycle_legacy_system", "item_lifecycle_runtime", "item_update_policy", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "item_lifecycle_step_legacy", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_forced_action_planner_system", "tarinai_action_planner_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_update_legacy_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_step_legacy", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "world_update_phases", "system_order", "simulation", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "save_codec", "save_storage", "save_system", "patch_helpers", "freeze_store", "freeze_snapshot_adapter", "freeze_panel", "freeze_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" ]; const CORE_ASSETS = [ "./",