diff --git a/FILES.json b/FILES.json index d4f8e57..61b1ba9 100644 --- a/FILES.json +++ b/FILES.json @@ -1,6 +1,6 @@ { "purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.", - "version": "39.15.47", + "version": "39.15.75", "entrypoints": { "app": "index.html", "runtime": "js/main.js", @@ -9,75 +9,159 @@ "service_worker": "service-worker.js" }, "read_strategy": { - "default": ["README.md"], - "architecture": ["README.md", "AI_MAP.md"], - "exact_inventory": ["powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All", "python scripts/ai_inventory.py --all"], - "machine_routing": ["FILES.json"] + "default": [ + "README.md" + ], + "architecture": [ + "README.md", + "AI_MAP.md" + ], + "exact_inventory": [ + "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All", + "python scripts/ai_inventory.py --all" + ], + "machine_routing": [ + "FILES.json" + ] }, "groups": [ { "id": "root", - "patterns": [".gitignore", ".htaccess", "app_manifest.json", "favicon.ico", "index.html", "README.md", "AI_MAP.md", "GAME_FEATURES.md", "FILES.json", "service-worker.js"], + "patterns": [ + ".gitignore", + ".htaccess", + "app_manifest.json", + "favicon.ico", + "index.html", + "README.md", + "AI_MAP.md", + "GAME_FEATURES.md", + "FILES.json", + "service-worker.js" + ], "meaning": "Repo/app shell metadata, server/static cache config, docs, manifest, entrypoint." }, { "id": "css", - "patterns": ["css/*.css"], + "patterns": [ + "css/*.css" + ], "meaning": "Base, layout, panel, component, and mobile responsive styling." }, { "id": "scripts", - "patterns": ["scripts/*.py", "scripts/*.ps1"], + "patterns": [ + "scripts/*.py", + "scripts/*.ps1" + ], "meaning": "Generators, regression checks, and token-aware AI inventory tooling." }, { "id": "core", - "patterns": ["js/version.js", "js/event_bus.js", "js/registry_base.js", "js/disease_registry.js", "js/sound_pack.js", "js/data.js", "js/ground_types.js", "js/health.js", "js/input_mode_manager.js", "js/main.js", "js/math.js", "js/perf_profiler.js", "js/debug_tools.js"], + "patterns": [ + "js/version.js", + "js/event_bus.js", + "js/registry_base.js", + "js/disease_registry.js", + "js/sound_pack.js", + "js/data.js", + "js/ground_types.js", + "js/health.js", + "js/input_mode_manager.js", + "js/main.js", + "js/math.js", + "js/perf_profiler.js", + "js/debug_tools.js" + ], "meaning": "Bootstrap, shared constants/helpers, registries, config, profiling/debug." }, { "id": "assets_runtime", - "patterns": ["js/assets.js", "js/audio.js", "js/render.js", "js/tarinai_render.js", "js/item_render_runtime.js"], + "patterns": [ + "js/assets.js", + "js/audio.js", + "js/render.js", + "js/tarinai_render.js", + "js/item_render_runtime.js" + ], "meaning": "Image/audio loading and canvas rendering." }, { "id": "simulation", - "patterns": ["js/sim_core.js", "js/simulation*.js", "js/system_order.js"], + "patterns": [ + "js/sim_core.js", + "js/simulation*.js", + "js/system_order.js" + ], "meaning": "Simulation primitives, concrete systems, phase ordering." }, { "id": "world", - "patterns": ["js/world*.js", "js/weather_system.js"], + "patterns": [ + "js/world*.js", + "js/weather_system.js" + ], "meaning": "World state/view/update/tools/environment/combat/family/social/ants." }, { "id": "physics", - "patterns": ["js/physics*.js", "js/mechanical_system.js", "js/constraint_system.js", "js/collision_footprint_system.js"], + "patterns": [ + "js/physics*.js", + "js/mechanical_system.js", + "js/constraint_system.js", + "js/collision_footprint_system.js" + ], "meaning": "Geometry, collisions, constraints, mechanical item behavior." }, { "id": "items", - "patterns": ["js/item*.js", "js/items.js", "js/structures.js", "js/structure_lifecycle.js"], + "patterns": [ + "js/item*.js", + "js/items.js", + "js/structures.js", + "js/structure_lifecycle.js" + ], "meaning": "Item/structure registry, lifecycle, dynamic behavior, rendering." }, { "id": "tarinai", - "patterns": ["js/tarinai*.js", "js/ants.js"], + "patterns": [ + "js/tarinai*.js", + "js/ants.js" + ], "meaning": "Creature state/actions/needs/social life/item effects/disease/rendering plus ant actor model." }, { "id": "save", - "patterns": ["js/save*.js", "js/snapshot_system.js", "js/restore_coordinator.js", "js/history_system.js"], + "patterns": [ + "js/save*.js", + "js/snapshot_system.js", + "js/restore_coordinator.js", + "js/history_system.js" + ], "meaning": "Snapshot schema/codec/storage/restore/history." }, { "id": "ui", - "patterns": ["js/ui*.js", "js/text_catalog.js", "js/command_dispatcher.js", "js/family_graph.js"], + "patterns": [ + "js/ui*.js", + "js/text_catalog.js", + "js/command_dispatcher.js", + "js/family_graph.js" + ], "meaning": "DOM UI, input, tools, selected panel, logs, charts, family tree, text labels, command routing." }, { "id": "assets", - "patterns": ["assets/sprites/tarinai_*.webp", "assets/ui/tool_*", "assets/ui/favicon*", "assets/ui/apple-touch-icon.png", "assets/ui/ecology_*.webp", "assets/objects/*", "assets/sounds/*"], + "patterns": [ + "assets/sprites/tarinai_*.webp", + "assets/ui/tool_*", + "assets/ui/favicon*", + "assets/ui/apple-touch-icon.png", + "assets/ui/ecology_*.webp", + "assets/objects/*", + "assets/sounds/*" + ], "meaning": "Creature sprites, UI/tool icons, field object images, voice/SFX samples." } ], diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md index 9619b93..a3d0acb 100644 --- a/GAME_FEATURES.md +++ b/GAME_FEATURES.md @@ -67,7 +67,7 @@ The core loop is: ### Habitat - **ずんち / zunchi**: tarinai waste; affects disease and can fertilize grass. -- **スプリンクラー / sprinkler**: cleans one target at a time in its effective radius. +- **ロボ掃除機 / robot_cleaner**: moves through the field at fixed normal speed 60, or 120 in high-speed mode. It checks the nearest matching cleanup target every 2 seconds, keeps pursuing it between scans, and cleans every matching object it touches. The 死骸 target also covers blood traces; clicking the item opens its target settings. - **干草寝床 / bed**: placeable sleeping spot; helps HP recovery. - **巣箱 / nest box**: reduces stress nearby; improves sleep quality when used. - **ボール / ball**: toy that tarinai can play with and the player can poke. diff --git a/README.md b/README.md index 05dd14f..51fe40e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; read deeper docs only when needed. +## v39.15.70 robot cleaner tuning +- Tuned ロボ掃除機 targeting: normal speed is 60, high-speed mode is 120, and speed no longer changes based on the selected cleanup targets. +- The cleaner checks the nearest matching target every 2 seconds, then keeps trying to clean that target between scans. Contact cleanup still removes every matching object it touches. +- The 死骸 target also covers blood traces. When たりない is enabled, nearby Tarinai panic while chased and are cleaned on contact. + ## Read Strategy - Start here for architecture, script order constraints, and where to look next. @@ -68,12 +73,11 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; - Oshibyo lodged position is anchored to the left side of the tarinai sprite. -## v39.15.47 family / sprinkler polish +## v39.15.47 family polish - Family tree generation row spacing is tightened. - Family tree personality tags also use a 2x2 grid (up to 4 visible tags). - Sprinkler effective cleaning radius is increased to 3x the previous value and still filters targets by actual distance. -- Sprinkler rendering is redesigned to a more realistic yard-sprinkler look and now emits water-like spray effects when active. ## v39.15.47 owned object panic / shoot effect diff --git a/app_manifest.json b/app_manifest.json index 89c1eab..86a315a 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.15.65", + "version": "39.15.75", "css": [ "css/base.css", "css/layout.css", diff --git a/css/base.css b/css/base.css index d07ed88..0952812 100644 --- a/css/base.css +++ b/css/base.css @@ -524,6 +524,31 @@ h1 { margin-top: 12px; } +.robot-cleaner-editor-panel { width: min(300px, calc(100vw - 28px)); } +.robot-cleaner-editor-help { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + margin: 0 2px 10px; +} +.robot-cleaner-targets { + display: grid; + gap: 8px; + margin: 8px 0 2px; +} +.robot-cleaner-targets label { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255,255,255,0.58); + border: 1px solid rgba(84,68,48,0.11); + cursor: pointer; + user-select: none; +} +.robot-cleaner-targets input { accent-color: #70b242; } + /* v16.07.08: right menu fixed quick tabs */ .panel-quick-tabs { position: sticky; diff --git a/css/panel.css b/css/panel.css index 9e0fa03..caa34bc 100644 --- a/css/panel.css +++ b/css/panel.css @@ -185,274 +185,57 @@ body::before { .family-personality-tags { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: minmax(13px, auto); + align-items: stretch; overflow: visible !important; text-overflow: clip !important; - gap: 2px 4px; + gap: 2px 3px; margin-top: 2px; white-space: normal !important; max-height: none; - max-width: 84px; + max-width: 100%; + width: 100%; } .family-personality-tags em { - display: block; + display: flex; + align-items: center; + justify-content: center; min-width: 0; - max-width: none; - padding: 1px 4px; + max-width: 100%; + padding: 1px 3px; border-radius: 999px; background: rgba(103,178,44,0.11); border: 1px solid rgba(103,178,44,0.20); color: rgba(64,72,48,0.86); font-size: 8.2px; font-style: normal; - line-height: 1.25; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.family-copy strong { font-size: 12.5px; } -.family-copy span { color: var(--muted); font-size: 9.6px; margin-top: 1px; } - - -.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; } -.colony-stats { - grid-template-columns: repeat(2, 1fr); - margin-bottom: 8px; -} -.colony-chart { - display: block; - width: 100%; - height: 188px; - min-height: 188px; - border: 1px solid rgba(84, 68, 48, 0.14); - border-radius: 14px; - background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54)); - overflow: hidden; -} -.colony-card h2 { line-height: 1.45; margin-bottom: 10px; } -.colony-chart svg { display: block; width: 100%; height: 100%; } -.colony-chart-legend { - display: flex; - flex-wrap: wrap; - gap: 4px 8px; - align-items: center; - margin: 4px 0 8px; - font-size: 11px; - color: var(--muted); - line-height: 1.25; -} -.colony-chart-legend span { - display: inline-flex; - align-items: center; - gap: 4px; - white-space: nowrap; -} -.colony-chart-legend i { - width: 8px; - height: 8px; - border-radius: 999px; - display: inline-block; - box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10); -} - -.relation-row strong { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; } -.relation-face { - width: 24px; - height: 24px; - object-fit: contain; - border-radius: 999px; - background: rgba(255,255,255,0.66); - border: 1px solid rgba(84,68,48,0.12); - vertical-align: middle; - flex: 0 0 auto; -} - - -.lineage-family { - padding: 10px; - margin: 0 0 12px; - border-radius: 15px; - background: rgba(255,253,248,0.62); - border: 1px solid rgba(84,68,48,0.12); -} -.lineage-family h3 { - margin: 0 0 8px; - font-size: 13px; - color: rgba(64,52,38,0.86); -} -/* Family archive: actual top-down tree diagram. */ -.lineage-family-tree { - overflow-x: auto; - overflow-y: visible; - padding: 10px 10px 22px; - -webkit-overflow-scrolling: touch; - overscroll-behavior-x: contain; -} -.lineage-family-tree h3 { - position: sticky; - left: 0; - z-index: 3; - width: max-content; - padding-right: 12px; - background: rgba(255,253,248,0.82); - border-radius: 8px; -} -.lineage-family-tree .lineage-tree-panel { - position: relative; + line-height: 1.18; overflow: visible; - min-width: 360px; - margin-top: 6px; - border-radius: 14px; - background: - linear-gradient(90deg, rgba(84,68,48,0.045) 1px, transparent 1px), - linear-gradient(180deg, rgba(84,68,48,0.040) 1px, transparent 1px), - rgba(255,250,244,0.48); - background-size: 34px 34px, 34px 34px, auto; - border: 1px solid rgba(84,68,48,0.10); -} -.lineage-family-tree .lineage-links { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - overflow: visible; - pointer-events: none; - z-index: 1; - isolation: isolate; -} - -.lineage-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} -.btn.active { - background: #efffde; - border-color: rgba(103,178,44,0.46); - font-weight: 700; -} - -.lineage-render-status { - position: sticky; - top: 0; - left: 0; - z-index: 6; - width: max-content; - margin: 0 0 8px; - padding: 6px 10px; - border-radius: 999px; - font-size: 12px; - color: var(--muted); - background: rgba(255,255,255,0.88); - border: 1px solid rgba(84,68,48,0.10); - box-shadow: 0 4px 10px rgba(64,48,32,0.06); -} -.lineage-stale-status { - position: sticky; - top: 0; - left: 0; - z-index: 6; - width: max-content; - max-width: calc(100% - 12px); - margin: 0 0 8px; - padding: 6px 10px; - border-radius: 999px; - font-size: 12px; - color: rgba(92, 75, 52, 0.82); - background: rgba(255, 249, 232, 0.92); - border: 1px solid rgba(150, 115, 46, 0.16); - box-shadow: 0 4px 10px rgba(64,48,32,0.05); -} -.lineage-family-tree .lineage-links path { - fill: none; - stroke: rgba(107, 88, 61, 0.58); - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; -} -.lineage-family-tree .lineage-links .partner-link { - stroke-width: 2.4; - stroke: rgba(98, 80, 56, 0.68); - stroke-dasharray: 5 4; -} -.lineage-family-tree .lineage-links .child-link { - stroke-width: 2; - stroke: rgba(107, 88, 61, 0.58); -} -.lineage-family-tree .tree-node { - position: absolute; - width: 150px; - height: 104px; - min-height: 104px; - box-sizing: border-box; - align-items: flex-start; - background: linear-gradient(180deg, #fffdf8, #f8f1e5); - box-shadow: 0 2px 5px rgba(64,48,32,0.035); - contain: layout paint style; - z-index: 2; - isolation: isolate; -} -.lineage-family-tree .tree-node.child { - border-color: rgba(103,178,44,0.34); -} -.lineage-family-tree .tree-node.dead { - opacity: 1; - filter: none; -} -.lineage-family-tree .tree-node[data-family-tarinai-id]:hover { - transform: none; -} -.lineage-family-tree .tree-node .family-portrait { - right: 4px; - top: 4px; - bottom: auto; - width: 48px; - height: 48px; -} -.lineage-family-tree .tree-generation-label { - position: absolute; - left: 10px; - width: 54px; - padding: 3px 0; - border-radius: 999px; - font-size: 11px; - color: var(--muted); - text-align: center; - background: rgba(255,255,255,0.70); - border: 1px solid rgba(84,68,48,0.08); - z-index: 2; -} -.lineage-family-tree .lineage-empty-family { - position: absolute; - left: 72px; - bottom: 8px; - padding: 5px 8px; - border-radius: 9px; - background: rgba(255,255,255,0.70); -} - -@media (max-width: 720px) { - .ecology-grid { grid-template-columns: 1fr; } - .ecology-card { grid-template-columns: 68px minmax(0, 1fr); } - .ecology-card img { width: 68px; height: 68px; } -} - -@media (max-width: 720px) { - .lineage-main-content { - height: 70vh; - min-height: 420px; - max-height: none; - } + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; + word-break: keep-all; } .tool[data-tip] { position: relative; } .lineage-family-tree .tree-node .family-copy { - max-width: calc(100% - 46px); + max-width: calc(100% - 8px); + width: 100%; } .lineage-family-tree .tree-node .family-copy strong, .lineage-family-tree .tree-node .family-copy > span:not(.family-genetic-stats):not(.family-fight-stat):not(.family-personality-tags) { max-width: 100%; } + + +.lineage-family-tree .tree-node .family-personality-tags { + max-width: 100%; + width: 100%; +} +.lineage-family-tree .tree-node .family-personality-tags em { + overflow: visible; + text-overflow: clip; + white-space: normal; +} diff --git a/index.html b/index.html index 045871b..0d8fa5a 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -201,152 +201,152 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index b27f2bf..9da7c83 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -158,6 +158,10 @@ return Math.max(14, reach); } const radiusFor = typeof global.itemRadiusFor === "function" ? global.itemRadiusFor : ((type, fallback = 12) => fallback); + if (item.type === "duplicator") { + // 保存済みデータに古い r=34 が残っていても、配置判定は外観に近い半径で扱う。 + return Math.max(14, Math.min(24, num(item.r, radiusFor(item.type, 24)) || radiusFor(item.type, 24))); + } return Math.max(14, num(item.r, radiusFor(item.type, 12)) || radiusFor(item.type, 12)); } @@ -191,8 +195,10 @@ const hub = Math.max(18, Math.min(40, (global.TarinaiPhysicsBodySystem.scalar(item, "thickness", 12) ?? 12) * 2.3)); if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true; } else if (!rects.length) { - const mul = num(opts.radiusMultiplier, item.type === "ball" ? 4.0 : 2.2); - const r = Math.max(24, num(item.r, 12) * mul); + const baseRadius = item.type === "duplicator" ? Math.min(24, num(item.r, 24) || 24) : num(item.r, 12); + const defaultMul = item.type === "ball" ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2); + const mul = num(opts.radiusMultiplier, defaultMul); + const r = Math.max(item.type === "duplicator" ? 18 : 24, baseRadius * mul); hit = bestDistance <= r + padding; } return { hit, distance: bestDistance }; diff --git a/js/command_dispatcher.js b/js/command_dispatcher.js index 09742e9..112ac54 100644 --- a/js/command_dispatcher.js +++ b/js/command_dispatcher.js @@ -9,7 +9,6 @@ function emit(worldRef, type, payload) { worldRef?.emit?.(type, payload); - return payload; } function ok(payload = {}) { @@ -108,12 +107,6 @@ 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"); @@ -140,20 +133,14 @@ 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": diff --git a/js/data.js b/js/data.js index 137665a..7b69a6a 100644 --- a/js/data.js +++ b/js/data.js @@ -48,7 +48,6 @@ const CONFIG = { dayLength: 120, childGrowTime: 58, fightCooldown: 8, - lightingQuality: "cinematic", weatherChangeMin: 46, weatherChangeMax: 96, bubbleLimit: 32, diff --git a/js/event_bus.js b/js/event_bus.js index 562dab0..4fb0e41 100644 --- a/js/event_bus.js +++ b/js/event_bus.js @@ -6,21 +6,13 @@ class TarinaiEventBus { } on(type, handler) { - if (!type || typeof handler !== "function") return () => {}; + if (!type || typeof handler !== "function") return; let list = this.handlers.get(type); if (!list) { list = new Set(); this.handlers.set(type, list); } list.add(handler); - return () => this.off(type, handler); - } - - off(type, handler) { - const list = this.handlers.get(type); - if (!list) return; - list.delete(handler); - if (!list.size) this.handlers.delete(type); } emit(type, detail = {}) { diff --git a/js/history_system.js b/js/history_system.js index 41a8df5..e267361 100644 --- a/js/history_system.js +++ b/js/history_system.js @@ -20,7 +20,6 @@ function snapshotKey(snapshot) { try { const clone = cloneSnapshot(snapshot); - if (clone) clone.c = 0; return JSON.stringify(clone); } catch (_) { return ""; diff --git a/js/item_dynamic_system.js b/js/item_dynamic_system.js index 7bebbe7..3d38b7b 100644 --- a/js/item_dynamic_system.js +++ b/js/item_dynamic_system.js @@ -13,7 +13,7 @@ case "poison_block": case "reciprocator": case "gate_fence": - case "sprinkler": + case "robot_cleaner": case "rope": case "rod": return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false; diff --git a/js/item_dynamic_tool_system.js b/js/item_dynamic_tool_system.js index ffc576d..54c7203 100644 --- a/js/item_dynamic_tool_system.js +++ b/js/item_dynamic_tool_system.js @@ -48,67 +48,327 @@ - function sprinklerRange(item) { - // v39.15.47: effective cleaning radius increased to 3x the prior radius. - // Previous formula: max(96, r * 4.6). Current: max(288, r * 13.8). - return Math.max(288, (Number(item?.r || 28) || 28) * 13.8); + const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains + const ROBOT_MOVE_SPEED = 60; + const ROBOT_HIGH_SPEED = 120; + const ROBOT_RESCAN_INTERVAL = 2.0; + const ROBOT_TARGET_BITS = Object.freeze({ + zunchi: 1 << 0, + ant_corpse: 1 << 1, + tarinai: 1 << 2, + grass: 1 << 3, + grass_bed: 1 << 4, + water: 1 << 5, + }); + const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water"]); + + function robotCleanerDefaultMask() { + return ROBOT_DEFAULT_MASK; } - function spawnSprinklerWaterEffects(item, worldRef, range, flashStrength = 1) { - if (!worldRef?.effects || !item) return; - const burst = 8; - for (let i = 0; i < burst; i++) { - const base = (Math.PI * 2 * i) / burst + (Number(item.sprinklerSpin || 0) || 0) * 0.35; - const dist = Math.min(range * 0.55, 46 + i * 6); - const px = item.x + Math.cos(base) * dist; - const py = item.y + Math.sin(base) * dist * 0.72 - 6; - worldRef.effects.push(new Effect("ring", px, py, { - size: 6 + (i % 3) * 2, - life: 0.16 + (i % 2) * 0.05, - color: `rgba(120,204,245,${0.28 + 0.12 * flashStrength})`, - vx: Math.cos(base) * 8, - vy: Math.sin(base) * 6 - 10, + function robotCleanerMask(item) { + const raw = Number(item?.robotCleanerMask); + return Number.isFinite(raw) ? (raw | 0) : ROBOT_DEFAULT_MASK; + } + + function setRobotCleanerMask(item, mask = ROBOT_DEFAULT_MASK) { + if (!item || item.type !== "robot_cleaner") return false; + item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(mask) | 0)); + return true; + } + + function robotCleanerHighSpeed(item) { + return Boolean(item?.robotCleanerHighSpeed); + } + + function setRobotCleanerHighSpeed(item, enabled = false) { + if (!item || item.type !== "robot_cleaner") return false; + item.robotCleanerHighSpeed = Boolean(enabled); + item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item); + return true; + } + + function robotCleanerEffectiveSpeed(item) { + return robotCleanerHighSpeed(item) ? ROBOT_HIGH_SPEED : ROBOT_MOVE_SPEED; + } + + function robotCleanerHasTarget(item, key = "") { + const bit = ROBOT_TARGET_BITS[String(key || "")]; + return Boolean(bit && (robotCleanerMask(item) & bit)); + } + + function robotTargetLabel(key = "") { + switch (String(key || "")) { + case "zunchi": return "\u305a\u3093\u3061"; + case "ant_corpse": return "\u6b7b\u9ab8"; + case "trace": return "\u6b7b\u9ab8"; + case "splat": return "\u8840\u75d5"; + case "tarinai": return "\u305f\u308a\u306a\u3044"; + case "grass": return "\u8349"; + case "grass_bed": return "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9"; + case "water": return "\u6c34\u6ef4"; + default: return "\u5bfe\u8c61"; + } + } + + function normalizeRobotCleanerState(item) { + if (!item || item.type !== "robot_cleaner") return null; + if (!Number.isFinite(Number(item.robotCleanerMask))) item.robotCleanerMask = ROBOT_DEFAULT_MASK; + item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask) | 0)); + item.amount = Math.max(Number(item.amount || 0) || 0, 999); + if (!Number.isFinite(Number(item.robotCleanerScanTimer))) item.robotCleanerScanTimer = 0; + if (!Number.isFinite(Number(item.robotCleanerCleanFlash))) item.robotCleanerCleanFlash = 0; + if (!Number.isFinite(Number(item.robotCleanerAngle))) item.robotCleanerAngle = Number(item.angle || 0) || 0; + if (!Number.isFinite(Number(item.robotCleanerWanderAngle))) item.robotCleanerWanderAngle = Number(item.robotCleanerAngle || 0) || 0; + item.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); + item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item); + return item; + } + + function robotTargetAlive(target) { + if (!target) return false; + return !target.dead && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y)); + } + + function robotTargetBlocked(item, target, worldRef) { + if (!item || !target || !worldRef) return false; + return Boolean(worldRef.targetBlockedByObstacle?.(item, target, Math.max(18, (item.r || 22) * 0.65))); + } + + function currentRobotTarget(item, worldRef) { + const key = String(item?.robotCleanerTargetType || ""); + const id = String(item?.robotCleanerTargetId || ""); + if (!key || !id || !worldRef) return null; + if (key === "tarinai") { + const t = (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead); + return robotTargetAlive(t) && !robotTargetBlocked(item, t, worldRef) ? t : null; + } + const found = (worldRef.items || []).find(it => it && it.id === id && !it.dead && ( + it.type === key || (key === "ant_corpse" && (it.type === "trace" || it.type === "splat")) + )); + return robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null; + } + + function robotTargetCandidates(item, worldRef) { + const out = []; + if (!worldRef || !item) return out; + const mask = robotCleanerMask(item); + worldRef.ensureItemBuckets?.("robot-cleaner-scan"); + for (const key of ROBOT_TARGET_ORDER) { + const bit = ROBOT_TARGET_BITS[key]; + if (!(mask & bit)) continue; + if (key === "tarinai") { + for (const t of worldRef.tarinai || []) { + if (!t || t.dead) continue; + if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + if (robotTargetBlocked(item, t, worldRef)) continue; + out.push({ target: t, type: key }); + } + continue; + } + const buckets = key === "ant_corpse" + ? ["ant_corpse", "trace", "splat"].flatMap(type => worldRef.itemsOfType?.(type) || (worldRef.items || []).filter(it => it && it.type === type)) + : (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key)); + for (const it of buckets || []) { + if (!it || it.dead || it === item) continue; + if (robotTargetBlocked(item, it, worldRef)) continue; + out.push({ target: it, type: key }); + } + } + return out; + } + + function retargetRobotCleaner(item, worldRef, force = false) { + const current = currentRobotTarget(item, worldRef); + if (current && !force) return current; + item.robotCleanerTargetId = ""; + item.robotCleanerTargetType = ""; + let best = null; + let bestType = ""; + let bestD = Infinity; + for (const rec of robotTargetCandidates(item, worldRef)) { + const target = rec.target; + if (!robotTargetAlive(target)) continue; + const d = distXY(item.x, item.y, target.x, target.y); + if (d < bestD) { best = target; bestType = rec.type; bestD = d; } + } + if (best) { + item.robotCleanerTargetId = best.id || ""; + item.robotCleanerTargetType = bestType || best.type || ""; + } + return best; + } + + function triggerRobotPanic(item, target, worldRef, force = false) { + if (!item || !target || target.dead) return false; + const now = Number(worldRef?.time || target.world?.time || 0) || 0; + if (!force && now < Number(target.robotCleanerPanicCooldownUntil || 0)) return false; + target.robotCleanerPanicCooldownUntil = now + 0.65; + const reason = "\u30ed\u30dc\u6383\u9664\u6a5f\u304b\u3089\u9003\u3052\u3066\u3044\u308b"; + if (typeof target.enterPanic === "function") { + target.enterPanic({ threat: item, reason, fear: 0.86, wake: true, cause: "robot_cleaner", surpriseTimer: 0.18, awakeLockTimer: 0.45 }); + } else { + target.fearTimer = Math.max(target.fearTimer || 0, 0.86); + target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(item, true) : null, reason, wake: true }); + } + return true; + } + + function isRobotTarinaiTarget(target) { + return Boolean(target && (target.constructor?.name === "Tarinai" || (target.thought !== undefined && target.name && target.die))); + } + + function robotTargetTypeForContact(item, target) { + if (!item || !target || target.dead) return ""; + if (isRobotTarinaiTarget(target)) return robotCleanerHasTarget(item, "tarinai") ? "tarinai" : ""; + const type = String(target.type || ""); + if ((type === "ant_corpse" || type === "trace" || type === "splat") && robotCleanerHasTarget(item, "ant_corpse")) return type; + return robotCleanerHasTarget(item, type) ? type : ""; + } + + function robotCleanerCaptureRadius(item, target) { + return Math.max(18, (item?.r || 22) * 0.72 + (target?.r || target?.radius || 10) * 0.72); + } + + function clearRobotTrackedTargetIfMatched(item, target) { + if (!item || !target) return false; + const id = String(target.id || ""); + if (!id || String(item.robotCleanerTargetId || "") !== id) return false; + item.robotCleanerTargetId = ""; + item.robotCleanerTargetType = ""; + item.robotCleanerScanTimer = 0; + return true; + } + + function cleanRobotTarget(item, target, worldRef, typeOverride = "") { + if (!item || !target || !worldRef || target.dead) return false; + const type = String(typeOverride || item.robotCleanerTargetType || target.type || ""); + const label = robotTargetLabel(type); + let removed = false; + const cleanReason = "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u5438\u3044\u8fbc\u307e\u308c\u305f"; + if (type === "tarinai" || isRobotTarinaiTarget(target)) { + triggerRobotPanic(item, target, worldRef, true); + if (typeof target.die === "function") { + target.die(cleanReason, { lowHealth: true }); + removed = true; + } else { + target.dead = true; + target.deathReason = cleanReason; + worldRef.markDead?.(target, cleanReason); + removed = true; + } + } else { + removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, { + reason: "robot-cleaner", + userReason: type === "grass_bed" ? "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u7247\u4ed8\u3051\u3089\u308c\u305f" : cleanReason, + wake: true, + breaker: item, })); + if (!removed) { + target.amount = 0; + target.hp = 0; + target.dead = true; + target.removedReason = "robot-cleaner"; + removed = true; + } } - } - - function updateSprinkler(item, dt, worldRef) { - if (!item || item.dead || !worldRef) return false; - item.amount = Math.max(item.amount || 0, 999); - item.sprinklerSpin = (Number(item.sprinklerSpin || 0) || 0) + dt * 4.2; - item.sprinklerFlash = Math.max(0, (Number(item.sprinklerFlash || 0) || 0) - dt * 1.8); - item.sprinklerTimer = Math.max(0, (Number(item.sprinklerTimer || 0) || 0) - dt); - if (item.sprinklerTimer > 0) return true; - item.sprinklerTimer = global.deterministicRange ? global.deterministicRange(worldRef, "sprinkler-next", 0.55, 1.25, item) : (0.55 + Math.random() * 0.70); - const range = sprinklerRange(item); - const nearby = worldRef.nearbyItems?.(item.x, item.y, range, true) || worldRef.items || []; - const targets = []; - for (const it of nearby) { - if (!it || it.dead || it === item) continue; - if (!(it.type === "zunchi" || it.type === "trace" || it.type === "ant_corpse" || it.type === "splat")) continue; - const r = Math.max(4, Number(it.r || 0) || 4); - if (distXY(item.x, item.y, it.x, it.y) <= range + r) targets.push(it); - } - const sprayAngle = global.deterministicAngle ? global.deterministicAngle(worldRef, "sprinkler-spray-angle", item) : Math.random() * Math.PI * 2; - const sprayX = item.x + Math.cos(sprayAngle) * Math.min(range * 0.42, 132); - const sprayY = item.y + Math.sin(sprayAngle) * Math.min(range * 0.42, 132); - worldRef.effects?.push(new Effect("ring", sprayX, sprayY, { size: 14, life: 0.18, color: "rgba(95,166,230,0.22)" })); - spawnSprinklerWaterEffects(item, worldRef, range, 0.8); - if (!targets.length) return true; - const pickIndex = Math.floor((global.deterministicUnit ? global.deterministicUnit(worldRef, "sprinkler-pick", item, targets.length) : Math.random()) * targets.length) % targets.length; - const target = targets[pickIndex]; - if (!target || target.dead) return true; - target.amount = 0; - target.hp = 0; - target.removedReason = "sprinkler-clean"; - item.sprinklerFlash = 0.55; - item.sprinklerJustActivated = 0.22; - worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(18, (target.r || 12) * 1.6), life: 0.28, color: "rgba(95,166,230,0.34)" })); - spawnSprinklerWaterEffects(item, worldRef, range, 1); - worldRef.markItemBucketsDirty?.("sprinkler-clean"); - worldRef.markSpatialDirty?.("sprinkler-clean"); + if (!removed) return false; + clearRobotTrackedTargetIfMatched(item, target); + item.robotCleanerScanTimer = 0; + item.robotCleanerCleanFlash = 0.42; + item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1; + worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" })); + worldRef.markItemBucketsDirty?.("robot-cleaner"); + worldRef.markSpatialDirty?.("robot-cleaner"); + if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner"); worldRef.updateItemCounts?.(); worldRef.drawListDirty = true; + if ((worldRef.time || 0) - Number(item.robotCleanerLastLogAt || -999) > 1.2) { + worldRef.log?.(`\u30ed\u30dc\u6383\u9664\u6a5f\u304c${label}\u3092\u7247\u4ed8\u3051\u305f\u3002`, "observe"); + item.robotCleanerLastLogAt = worldRef.time || 0; + } + return true; + } + + function cleanRobotContacts(item, worldRef) { + if (!item || !worldRef) return false; + const seen = new Set(); + let changed = false; + for (const rec of robotTargetCandidates(item, worldRef)) { + const target = rec?.target; + if (!robotTargetAlive(target)) continue; + const key = String(target.id || `${rec.type}:${target.x}:${target.y}`); + if (seen.has(key)) continue; + seen.add(key); + const type = robotTargetTypeForContact(item, target) || rec.type || ""; + if (!type) continue; + const capture = robotCleanerCaptureRadius(item, target); + if (distXY(item.x, item.y, target.x, target.y) <= capture) { + if (cleanRobotTarget(item, target, worldRef, type)) changed = true; + } + } + return changed; + } + + function moveRobotCleaner(item, dt, target, worldRef) { + const r = Math.max(14, Number(item.r || 22) || 22); + const speed = robotCleanerEffectiveSpeed(item); + let desiredAngle = Number(item.robotCleanerWanderAngle || 0) || 0; + let desiredSpeed = speed; + if (target && robotTargetAlive(target)) { + desiredAngle = Math.atan2((target.y || 0) - (item.y || 0), (target.x || 0) - (item.x || 0)); + desiredSpeed = speed; + } else if ((item.robotCleanerWanderTimer || 0) <= 0) { + desiredAngle = global.deterministicAngle ? global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2)) : Math.random() * Math.PI * 2; + item.robotCleanerWanderTimer = global.deterministicRange ? global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item) : (1.2 + Math.random() * 2.0); + } + item.robotCleanerWanderTimer = Math.max(0, Number(item.robotCleanerWanderTimer || 0) - dt); + item.robotCleanerWanderAngle = desiredAngle; + const vx = Math.cos(desiredAngle) * desiredSpeed; + const vy = Math.sin(desiredAngle) * desiredSpeed; + const blend = Math.min(1, dt * 6.2); + item.vx = (Number(item.vx || 0) || 0) + (vx - (Number(item.vx || 0) || 0)) * blend; + item.vy = (Number(item.vy || 0) || 0) + (vy - (Number(item.vy || 0) || 0)) * blend; + item.prevX = item.x; + item.prevY = item.y; + item.x += item.vx * dt; + item.y += item.vy * dt; + const minX = CONFIG.worldPadding + r * 0.5; + const minY = CONFIG.worldPadding + r * 0.5; + const maxX = Math.max(minX, (worldRef.w || 0) - CONFIG.worldPadding - r * 0.5); + const maxY = Math.max(minY, (worldRef.h || 0) - CONFIG.worldPadding - r * 0.5); + let bounced = false; + if (item.x < minX) { item.x = minX; item.vx = Math.abs(item.vx) * 0.35; bounced = true; } + if (item.x > maxX) { item.x = maxX; item.vx = -Math.abs(item.vx) * 0.35; bounced = true; } + if (item.y < minY) { item.y = minY; item.vy = Math.abs(item.vy) * 0.35; bounced = true; } + if (item.y > maxY) { item.y = maxY; item.vy = -Math.abs(item.vy) * 0.35; bounced = true; } + if (bounced) { + item.robotCleanerWanderAngle = Math.atan2(item.vy || 0, item.vx || 0) + (global.deterministicRange ? global.deterministicRange(worldRef, "robot-cleaner-bounce", -0.8, 0.8, item) : (Math.random() - 0.5) * 1.6); + item.robotCleanerWanderTimer = 0.4; + } + if (Math.hypot(item.vx || 0, item.vy || 0) > 2) item.robotCleanerAngle = Math.atan2(item.vy || 0, item.vx || 0); + worldRef.markSpatialDirty?.("robot-cleaner-move"); + worldRef.drawListDirty = true; + } + + function updateRobotCleaner(item, dt, worldRef) { + if (!item || item.dead || !worldRef) return false; + normalizeRobotCleanerState(item); + dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0)); + item.robotCleanerCleanFlash = Math.max(0, Number(item.robotCleanerCleanFlash || 0) - dt * 2.8); + item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt); + let target = currentRobotTarget(item, worldRef); + if (!target || item.robotCleanerScanTimer <= 0) { + target = retargetRobotCleaner(item, worldRef, true); + item.robotCleanerScanTimer = ROBOT_RESCAN_INTERVAL; + } + moveRobotCleaner(item, dt, target, worldRef); + if (target && robotTargetAlive(target)) { + const isTarinaiTarget = String(item.robotCleanerTargetType || "") === "tarinai" || isRobotTarinaiTarget(target); + const d = distXY(item.x, item.y, target.x, target.y); + if (isTarinaiTarget && d <= Math.max(96, (item.r || 22) + (target.radius || target.r || 12) + 70)) triggerRobotPanic(item, target, worldRef); + } + cleanRobotContacts(item, worldRef); return true; } @@ -125,7 +385,7 @@ if (item.type === "poison_block") return updatePoisonBlock(item, dt, worldRef); if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef); if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef); - if (item.type === "sprinkler") return updateSprinkler(item, dt, worldRef); + if (item.type === "robot_cleaner") return updateRobotCleaner(item, dt, worldRef); if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef); if (item.type === "rod") return updateRigidLink(item, dt, worldRef); return false; @@ -133,6 +393,17 @@ global.TarinaiItemDynamicToolSystem = Object.freeze({ update, - sprinklerRange, + robotCleanerDefaultMask, + robotCleanerMask, + setRobotCleanerMask, + robotCleanerHasTarget, + robotCleanerHighSpeed, + setRobotCleanerHighSpeed, + robotCleanerEffectiveSpeed, + robotTargetLabel, + robotCleanerTargetBits: ROBOT_TARGET_BITS, + robotCleanerTargetOrder: ROBOT_TARGET_ORDER, + robotCleanerMoveSpeed: ROBOT_MOVE_SPEED, + robotCleanerHighMoveSpeed: ROBOT_HIGH_SPEED, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index 95153e5..ffdffcb 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -110,7 +110,6 @@ draw(ctx, t, lighting = null) { drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 }); } } - drawItemGlow(ctx, this, lightState, visibleAlpha); if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) { ctx.save(); ctx.globalAlpha = 0.32; @@ -293,120 +292,58 @@ draw(ctx, t, lighting = null) { ctx.beginPath(); ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.fill(); - } else if (this.type === "sprinkler") { + } else if (this.type === "robot_cleaner") { ctx.save(); - const flash = clamp(Number(this.sprinklerFlash || 0) || 0, 0, 1); - const spin = Number(this.sprinklerSpin || 0) || 0; - const range = globalThis.TarinaiItemDynamicToolSystem.sprinklerRange ? globalThis.TarinaiItemDynamicToolSystem.sprinklerRange(this) : Math.max(288, (Number(this.r || 28) || 28) * 13.8); - const metalA = styleNight ? "#6a7782" : "#d6dde3"; - const metalB = styleNight ? "#39434d" : "#8a969f"; - const darkMetal = styleNight ? "#1e252b" : "#53606a"; - const baseGreen = styleNight ? "#4f6a3a" : "#76a54b"; - const baseGreenDark = styleNight ? "#304324" : "#547635"; - const hose = styleNight ? "#2f3a42" : "#4b5963"; - // base shadow - ctx.fillStyle = "rgba(30,40,48,0.14)"; + const flash = clamp(Number(this.robotCleanerCleanFlash || 0) || 0, 0, 1); + const angle = Number(this.robotCleanerAngle || this.angle || 0) || 0; + const bodyA = styleNight ? "#39434a" : "#d8e1e3"; + const bodyB = styleNight ? "#1f272d" : "#a7b4b8"; + const trim = styleNight ? "#657781" : "#ffffff"; + const dark = styleNight ? "#12191e" : "#526168"; + ctx.fillStyle = "rgba(20,24,28,0.16)"; ctx.beginPath(); - ctx.ellipse(0, this.r * 0.70, this.r * 0.92, this.r * 0.26, 0, 0, Math.PI * 2); + ctx.ellipse(0, this.r * 0.56, this.r * 1.05, this.r * 0.30, 0, 0, Math.PI * 2); ctx.fill(); - // ground stake and foot - ctx.fillStyle = baseGreen; - ctx.strokeStyle = baseGreenDark; - ctx.lineWidth = Math.max(1.6, this.r * 0.05); - roundedRect(ctx, -this.r * 0.56, this.r * 0.12, this.r * 1.12, this.r * 0.46, this.r * 0.12); - ctx.fill(); - ctx.stroke(); + const bodyGrad = ctx.createRadialGradient(-this.r * 0.34, -this.r * 0.38, this.r * 0.18, 0, 0, this.r * 1.28); + bodyGrad.addColorStop(0, trim); + bodyGrad.addColorStop(0.46, bodyA); + bodyGrad.addColorStop(1, bodyB); + ctx.fillStyle = bodyGrad; + ctx.strokeStyle = dark; + ctx.lineWidth = Math.max(2, this.r * 0.08); ctx.beginPath(); - ctx.moveTo(-this.r * 0.18, this.r * 0.10); - ctx.lineTo(-this.r * 0.38, this.r * 0.88); - ctx.lineTo(-this.r * 0.18, this.r * 0.88); - ctx.lineTo(-this.r * 0.02, this.r * 0.10); - ctx.closePath(); + ctx.ellipse(0, 0, this.r * 1.04, this.r * 0.88, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(this.r * 0.18, this.r * 0.10); - ctx.lineTo(this.r * 0.38, this.r * 0.88); - ctx.lineTo(this.r * 0.18, this.r * 0.88); - ctx.lineTo(this.r * 0.02, this.r * 0.10); - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - // riser pipe - const pipeGrad = ctx.createLinearGradient(-this.r * 0.12, -this.r * 0.54, this.r * 0.12, this.r * 0.36); - pipeGrad.addColorStop(0, metalA); - pipeGrad.addColorStop(0.55, metalB); - pipeGrad.addColorStop(1, metalA); - ctx.fillStyle = pipeGrad; - ctx.strokeStyle = darkMetal; - roundedRect(ctx, -this.r * 0.12, -this.r * 0.30, this.r * 0.24, this.r * 0.62, this.r * 0.08); - ctx.fill(); - ctx.stroke(); - // head and rotor ctx.save(); - ctx.translate(0, -this.r * 0.40); - ctx.rotate(spin); - const armGrad = ctx.createLinearGradient(-this.r * 0.84, 0, this.r * 0.84, 0); - armGrad.addColorStop(0, metalB); - armGrad.addColorStop(0.5, metalA); - armGrad.addColorStop(1, metalB); - ctx.strokeStyle = armGrad; - ctx.lineWidth = Math.max(2.4, this.r * 0.09); - ctx.lineCap = "round"; - ctx.beginPath(); - ctx.moveTo(-this.r * 0.72, 0); - ctx.lineTo(this.r * 0.72, 0); + ctx.rotate(angle); + ctx.fillStyle = flash > 0.02 ? "rgba(105,210,225,0.84)" : (styleNight ? "#50636d" : "#71868e"); + ctx.strokeStyle = dark; + ctx.lineWidth = Math.max(1, this.r * 0.045); + roundedRect(ctx, this.r * 0.18, -this.r * 0.15, this.r * 0.58, this.r * 0.30, this.r * 0.09); + ctx.fill(); ctx.stroke(); - ctx.strokeStyle = darkMetal; - ctx.lineWidth = Math.max(1.2, this.r * 0.04); - ctx.beginPath(); - ctx.moveTo(0, -this.r * 0.28); - ctx.lineTo(0, this.r * 0.20); - ctx.stroke(); - for (const sx of [-1, 1]) { - ctx.fillStyle = hose; - ctx.beginPath(); - ctx.arc(sx * this.r * 0.74, 0, this.r * 0.10, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = flash > 0 ? "rgba(170,225,255,0.95)" : "rgba(118,188,232,0.80)"; - ctx.beginPath(); - ctx.arc(sx * this.r * 0.80, 0, this.r * 0.06, 0, Math.PI * 2); - ctx.fill(); - } - if (flash > 0.02) { - ctx.strokeStyle = `rgba(142,216,255,${0.45 + flash * 0.35})`; - ctx.lineWidth = Math.max(1.0, this.r * 0.04); - for (const sx of [-1, 1]) { - for (let i = 0; i < 3; i++) { - const dir = sx < 0 ? -1 : 1; - const spread = (i - 1) * 0.18; - const len = this.r * (0.95 + i * 0.20); - ctx.beginPath(); - ctx.moveTo(sx * this.r * 0.80, 0); - ctx.quadraticCurveTo(sx * this.r * (1.10 + i * 0.14), -this.r * (0.10 + Math.abs(spread) * 0.4), sx * this.r * (1.36 + i * 0.18), -this.r * (0.18 + spread)); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(sx * this.r * (1.36 + i * 0.18), -this.r * (0.18 + spread), this.r * 0.035 * (1.0 + i * 0.14), 0, Math.PI * 2); - ctx.fillStyle = `rgba(164,228,255,${0.35 + flash * 0.25})`; - ctx.fill(); - } - } - } ctx.restore(); - // top cap - ctx.fillStyle = metalA; - ctx.strokeStyle = darkMetal; + ctx.fillStyle = styleNight ? "#20292f" : "#eef4f5"; + ctx.strokeStyle = styleNight ? "#11181d" : "#6a7980"; + ctx.lineWidth = Math.max(1.4, this.r * 0.055); ctx.beginPath(); - ctx.arc(0, -this.r * 0.40, this.r * 0.16, 0, Math.PI * 2); + ctx.arc(-this.r * 0.26, -this.r * 0.12, this.r * 0.22, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - const rangePulse = 0.14 + flash * 0.30; - ctx.strokeStyle = `rgba(88,157,214,${rangePulse})`; - ctx.lineWidth = 1.2; - ctx.setLineDash([7, 6]); ctx.beginPath(); - ctx.arc(0, 0, range, 0, Math.PI * 2); + ctx.arc(this.r * 0.26, -this.r * 0.12, this.r * 0.22, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = flash > 0.02 ? `rgba(110,220,235,${0.42 + flash * 0.42})` : "rgba(80,100,110,0.38)"; + ctx.lineWidth = Math.max(1.1, this.r * 0.04); + ctx.beginPath(); + ctx.arc(0, 0, this.r * 0.56, 0.22, Math.PI - 0.22); + ctx.stroke(); + ctx.fillStyle = flash > 0.02 ? `rgba(125,230,240,${0.28 + flash * 0.36})` : "rgba(60,78,86,0.20)"; + ctx.beginPath(); + ctx.arc(0, this.r * 0.20, this.r * (0.18 + flash * 0.10), 0, Math.PI * 2); + ctx.fill(); ctx.restore(); } else if (this.type === "duplicator") { ctx.save(); diff --git a/js/item_tool_definitions.js b/js/item_tool_definitions.js index 9f86259..9126f9a 100644 --- a/js/item_tool_definitions.js +++ b/js/item_tool_definitions.js @@ -4,7 +4,7 @@ (function (global) { const TOOL_DEFINITIONS = Object.freeze({ observe: { id: "observe", label: "\u305f\u308a\u306a\u3044\u30c7\u30fc\u30bf", 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" }, - clear_tool: { id: "clear_tool", label: "\u89e3\u9664", placeable: false, scalable: false, iconText: "\u2715", tooltip: "選択中の道具ボタンを解除し、通常の観察状態に戻す。" }, + clear_tool: { id: "clear_tool", label: "\u89e3\u9664", placeable: false, scalable: false, iconText: "\u2715", tooltip: "\u9078\u629e\u4e2d\u306e\u9053\u5177\u30dc\u30bf\u30f3\u3092\u89e3\u9664\u3057\u3001\u901a\u5e38\u306e\u89b3\u5bdf\u72b6\u614b\u306b\u623b\u3059\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: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" }, copy: { id: "copy", label: "\u30b3\u30d4\u30fc", placeable: false, scalable: false, iconText: "\u29c9", tooltip: "\u7f6e\u3044\u305f\u30a2\u30a4\u30c6\u30e0\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u30b3\u30d4\u30fc\u3057\u3001\u6b21\u306e\u30af\u30ea\u30c3\u30af\u3067\u8a2d\u5b9a\u3054\u3068\u8cbc\u308a\u4ed8\u3051\u308b\u3002" }, @@ -17,7 +17,7 @@ const TOOL_DEFINITIONS = Object.freeze({ rope: { id: "rope", label: "\u30d2\u30e2", placeable: false, scalable: false, iconText: "\u2570", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3075\u306b\u3083\u3075\u306b\u3083\u3057\u305f\u30d2\u30e2\u3067\u7e4b\u3050\u3002" }, rod: { id: "rod", label: "\u68d2", placeable: false, scalable: false, iconText: "\u2501", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001\u66f2\u304c\u3089\u306a\u3044\u68d2\u3067\u63a5\u7d9a\u3059\u308b\u3002" }, shoot: { id: "shoot", label: "\u5c04\u6483", placeable: false, scalable: false, icon: "assets/ui/tool_shoot.png", tooltip: "\u9283\u3067\u653b\u6483\u3059\u308b\u3002" }, - sprinkler: { id: "sprinkler", itemType: "sprinkler", label: "\u30b9\u30d7\u30ea\u30f3\u30af\u30e9\u30fc", placeable: true, scalable: true, radius: 28, amount: 999, iconText: "\u2739", tooltip: "\u52b9\u679c\u7bc4\u56f2\u5185\u306e\u305a\u3093\u3061\u3068\u6b7b\u9ab8\u3092\u30e9\u30f3\u30c0\u30e0\u306b1\u3064\u6d17\u6d44\u3059\u308b\u3002" }, + robot_cleaner: { id: "robot_cleaner", itemType: "robot_cleaner", label: "\u30ed\u30dc\u6383\u9664\u6a5f", placeable: true, scalable: false, radius: 22, amount: 999, iconText: "\u25c9", tooltip: "\u30b4\u30df\u3084\u6c5a\u308c\u3092\u7247\u4ed8\u3051\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" }, zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, @@ -41,7 +41,7 @@ const TOOL_DEFINITIONS = Object.freeze({ ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" }, - duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "食べ物、薬、鋲をセットすると無限に供給できる。" }, + duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" }, firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" }, oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" }, @@ -68,7 +68,7 @@ const ITEM_TRAITS = Object.freeze({ pin: new Set(["pushpin", "oshibyo"]), kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), sleepFurniture: new Set(["bed", "nest_box", "grass_bed"]), - draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "sprinkler", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "plushie", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), }); @@ -130,7 +130,7 @@ const TOOL_CATEGORIES = Object.freeze([ { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] }, { 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", "sprinkler", "bed", "nest_box", "ball", "signboard"] }, + { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "ball", "signboard"] }, { id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS }, { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] }, ]); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index a5a5c99..edbc3e2 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -35,12 +35,19 @@ function initializeItemTypeState(item, type, x, y) { item.needEffects.food = 55; item.amount = 999; } - if (type === "sprinkler") { + if (type === "robot_cleaner") { item.amount = 999; - item.sprinklerTimer = rand(0.18, 0.72); - item.sprinklerSpin = rand(0, Math.PI * 2); - item.sprinklerFlash = 0; - item.sprinklerJustActivated = 0; + item.robotCleanerMask = globalThis.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3; + item.robotCleanerTargetId = ""; + item.robotCleanerTargetType = ""; + item.robotCleanerScanTimer = 0; + item.robotCleanerCleanFlash = 0; + item.robotCleanerCleanCount = 0; + item.robotCleanerAngle = rand(0, Math.PI * 2); + item.robotCleanerWanderAngle = item.robotCleanerAngle; + item.robotCleanerWanderTimer = rand(0.2, 1.4); + item.robotCleanerHighSpeed = false; + item.robotCleanerSpeed = globalThis.TarinaiItemDynamicToolSystem?.robotCleanerMoveSpeed ?? 60; } if (type === "ball") { item.vx = rand(-5, 5); diff --git a/js/item_update_policy.js b/js/item_update_policy.js index c59065e..e0b50f2 100644 --- a/js/item_update_policy.js +++ b/js/item_update_policy.js @@ -5,11 +5,11 @@ // 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"]); + const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "robot_cleaner"]); const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); const SCHEDULED_ITEM_TYPES = Object.freeze([ "ball", "genkotsu", "firecracker", "pushpin", "oshibyo", - "plushie", "grass_bed", "zunchi", "sprinkler", "duplicator", "water", "trace", "splat", "ant_corpse", "food", + "plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", ]); @@ -31,7 +31,6 @@ 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 === "sprinkler") return mobile ? 0.45 : 0.25; if (item.type === "grass") return Infinity; if (item.type === "duplicator") return mobile ? 0.8 : 0.5; if (item.type === "water") return mobile ? 2.4 : 1.8; diff --git a/js/physics_world_system.js b/js/physics_world_system.js index 021b91c..215ee4e 100644 --- a/js/physics_world_system.js +++ b/js/physics_world_system.js @@ -537,7 +537,7 @@ const applyConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return true; } return false; }; const bodyApi = Object.freeze({ - isPhysicsType, + isBodyType, isConstraintType, isPhysicsType, ensureBody, ensureConstraint, normalizeBodyState, applyBodyState, markBodyChanged, normalizeConstraintState, applyConstraintState, compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage, diff --git a/js/placement_preview_system.js b/js/placement_preview_system.js index 4815d30..ec49306 100644 --- a/js/placement_preview_system.js +++ b/js/placement_preview_system.js @@ -152,11 +152,6 @@ if (!tmp) return null; const r = tmp.r; let extra = {}; - if (type === "sprinkler") { - const range = global.TarinaiItemDynamicToolSystem.sprinklerRange(tmp) || Math.max(288, r * 13.8); - extra.influence = { kind: "sprinkler", x, y, radius: range }; - extra.boundsRadius = Math.max(14, r * 1.25); - } if (type === "grass") { const overlaps = global.TarinaiPhysicsSoftClear.collectForPlacement(worldRef, tmp) || []; const replacingGrass = overlaps.some(it => it?.type === "grass"); diff --git a/js/render.js b/js/render.js index 73b6368..433bf30 100644 --- a/js/render.js +++ b/js/render.js @@ -318,9 +318,8 @@ function getLightingState(targetWorld) { const horizon = Math.cos(progress * Math.PI * 2 - Math.PI / 2); const shadowLength = lerp(1.58, 0.52, elevation); const warmth = clamp(0.28 + goldenStrength * 0.70 + noonStrength * 0.18 - nightStrength * 0.12, 0, 1); - const bloom = CONFIG.lightingQuality === "cinematic" ? clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58) : 0; + const bloom = clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58); return { - quality: CONFIG.lightingQuality, progress, light, dawnStrength, @@ -408,12 +407,6 @@ function drawRadialGlow(ctx, x, y, radius, color, alpha) { ctx.restore(); } -function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) { - // \u500b\u5225\u306e\u767a\u5149\u30b0\u30e9\u30c7\u30fc\u30b7\u30e7\u30f3\u306f\u500b\u4f53\u6570\u5897\u52a0\u6642\u306e\u8ca0\u8377\u304c\u9ad8\u3044\u305f\u3081\u3001\u901a\u5e38\u63cf\u753b\u306b\u7d71\u5408\u3059\u308b\u3002 - return; -} - - function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale = 1) { if (!influence) return; const x = Number(influence.x || 0) || 0; @@ -645,10 +638,6 @@ function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) { ctx.restore(); } -function drawDawnRimLight(ctx, drawW, drawH, lighting) { - return; -} - // Cached static garden layer: expensive soil/decor loops are rendered only when // canvas size, coarse light phase, or weather changes. let activeCtx = null; @@ -661,15 +650,24 @@ const backgroundCache = { key: "", }; +function backgroundTimePhase(world) { + const progress = typeof world?.dayProgress === "function" ? world.dayProgress() : (((world?.time || 0) % CONFIG.dayLength) / CONFIG.dayLength); + const hour = ((progress % 1) + 1) % 1 * 24; + if (hour >= 5 && hour < 10) return "morning"; + if (hour >= 10 && hour < 17) return "day"; + if (hour >= 17 && hour < 20) return "evening"; + return "night"; +} + function backgroundCacheKey(world, lighting) { const weather = world.weather || "sunny"; const field = world.fieldType || "garden"; const ground = world.groundType || "soil"; + const phase = backgroundTimePhase(world); // Keep the heavy garden/sky redraw coarse. Fine light changes are handled by overlays. const lightBucket = Math.round((lighting.light || 0) * 8); const warmthBucket = Math.round((lighting.goldenStrength || 0) * 5); - const weatherBucket = Math.floor((world.time || 0) / 8); - return `${field}:${ground}:${weather}:${lightBucket}:${warmthBucket}:${weatherBucket}`; + return `${field}:${ground}:${weather}:${phase}:${lightBucket}:${warmthBucket}`; } @@ -702,7 +700,7 @@ function isCachedTerrainItem(it) { } function isBehindSpriteLayerItem(it) { - return Boolean(it && !it.dead && (it.type === "rope" || it.type === "rod" || it.type === "bed" || it.type === "grass_bed" || it.type === "splat")); + return Boolean(it && !it.dead && (it.type === "rope" || it.type === "rod" || it.type === "bed" || it.type === "grass_bed")); } function renderLayerSortY(entity) { @@ -714,7 +712,6 @@ function renderLayerSortY(entity) { function renderLayerKindRank(entity) { if (!entity) return 0; - if (entity.kind === "ant_worker" || entity.kind === "ant_queen") return 1; if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return 2; return 0; } @@ -1261,7 +1258,7 @@ function drawGardenBed(w, h, lighting) { } function drawLightRays(ctx, w, h, lighting) { - if (lighting.quality !== "cinematic" || lighting.goldenStrength <= 0.08) return; + if (lighting.goldenStrength <= 0.08) return; ctx.save(); ctx.globalCompositeOperation = "screen"; const rayAlpha = lighting.goldenStrength * LIGHTING_TUNING.rayAlpha; @@ -1303,9 +1300,7 @@ function drawAtmosphericLighting(ctx, w, h, lighting) { } ctx.restore(); - if (lighting.quality === "cinematic") { - drawRadialGlow(ctx, w * 0.5, h * 0.46, Math.max(w, h) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha); - } + drawRadialGlow(ctx, w * 0.5, h * 0.46, Math.max(w, h) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha); ctx.save(); const vignette = ctx.createRadialGradient(w * 0.5, h * 0.46, Math.min(w, h) * 0.18, w * 0.5, h * 0.48, Math.max(w, h) * 0.76); diff --git a/js/save_codec.js b/js/save_codec.js index 4ec7ac6..48656af 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -13,7 +13,6 @@ FIELD_IDS, GROUND_IDS, MOOD_IDS, - LEGACY_MOOD_IDS, canonicalMoodId, enumIndex, ITEM_TYPE_IDS, @@ -390,7 +389,7 @@ writeBitFields(writer, [tailMask], [5]); if (tailMask & 1) writer.u(pinIdx); if (tailMask & 2) writer.u(nestIdx); - if (tailMask & 4) { writeBitFields(writer, [stateId], [3]); writer.s(stateTarget); } + if (tailMask & 4) { writeBitFields(writer, [stateId], [1]); writer.s(stateTarget); } if (tailMask & 8) for (let i = 0; i < 4; i++) writer.s(genetics[i] || 100); if (tailMask & 16) { writer.u(fightStats[0] || 0); writer.u(fightStats[1] || 0); writer.s(fightStats[2] || 0); } } @@ -412,7 +411,7 @@ const [tailMask] = readBitFields(reader, [5]); const pinIdx = (tailMask & 1) ? reader.u() : -1; const nestIdx = (tailMask & 2) ? reader.u() : -1; - const stateInfo = (tailMask & 4) ? [readBitFields(reader, [3])[0], reader.s()] : [0, -1]; + const stateInfo = (tailMask & 4) ? [readBitFields(reader, [1])[0], reader.s()] : [0, -1]; const genetics = (tailMask & 8) ? [reader.s(), reader.s(), reader.s(), reader.s()] : []; const fightStats = (tailMask & 16) ? [reader.u(), reader.u(), reader.s()] : []; return [flags, birthSeed, x, y, age, generation, hunger, energy, sleepPressure, needs, current, parents, relationships, timers, modes, pinIdx, nestIdx, stateInfo, genetics, fightStats]; @@ -429,6 +428,9 @@ writer.str(extra[0] || ""); } else if (typeId === 6) { // duplicator writer.s(extra[0] ?? -1); + } else if (typeId === 42) { // robot_cleaner + writer.u(Math.max(0, Math.min(0x3f, Number(extra[0] ?? 3) | 0))); + writer.u(extra[1] ? 1 : 0); } else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) { writer.str(JSON.stringify(extra || [])); } else if (typeId === 21) { // ball @@ -453,6 +455,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 === 42) return [reader.u(), reader.u()]; if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) { try { const parsed = JSON.parse(reader.str() || "[]"); @@ -538,13 +541,12 @@ function fieldValue(index = 0) { return FIELD_IDS[Number(index) | 0] || "garden"; } function groundValue(index = 0) { return GROUND_IDS[Number(index) | 0] || "soil"; } - function summaryFromPayload(w = [], tarinaiCount = 0, itemCount = 0) { + function summaryFromPayload(w = [], tarinaiCount = 0) { const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {}); const dayLength = Math.max(1, Number(config.dayLength || 120)); const totalTime = (Number(w[3]) || 0) / 10; const day = Math.max(1, Math.floor(totalTime / dayLength) + 1); - const time10 = Math.round((totalTime - (day - 1) * dayLength) * 10); - return [day, tarinaiCount, fieldValue(w[0]), time10, itemCount, groundValue(w[1])]; + return [day, tarinaiCount, fieldValue(w[0])]; } function encodeBinarySnapshot(snapshot) { @@ -565,22 +567,17 @@ function decodeBinarySnapshot(bytes) { const reader = new BinaryReader(bytes); const schema = reader.b(); - if (schema !== BINARY_SCHEMA_VERSION && schema !== 15) throw new Error("unsupported binary save schema"); + if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema"); const snapshotVersion = reader.u(); - if (snapshotVersion !== Snapshot.version && snapshotVersion !== 11) throw new Error("unsupported binary save version"); + if (snapshotVersion !== Snapshot.version) throw new Error("unsupported binary save version"); const w = readWorld(reader); - if (schema === 15) { - const legacyMood = LEGACY_MOOD_IDS?.[Number(w[2]) | 0] || "relaxed"; - w[2] = enumIndex(MOOD_IDS, canonicalMoodId?.(legacyMood) || "relaxed"); - } const tCount = reader.u(); const t = []; const tarCoordState = { x: 0, y: 0 }; for (let i = 0; i < tCount; i++) t.push(readTarinai(reader, tarCoordState)); const items = readItemBlocks(reader); - const itemCount = items.length; if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save"); - return { v: Snapshot.version, a: "tj1", c: Date.now(), m: summaryFromPayload(w, t.length, items.length), w, t, i: items }; + return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items }; } async function packBytesForSave(bytes) { diff --git a/js/save_schema.js b/js/save_schema.js index 1644f99..c7ed7df 100644 --- a/js/save_schema.js +++ b/js/save_schema.js @@ -4,14 +4,14 @@ // Owns stable save identifiers shared by snapshot_system.js and save_codec.js. // Current-format save identifiers only. (function (global) { - const SNAPSHOT_VERSION = 12; - const BINARY_SCHEMA_VERSION = 16; + const SNAPSHOT_VERSION = 15; + const BINARY_SCHEMA_VERSION = 19; const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]); const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]); const MOOD_IDS = Object.freeze(["relaxed", "devastation", "crisis", "confusion", "fearful", "disease_spread", "weakened", "breeding", "happy", "overcrowded", "isolated"]); - const STATE_IDS = Object.freeze(["idle", "sleep", "panic", "sunbath", "fight", "seek_food", "seek_bed", "wander"]); + const STATE_IDS = Object.freeze(["idle", "sleep"]); 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"]); @@ -21,7 +21,7 @@ "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", - "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "sprinkler" + "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner" ]); function typeIds(names) { @@ -39,20 +39,9 @@ return i >= 0 ? i : fallback; } - const LEGACY_MOOD_IDS = Object.freeze(["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]); - const LEGACY_MOOD_ALIASES = Object.freeze({ - calm: "relaxed", - prickly: "confusion", - stinky: "disease_spread", - weaklings: "weakened", - zunda_party: "happy", - ball_party: "happy", - sun_party: "happy", - ant_overrun: "fearful", - }); function canonicalMoodId(value = "relaxed") { const id = String(value || "relaxed"); - return LEGACY_MOOD_ALIASES[id] || id; + return MOOD_IDS.includes(id) ? id : "relaxed"; } function enumValue(list, index, fallback = "") { @@ -62,7 +51,7 @@ function moodValue(index, fallback = "relaxed") { const i = Number(index) | 0; - return canonicalMoodId(MOOD_IDS[i] ?? LEGACY_MOOD_IDS[i] ?? fallback); + return canonicalMoodId(MOOD_IDS[i] ?? fallback); } global.TarinaiSaveSchema = Object.freeze({ @@ -72,8 +61,6 @@ GROUND_IDS, WEATHER_IDS, MOOD_IDS, - LEGACY_MOOD_IDS, - LEGACY_MOOD_ALIASES, canonicalMoodId, moodValue, STATE_IDS, diff --git a/js/save_system.js b/js/save_system.js index 26fb39f..0baa6cc 100644 --- a/js/save_system.js +++ b/js/save_system.js @@ -28,7 +28,7 @@ async function saveSlot(slot) { const snapshot = createSnapshot(global.world); const hash = await encodeSnapshot(snapshot); - return Storage.writeSlot(slot, hash, { c: snapshot.c, m: snapshot.m }); + return Storage.writeSlot(slot, hash, { m: snapshot.m }); } async function loadSlot(slot) { @@ -48,9 +48,8 @@ const day = m[0] || 1; const population = m[1] || 0; const fieldType = m[2] || "\u5ead"; - const timeText = Number.isFinite(m[3]) ? `${Math.floor((m[3] / 10) % 24).toString().padStart(2, "0")}:00` : ""; const hashLen = entry.hash ? ` / ${entry.hash.length}\u5b57` : ""; - return `${date} / ${fieldType} / ${day}\u65e5\u76ee ${timeText} / ${population}\u5339${hashLen}`; + return `${date} / ${fieldType} / ${day}\u65e5\u76ee / ${population}\u5339${hashLen}`; } function ensureDialog() { diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 4846f88..551af35 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -199,7 +199,7 @@ const modes = [enumIndex(POWER_MODE_IDS, t.powerItemMode || ""), enumIndex(SIZE_MODE_IDS, t.sizeItemMode || ""), enumIndex(LIFE_MODE_IDS, t.lifeItemMode || "")]; const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1; const nestIdx = itemIndex.get(t.insideNestBoxId) ?? -1; - const state = t.state === "sleep" ? "sleep" : (STATE_IDS.includes(t.state) ? t.state : "idle"); + const state = t.state === "sleep" ? "sleep" : "idle"; let targetIdx = -1; if (state === "sleep") { const targetId = t.sleepSession?.targetId || t.pendingGrassBedWakeId || t.target?.id || ""; @@ -231,7 +231,7 @@ x: u(row[2], 1), y: u(row[3], 1), vx: 0, vy: 0, age, birthTime: (worldRef?.time || 0) - age, generation: Math.max(1, u(row[5], 1, 1)), hunger: u(row[6], 1), loneliness: Number(needs.social) || 0, energy: u(row[7], 1), circadianSleepPressure: u(row[8], 1), - needs, state: enumValue(STATE_IDS, row[17]?.[0], "idle"), + needs, state: "idle", isZunchiSlave: Boolean((Number(row[0]) || 0) & (1 << 5)), isTarinaiChampion: Boolean((Number(row[0]) || 0) & (1 << 9)), totalFightWins: Array.isArray(row[19]) ? u(row[19][0], 1) : 0, @@ -274,6 +274,7 @@ 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 === "robot_cleaner") return [Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0]; if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) { const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex); if (packed) return packed; @@ -324,6 +325,13 @@ 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 === "robot_cleaner") { + const mask = Number(extra[0]); + item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3); + item.robotCleanerHighSpeed = Boolean(extra[1]); + item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60); + item.robotCleanerTargetId = ""; + item.robotCleanerTargetType = ""; } else if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) { return; } else if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) { @@ -373,8 +381,7 @@ return { v: SNAPSHOT_VERSION, a: "tj1", - c: Date.now(), - m: [worldRef.day || 1, tarinaiLive.length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemRows.length, worldRef.groundType || "soil"], + m: [worldRef.day || 1, tarinaiLive.length, worldRef.fieldType || "garden"], w: compactWorld(worldRef), t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)), i: itemRows, diff --git a/js/structures.js b/js/structures.js index 1c0f9ea..bc27537 100644 --- a/js/structures.js +++ b/js/structures.js @@ -31,6 +31,12 @@ this.onHead = definition.type === "plushie"; if (definition.type === "plushie") { this.plushieSpriteId = "plushie_bear"; + this.plushieFlingTimer = 0; + this.plushieTrailTimer = 0; + this.spin = 0; + this.spinVelocity = 0; + this.vx = 0; + this.vy = 0; } this.dead = false; this.world = world || null; @@ -47,6 +53,30 @@ return; } if (this.type === "plushie") { + if ((this.plushieFlingTimer || 0) > 0) { + this.carriedById = ""; + this.onHead = false; + this.x += (Number(this.vx || 0) || 0) * dt; + this.y += (Number(this.vy || 0) || 0) * dt; + this.vy = (Number(this.vy || 0) || 0) + 34 * dt; + this.spin = (Number(this.spin || 0) || 0) + (Number(this.spinVelocity || 0) || 0) * dt; + this.plushieFlingTimer = Math.max(0, (this.plushieFlingTimer || 0) - dt); + this.plushieTrailTimer = Math.max(0, (this.plushieTrailTimer || 0) - dt); + if (worldRef?.effects && typeof Effect !== "undefined" && this.plushieTrailTimer <= 0) { + this.plushieTrailTimer = 0.045; + worldRef.effects.push(new Effect("ring", this.x, this.y, { size: 10 + Math.random() * 8, life: 0.16, color: "rgba(190,142,92,0.28)" })); + } + const margin = 96; + if (this.plushieFlingTimer <= 0 || this.x < -margin || this.x > (worldRef?.w || 0) + margin || this.y < -margin || this.y > (worldRef?.h || 0) + margin) { + this.dead = true; + this.hp = 0; + this.amount = 0; + worldRef?.markItemBucketsDirty?.("plushie-fling-out"); + worldRef?.markSpatialDirty?.("plushie-fling-out"); + } + worldRef && (worldRef.drawListDirty = true); + return; + } const owner = this.owner(worldRef); if (!owner) { this.dead = true; @@ -59,6 +89,13 @@ worldRef?.markSpatialDirty?.("plushie-owner-gone"); return; } + if (this.playerHeld || this._heldByPlayer) { + this.carriedById = ""; + this.onHead = false; + this.amount = Math.max(0.001, Number(this.hp || this.amount || 1) || 1); + worldRef && (worldRef.drawListDirty = true); + return; + } this.ownerId = owner.id || this.ownerId; this.carriedById = owner.id || this.ownerId || this.carriedById; this.onHead = true; @@ -102,6 +139,68 @@ return true; } + fling(worldRef = this.world, sourceX = this.x, sourceY = this.y, options = {}) { + if (this.dead || this.type !== "plushie") return false; + const world = worldRef || this.world; + this.world = world || this.world; + const owner = this.owner(world); + let vx = Number(options.vx); + let vy = Number(options.vy); + let speed = Math.hypot(Number.isFinite(vx) ? vx : 0, Number.isFinite(vy) ? vy : 0); + if (!Number.isFinite(vx) || !Number.isFinite(vy) || speed < 80) { + let dx = (Number(this.x || 0) || 0) - (Number(sourceX || 0) || 0); + let dy = (Number(this.y || 0) || 0) - (Number(sourceY || 0) || 0); + let len = Math.hypot(dx, dy); + if (len < 0.001 && owner) { + dx = Number(owner.facing || owner.faceDir || 1) || 1; + dy = -0.34; + len = Math.hypot(dx, dy); + } + if (len < 0.001) { + dx = Math.random() < 0.5 ? -1 : 1; + dy = -0.24; + len = Math.hypot(dx, dy); + } + const base = Math.max(520, Math.min(900, Number(options.speed || 650) || 650)); + vx = dx / len * base; + vy = dy / len * base - Math.max(80, base * 0.18); + speed = Math.hypot(vx, vy); + } else if (speed < 520) { + const scale = 520 / Math.max(1, speed); + vx *= scale; + vy *= scale; + speed = Math.hypot(vx, vy); + } + this.vx = clamp(vx, -980, 980); + this.vy = clamp(vy, -980, 980); + this.spin = Number(this.spin || 0) || 0; + const spinSign = Math.sign(this.vx) || (Math.random() < 0.5 ? -1 : 1); + const autoSpin = spinSign * (11 + Math.min(14, speed / 80)); + this.spinVelocity = clamp(Number(options.spinVelocity || autoSpin) || autoSpin, -28, 28); + this.plushieFlingTimer = Math.max(1.3, Math.min(3.4, Number(options.life || 2.7) || 2.7)); + this.plushieTrailTimer = 0; + this.carriedById = ""; + this.onHead = false; + this.playerHeld = false; + this._heldByPlayer = false; + if (owner) { + owner.target = null; + owner.thought = "ぬいぐるみが飛んでいった"; + owner.pinchThoughtTimer = Math.max(owner.pinchThoughtTimer || 0, 1.1); + owner.surpriseTimer = Math.max(owner.surpriseTimer || 0, 0.45); + if (typeof applyNeedShock === "function") applyNeedShock(owner, { fulfill: 12, safety: 4 }, this); + } + if (world?.effects && typeof Effect !== "undefined") { + world.effects.push(new Effect("ring", this.x, this.y, { size: 30, life: 0.22, color: "rgba(190,142,92,0.38)" })); + world.effects.push(new Effect("fall", this.x, this.y, { size: 18, life: 0.28, color: "rgba(190,142,92,0.28)" })); + } + world?.markItemBucketsDirty?.("plushie-fling"); + world?.markSpatialDirty?.("plushie-fling"); + if (world) world.drawListDirty = true; + if (options.log !== false) world?.log?.("ぬいぐるみが回転しながら飛んでいった。", "observe", { eventType: "plushie_fling", participants: owner ? [owner] : [] }); + return true; + } + draw(ctx, time = 0, lighting = {}) { if (this.dead) return; let ownerAlpha = 1; @@ -146,7 +245,7 @@ const spriteId = "plushie_bear"; this.plushieSpriteId = spriteId; const img = getRenderableImage(spriteId, spriteId); - ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05); + ctx.rotate((Number(this.spin || 0) || 0) + Math.sin(time * 2 + this.seed) * 0.05); if (img) { const metrics = getImageMetrics(spriteId) || getImageMetrics("smile"); const w = this.r * 3.35; diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index 8380f5b..454c766 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -277,25 +277,6 @@ function createPanicEscapeActionSpec() { }; } -function createFleeActionSpec() { - return { - id: "flee", - need: "safety", - subNeed: "avoid", - state: "flee", - label: "\u5371\u306a\u3044\u3082\u306e\u304b\u3089\u96e2\u308c\u3066\u3044\u308b", - phrase: "\u96e2\u308c\u305f", - weight: 40, - selectTarget(t, world) { return findNearbyDanger(world, t, 220); }, - canStart: basicCanStartWithTarget, - start(t, world, ctx = {}) { - const danger = ctx.target ?? this.selectTarget(t, world, ctx); - t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); - return true; - }, - tick: updatePanicBehavior, - }; -} function createApproachFriendActionSpec() { return { @@ -473,7 +454,6 @@ const TARINAI_ACTION_DEFINITIONS = [ createRestActionSpec(), createSunbathActionSpec(), createPanicEscapeActionSpec(), - createFleeActionSpec(), { id: "hide_at_owned_structure", need: "safety", diff --git a/js/tarinai_action_spec.js b/js/tarinai_action_spec.js index c648ca4..02622ac 100644 --- a/js/tarinai_action_spec.js +++ b/js/tarinai_action_spec.js @@ -12,11 +12,6 @@ return Number.isFinite(n) ? n : fallback; } - function toList(value) { - if (Array.isArray(value)) return value.filter(Boolean).map(String); - if (value == null || value === "") return []; - return [String(value)]; - } function hasOwnFn(obj, key) { return typeof obj?.[key] === "function"; @@ -51,9 +46,7 @@ phrase: String(input.phrase || input.label || id), weight: finiteOr(input.weight, 1), priority: finiteOr(input.priority, 0), - tags: toList(input.tags), timerBacked: Boolean(input.timerBacked), - interruptible: input.interruptible !== false, }; const originalCanStart = hasOwnFn(input, "canStart") ? input.canStart : null; @@ -98,11 +91,6 @@ return spec.label; }; - Object.defineProperty(spec, "__actionSpec", { - value: true, - enumerable: false, - configurable: false, - }); return spec; } @@ -135,14 +123,14 @@ const canCtx = isObject(ctx) ? ctx : {}; if (hasOwnFn(spec, "canStart") && !spec.canStart(tarinai, world, canCtx)) return false; const nextCtx = buildActionContext(tarinai, world, canCtx, spec); - return hasOwnFn(spec, "start") ? spec.start(tarinai, world, nextCtx) : spec.run?.(tarinai, world, nextCtx); + return spec.start(tarinai, world, nextCtx); } function tickTarinaiAction(action, tarinai, world, dt = 0, needs = null, ctx = {}) { const spec = resolveActionSpec(action); if (!spec) return undefined; const nextCtx = buildActionContext(tarinai, world, ctx, spec); - return hasOwnFn(spec, "tick") ? spec.tick(tarinai, world, dt, needs, nextCtx) : spec.update?.(tarinai, world, dt, needs, nextCtx); + return spec.tick(tarinai, world, dt, needs, nextCtx); } function finishTarinaiAction(action, tarinai, world, ctx = {}) { diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index 520fed5..e028060 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -37,7 +37,6 @@ seek_bed: "sleep_in_bed", sleep: "sleep_anywhere", panic: "panic_escape", - flee: "flee", fight: "fight_rival", intimidate: "intimidate_enemy", birth_ritual: "birth_ritual", diff --git a/js/tarinai_behavior_state.js b/js/tarinai_behavior_state.js index 5df47c3..9bb48c7 100644 --- a/js/tarinai_behavior_state.js +++ b/js/tarinai_behavior_state.js @@ -4,8 +4,6 @@ const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js"); - const PHASES = new Set(["starting", "seeking", "approaching", "acting", "recovering", "finished", "failed", "idle", "start", "active", "perform"]); - function isObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -19,10 +17,6 @@ return finiteOr(tarinai?.world?.time, 0); } - function normalizePhase(value, fallback = "acting") { - const phase = String(value || fallback || "acting"); - return PHASES.has(phase) ? phase : phase; - } function targetIdFor(target = null) { if (!target) return null; @@ -121,7 +115,7 @@ const behavior = { actionId, - phase: normalizePhase(input.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "acting")), + phase: String(input.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "acting")), target, source, reason, diff --git a/js/tarinai_behavior_text.js b/js/tarinai_behavior_text.js index 20c2576..9e62196 100644 --- a/js/tarinai_behavior_text.js +++ b/js/tarinai_behavior_text.js @@ -44,7 +44,7 @@ function actionTextForReason(action = null, tarinai = null) { if (cause.includes("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")) return "\u53cd\u6483\u3057\u3066\u3044\u308b"; return target?.name ? `${name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b"; } - if (id === "panic_escape" || id === "flee") return "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + if (id === "panic_escape") return "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; return String(action?.label || action?.phrase || behavior?.label || "\u307c\u30fc\u3063\u3068\u3057\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, ""); } diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js index fe2a1a1..a551dfa 100644 --- a/js/tarinai_building_behavior.js +++ b/js/tarinai_building_behavior.js @@ -137,6 +137,7 @@ function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) { for (const item of candidates) { if (!item || item.dead || !roleMatches(item, "grassMaterial")) continue; if (tarinai?.shouldAvoidTarget?.(item)) continue; + if (world?.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65))) continue; const available = grassMaterialAvailableStages(item); if (available < required) continue; const reserved = reservedGrassStagesForBuildMaterial(world, item, type, tarinai); diff --git a/js/tarinai_forced_behavior.js b/js/tarinai_forced_behavior.js index 698ca0b..5886036 100644 --- a/js/tarinai_forced_behavior.js +++ b/js/tarinai_forced_behavior.js @@ -34,10 +34,6 @@ function isLiveTarinaiEntity(value) { return Boolean(value && !value.dead && typeof value.relationTo === "function" && Number.isFinite(value.x) && Number.isFinite(value.y)); } -function relationFearSafe(holder, other) { - if (!holder || !other || typeof holder.relationTo !== "function") return 0; - return Number(holder.relationTo(other.id)?.fear || 0) || 0; -} function nearestForcedFightTarget(tarinai, world, maxDist = 720) { if (!tarinai || !world) return null; diff --git a/js/tarinai_item_interaction_context.js b/js/tarinai_item_interaction_context.js index 1cbdac4..496dcdb 100644 --- a/js/tarinai_item_interaction_context.js +++ b/js/tarinai_item_interaction_context.js @@ -17,8 +17,8 @@ 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 scanLimit = 28; + const foodScanRadius = tarinai.radius + 92; 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 = []; diff --git a/js/tarinai_item_targeting.js b/js/tarinai_item_targeting.js index b7a2608..c1624bf 100644 --- a/js/tarinai_item_targeting.js +++ b/js/tarinai_item_targeting.js @@ -101,6 +101,7 @@ function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) { for (const it of world.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world.items || []) { if (!it || it.dead || it.type === "zunchi") continue; if (tarinai.shouldAvoidTarget?.(it)) continue; + if (tarinaiTargetBlockedByObstacle(world, tarinai, it, "food")) continue; const type = effectiveFoodTypeForItem(it); if (!type) continue; if (["sweet", "food", "grass", "water", "zunda_juice", "ant_corpse"].includes(type) || (isServingFoodType(type))) { @@ -130,25 +131,48 @@ function zunchiEatingBlocked(tarinai, item, world) { return false; } +function tarinaiTargetBlockedByObstacle(world, tarinai, item, role = "") { + if (!world || !tarinai || !item) return false; + // ずんだ餅だけは障害物越しでも食べに行く。 + if ((role === "food" || role === "medicine") && effectiveFoodTypeForItem(item) === "sweet") return false; + return Boolean(world.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65))); +} + +function foodRoleSearchTypes(role = "") { + const set = new Set(); + if (role === "food") { + for (const type of ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "grass", "zunchi", "ant_corpse", "zunda_juice", "duplicator"]) set.add(type); + if (typeof FOOD_SERVING_TYPES !== "undefined") for (const type of FOOD_SERVING_TYPES) set.add(type); + } else if (role === "drink") { + for (const type of ["water", "zunda_juice", "duplicator"]) set.add(type); + } else if (role === "medicine") { + for (const type of ["sweet", "water", "zunda_juice", "sleep_drug", "love_mochi", "fight_mochi", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "duplicator"]) set.add(type); + if (typeof PARAM_EFFECT_ITEM_TYPES !== "undefined") for (const type of PARAM_EFFECT_ITEM_TYPES) set.add(type); + } + return set; +} + function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { let best = null, bestScore = Infinity; - const hunger = Number(tarinai?.hunger || 0) || 0; - const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0; - const foodEmergency = role === "food" && (hunger >= 96 || foodNeed >= 84); - const searchDist = foodEmergency ? Math.max(maxDist, 1180) : maxDist; - const primary = ((role === "food" || role === "drink" || role === "medicine") && world?.nearbyFood) + const searchDist = Math.max(1, Number(maxDist) || 520); + const foodLikeRole = role === "food" || role === "drink" || role === "medicine"; + const primary = (foodLikeRole && world?.nearbyFood) ? world.nearbyFood(tarinai.x, tarinai.y, searchDist, true) : (world?.nearbyItems?.(tarinai.x, tarinai.y, searchDist, true) || world?.items || []); const candidates = Array.isArray(primary) ? primary.slice() : []; - if (foodEmergency && world?.itemsOfType) { - const emergencyTypes = new Set(["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "grass", "zunchi", "ant_corpse", "zunda_juice", "duplicator"]); - if (typeof FOOD_SERVING_TYPES !== "undefined") for (const type of FOOD_SERVING_TYPES) emergencyTypes.add(type); - for (const type of emergencyTypes) { - for (const it of world.itemsOfType(type) || []) { - if (it && !it.dead && !candidates.includes(it)) candidates.push(it); - } + const addCandidate = (it) => { + if (it && !it.dead && !candidates.includes(it)) candidates.push(it); + }; + + // 食料・水・薬は、通常時から空間インデックスだけに依存しない。 + // 生成直後/移動直後のアイテムが nearbyFood のキャッシュから漏れても、距離と roleMatches で最終判定する。 + if (foodLikeRole) { + if (Array.isArray(world?.items)) for (const it of world.items) addCandidate(it); + if (world?.itemsOfType) { + for (const type of foodRoleSearchTypes(role)) for (const it of world.itemsOfType(type) || []) addCandidate(it); } } + for (const item of candidates) { if (!roleMatches(item, role)) continue; if (role === "food" && typeof tarinai?.foodItemHasServingLeft === "function") { @@ -160,11 +184,16 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { if (tarinai?.shouldAvoidTarget?.(item)) continue; const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item); if (d > searchDist) continue; + const blocked = tarinaiTargetBlockedByObstacle(world, tarinai, item, role); let score = d; if (role === "food" || role === "medicine") { const priority = foodPriorityScore(tarinai, item, d); if (!Number.isFinite(priority)) continue; - score = -priority; + score = -priority + (blocked ? 260 : 0); + } else if (blocked && !foodLikeRole) { + continue; + } else if (blocked) { + score += 180; } if (score < bestScore) { best = item; bestScore = score; } } @@ -295,6 +324,7 @@ function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) { for (const item of world?.items || []) { if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue; if (type && item.type !== type) continue; + if (tarinaiTargetBlockedByObstacle(world, tarinai, item, "structure")) continue; const d = dist(tarinai, item); if (d < bestD) { best = item; bestD = d; } } @@ -307,6 +337,7 @@ function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) { if (!item?.isStructure || item.dead || item.ownerId) continue; if (type && item.type !== type) continue; if (tarinai?.shouldAvoidTarget?.(item)) continue; + if (tarinaiTargetBlockedByObstacle(world, tarinai, item, "structure")) continue; const d = dist(tarinai, item); if (d < bestD) { best = item; bestD = d; } } diff --git a/js/tarinai_needs_core.js b/js/tarinai_needs_core.js index ff9ee24..5631ee2 100644 --- a/js/tarinai_needs_core.js +++ b/js/tarinai_needs_core.js @@ -47,7 +47,6 @@ const TARINAI_ACTION_LOCK_SECONDS = { rest_to_recover: 6.0, sunbath: 10.5, panic_escape: 2.1, - flee: 1.7, hide_at_owned_structure: 6.4, approach_friend: 6.4, approach_parent_or_child: 7.0, @@ -66,7 +65,6 @@ const TARINAI_ACTION_PRIORITY = { fight_rival: 56, intimidate_enemy: 42, panic_escape: 94, - flee: 92, hide_at_owned_structure: 90, use_medicine: 86, rest_to_recover: 84, @@ -94,7 +92,6 @@ function actionPriority(actionOrId) { return Number(TARINAI_ACTION_PRIORITY[id] ?? actionOrId?.priority ?? 0) || 0; } -const TARINAI_UNINTERRUPTIBLE_STATES = ["panic", "intimidate", "fight", "birth_ritual"]; const TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY = { fight_mochi: 180, love_mochi: 150, @@ -229,7 +226,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3 return true; } } - const panicAction = actions.find(a => a.id === "panic_escape") || actions.find(a => a.id === "flee"); + const panicAction = actions.find(a => a.id === "panic_escape"); if (!panicAction) return false; const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 }; const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world); diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 29738e8..0812a4d 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -5,7 +5,7 @@ // Targeting, ActionSpec definitions, consumables, social ticks, and building are split into dedicated modules. if (typeof window !== "undefined") { - Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, chooseActionForNeed, buildReasonText, formatCauseActionText, forcedCausePhrase, refreshBehaviorReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findPrioritySleepPlaceBeforeGrassBedBuild, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyGroundStressModifier, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setBehaviorText, setBehavior, clearBehavior, behaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, forcedBehaviorQueueOf, currentForcedBehaviorRequest, actionPriority, forcedBehaviorSourcePriority, completeForcedBehavior, retryForcedBehavior, relationFearSafe, isLiveTarinaiEntity, actionById }); + Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, buildReasonText, formatCauseActionText, forcedCausePhrase, refreshBehaviorReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findPrioritySleepPlaceBeforeGrassBedBuild, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyGroundStressModifier, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setBehaviorText, setBehavior, clearBehavior, behaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, forcedBehaviorQueueOf, currentForcedBehaviorRequest, actionPriority, forcedBehaviorSourcePriority, completeForcedBehavior, retryForcedBehavior, isLiveTarinaiEntity, actionById }); } function decayNeedSatisfaction(tarinai, dt = 0) { @@ -242,16 +242,6 @@ function actionSelectionBonus(action, need, tarinai, options = {}) { return bonus; } -function chooseActionForNeed(need, tarinai, world) { - const actions = TARINAI_ACTIONS.filter(action => action.need === need && canStartTarinaiAction(action, tarinai, world)); - const weighted = actions.map(action => { - const bonus = actionSelectionBonus(action, need, tarinai, { includeComfortActions: true }); - return { ...action, weight: Math.max(0.01, (Number(action.weight) || 1) + bonus) }; - }); - const picked = weightedPickAction(weighted) || TARINAI_ACTIONS.find(action => action.id === "wander_lightly"); - return picked ? TARINAI_ACTIONS.find(action => action.id === picked.id) || picked : null; -} - function socialSubNeedStartThreshold(subNeed = "") { return ({ conflict: 34, mate: 24, family: 12, bond: 14, fearSocial: 18 })[subNeed] ?? 16; @@ -409,17 +399,11 @@ function startNeedAction(tarinai, world, choice, action, reasonText, options = { return Boolean(ran); } -function shouldInterruptCurrentAction(tarinai, needs) { - // \u6b32\u6c42\u5024\u306e\u81ea\u7136\u5909\u52d5\u3060\u3051\u3067\u306f\u5b9f\u884c\u4e2d\u884c\u52d5\u3092\u4e2d\u65ad\u3057\u306a\u3044\u3002 - // \u85ac\u30fb\u9905\u306a\u3069\u306e\u5916\u90e8\u5f37\u5236\u884c\u52d5\u306f forcedBehaviorQueue \u5074\u306e\u512a\u5148\u9806\u4f4d\u3067\u51e6\u7406\u3059\u308b\u3002 - return false; -} function continueCurrentBehavior(tarinai, world, needs, dt) { const behavior = currentTarinaiBehavior(tarinai); const actionId = behavior?.actionId; if (!actionId) return false; - if (shouldInterruptCurrentAction(tarinai, needs) && !isCurrentBehaviorForced(tarinai)) return false; const action = actionById(actionId); if (!action) return false; if (tarinai.target && tarinai.target.dead) return false; @@ -456,9 +440,19 @@ function continueCurrentBehavior(tarinai, world, needs, dt) { if (behaviorIsForced(behavior)) retryForcedBehavior(tarinai, behavior, "update_failed"); tarinai.actionCooldowns = tarinai.actionCooldowns || {}; tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {}; - tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6); - tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 2.4); - tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55); + const consumableAction = action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine"; + if (consumableAction) { + // 食料・水・薬は環境変化が速いので、失敗時も短い再評価だけにする。 + tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 0.35); + tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 0.45); + tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.18); + tarinai.target = null; + tarinai.targetKey = ""; + } else { + tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6); + tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 2.4); + tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55); + } clearBehavior(tarinai); return false; } @@ -510,6 +504,8 @@ function shouldWakeFromSleep(tarinai, world, needs) { const elapsed = Math.max(0, now - (Number.isFinite(started) ? started : now)); if ((tarinai.hurtTimer || 0) > 0.06 || (tarinai.pokeFlashTimer || 0) > 0.08) return true; if (findNearbyDanger(world, tarinai, 160)) return true; + const foodNeed = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0; + if ((tarinai.hunger || 0) >= 84 || foodNeed >= needThreshold("food", "start")) return true; // \u81ea\u7136\u306a\u7761\u7720\u306f\u77ed\u3044\u5b89\u5168\u30ed\u30c3\u30af\u5f8c\u3001\u5341\u5206\u306b\u56de\u5fa9\u3057\u305f\u3089\u8d77\u304d\u308b\u3002 if (elapsed < (Number(session.minDuration) || 8)) return false; const sleepNeed = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0; diff --git a/js/tarinai_nest_sleep_system.js b/js/tarinai_nest_sleep_system.js index ff90883..0e59aec 100644 --- a/js/tarinai_nest_sleep_system.js +++ b/js/tarinai_nest_sleep_system.js @@ -12,9 +12,13 @@ return Boolean(item && (item.roles?.sleepPlace || (typeof global.isSleepFurnitureType === "function" ? global.isSleepFurnitureType(item.type) : (item.type === "bed" || item.type === "nest_box" || item.type === "grass_bed")))); } + function sleepTargetBlocked(worldRef, tarinai, item) { + if (!worldRef || !tarinai || !item) return false; + return Boolean(worldRef.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65))); + } + function nestBoxCapacity(worldRef, box = null) { - if (worldRef?.nestBoxCapacity && worldRef.nestBoxCapacity !== nestBoxCapacityFacade) return worldRef.nestBoxCapacity(box); - return 5; + return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : 5; } function nestBoxOccupants(worldRef, box, limit = 5) { @@ -72,6 +76,7 @@ function sleepPlaceAvailableFor(worldRef, tarinai, item) { if (!worldRef || !tarinai || !item || item.dead || !isSleepFurniture(item)) return false; if (tarinai.shouldAvoidTarget?.(item)) return false; + if (sleepTargetBlocked(worldRef, tarinai, item)) return false; const capacity = item.type === "nest_box" ? nestBoxCapacity(worldRef, item) : 1; const occupancy = bedOccupancy(worldRef, item); return occupancy < capacity || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id); @@ -82,6 +87,7 @@ for (const item of worldRef?.items || []) { if (!item?.isStructure || item.dead || item.ownerId !== tarinai?.id) continue; if (type && item.type !== type) continue; + if (sleepTargetBlocked(worldRef, tarinai, item)) continue; const d = distFn(tarinai, item); if (d < bestD) { best = item; bestD = d; } } @@ -94,6 +100,7 @@ if (!item?.isStructure || item.dead || item.ownerId) continue; if (type && item.type !== type) continue; if (tarinai?.shouldAvoidTarget?.(item)) continue; + if (sleepTargetBlocked(worldRef, tarinai, item)) continue; const d = distFn(tarinai, item); if (d < bestD) { best = item; bestD = d; } } @@ -129,9 +136,6 @@ || null; } - // Named separately so nestBoxCapacity() can detect and avoid recursive facade calls. - function nestBoxCapacityFacade(box) { return nestBoxCapacity(this, box); } - global.TarinaiNestSleepSystem = Object.freeze({ isSleepFurniture, isTarinaiHiddenInNestBox, diff --git a/js/tarinai_render.js b/js/tarinai_render.js index 35590e1..cddbc53 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -473,7 +473,6 @@ ctx.restore(); } } - drawDawnRimLight(ctx, dw, dh, lightState); if (faceRight) ctx.scale(-1, 1); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 9bdb120..75b9547 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -370,7 +370,13 @@ } if (this.target && !this.target.dead) { - const moveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target; + const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target; + const routeStates = new Set(["seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "ant_attack"]); + const routeTargetItem = this.target?.hostItem || this.target; + const waypoint = routeStates.has(this.state) && this.world?.findTarinaiPathWaypoint + ? this.world.findTarinaiPathWaypoint(this, baseMoveTarget, { targetItem: routeTargetItem, padding: Math.max(18, (this.radius || 20) * 0.82), state: this.state }) + : null; + const moveTarget = waypoint || baseMoveTarget; const dx = moveTarget.x - this.x; const dy = moveTarget.y - this.y; const d = Math.hypot(dx, dy) || 1; diff --git a/js/tarinai_update_policy.js b/js/tarinai_update_policy.js index 2746c72..12515af 100644 --- a/js/tarinai_update_policy.js +++ b/js/tarinai_update_policy.js @@ -90,10 +90,10 @@ if (hardRealtimeUrgent) return { interval: 0, lane: "urgent", smoothMotion: false, visible: isVisible, urgent: true }; // Panic movement must stay visually continuous, but the full creature - // pipeline does not need to run every rendered frame. Treat panic/flee as + // pipeline does not need to run every rendered frame. Treat panic as // a high-priority motion lane: AI/timers are quantized, motion-only still // runs on visible skipped frames. - const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic|flee/i.test(id); + const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic/i.test(id); if (panicUrgent) { const interval = isVisible ? (mobile ? 0.13 : (tier === "low" ? 0.12 : 0.08)) : (isNear ? (mobile ? 0.24 : 0.18) : (mobile ? 0.46 : 0.36)); return { interval, lane: "panic", smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)), visible: isVisible, urgent: true }; diff --git a/js/ui_bind.js b/js/ui_bind.js index c3765cc..ba0507a 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -121,15 +121,6 @@ function bindUI() { ui.panelQuickTabs?.addEventListener("pointerup", activatePanelQuickTab, { passive: false }); ui.panelQuickTabs?.addEventListener("touchend", activatePanelQuickTab, { passive: false }); - /* old quick-tab binding removed */ - if (false) ui.panelQuickTabs?.addEventListener("click", (e) => { - const btn = e.target.closest("[data-panel-tab]"); - if (!btn) return; - e.preventDefault(); - audio.uiClick?.(); - scrollPanelToTab(btn.dataset.panelTab || ""); - }); - ui.panel?.addEventListener("scroll", () => { const panel = ui.panel; if (!panel || !ui.panelQuickTabs) return; diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 3c80ec5..d7ae5e8 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -148,7 +148,10 @@ function syncGrabbedPhysicsItem(target, reason = "pinch-move") { if (!target || target.dead) return false; const pb = global.TarinaiPhysicsBodySystem; - if (!pb.isBodyType(target.type)) return false; + const isBody = typeof pb?.isBodyType === "function" + ? pb.isBodyType(target) + : Boolean(typeof pb?.isPhysicsType === "function" && pb.isPhysicsType(target) && typeof pb?.ensureBody === "function" && pb.ensureBody(target, null, { syncFromLegacy: false })); + if (!isBody) return false; pb.syncPoseFromItem(target); pb.setScalar(target, "awakeUntil", Math.max(Number(pb.scalar(target, "awakeUntil", 0) || 0), (world.time || 0) + 0.65), reason); pb.markBodyChanged(target, reason); @@ -278,7 +281,13 @@ const releaseVx = clamp(Number(uiCache.grabVelocityX || target.vx || 0) || 0, -900, 900); const releaseVy = clamp(Number(uiCache.grabVelocityY || target.vy || 0) || 0, -900, 900); const releaseSpeed = Math.hypot(releaseVx, releaseVy); - if (releaseSpeed > 18) { + if (target.type === "plushie" && typeof target.fling === "function") { + const sourceX = Number(uiCache.grabLastWorldX ?? target.x) || target.x; + const sourceY = Number(uiCache.grabLastWorldY ?? target.y) || target.y; + const vx = releaseSpeed > 80 ? releaseVx : 0; + const vy = releaseSpeed > 80 ? releaseVy : 0; + target.fling(world, sourceX, sourceY, { source: "pinch", vx, vy, speed: Math.max(620, releaseSpeed * 1.05), life: 2.8 }); + } else if (releaseSpeed > 18) { target.vx = releaseVx; target.vy = releaseVy; const frameDt = Math.max(1 / 90, Math.min(1 / 30, Number(world.dt || 1 / 60) || 1 / 60)); diff --git a/js/ui_pointer_action_system.js b/js/ui_pointer_action_system.js index 6efd849..22ae826 100644 --- a/js/ui_pointer_action_system.js +++ b/js/ui_pointer_action_system.js @@ -114,7 +114,6 @@ dispatchTapAt, dispatchTapClient, cancel, - clearHoseHoldTimer, }); } diff --git a/js/ui_tools.js b/js/ui_tools.js index 39d740b..895351c 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -54,19 +54,8 @@ function drawToolPreviewCanvas(canvas, itemType, { watermark = false } = {}) { const ctx = canvas.getContext("2d"); if (!ctx) return false; ctx.clearRect(0, 0, canvas.width, canvas.height); - if (typeof window.drawToolItemPreview === "function") { - return window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true, watermark }) !== false; - } - if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType) && typeof window.drawConsumableFieldSprite === "function") { - ctx.save(); - ctx.translate(canvas.width / 2, canvas.height / 2); - const baseScale = itemType === "protein" || itemType === "niteropu" ? 0.9 : 1.0; - ctx.scale(baseScale, baseScale); - window.drawConsumableFieldSprite(ctx, itemType, 17.5, 112.4, { compact: true }); - ctx.restore(); - return true; - } - return false; + if (typeof window.drawToolItemPreview !== "function") return false; + return window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true, watermark }) !== false; } function createToolPreviewCanvas(className, size = 96) { @@ -83,7 +72,7 @@ function attachToolSpritePreview(btn, def, toolId) { if (!btn || !itemType || !def?.placeable) return; if (toolId === "reciprocator") return; if (toolId === "ball" || itemType === "ball") return; - if (typeof window.drawToolItemPreview !== "function" && typeof window.drawConsumableFieldSprite !== "function") return; + if (typeof window.drawToolItemPreview !== "function") return; const iconCanvas = createToolPreviewCanvas("tool-sprite-preview", 160); const iconDrawn = drawToolPreviewCanvas(iconCanvas, itemType); diff --git a/js/version.js b/js/version.js index 76ca377..042de4f 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.15.65"; - const APP_BUILD = "tarinai-mood-unification-v39-15-65"; + const APP_VERSION = "39.15.75"; + const APP_BUILD = "tarinai-ai-needs-cleanup-v39-15-75"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_environment.js b/js/world_environment.js index 28b01c5..9b1d0dc 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -835,11 +835,13 @@ || intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top); }, - pathBlockedByFence(x1, y1, x2, y2, padding = 16) { + pathBlockedByFence(x1, y1, x2, y2, padding = 16, opts = {}) { const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; - for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170)) { + const exclude = opts?.exclude || null; + for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) { + if (exclude && rect?.item === exclude) continue; if (rect?.oriented) { const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; @@ -851,6 +853,118 @@ return false; }, + targetBlockedByObstacle(actor, target, padding = 14) { + if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return false; + return Boolean(this.pathBlockedByFence?.(actor.x, actor.y, target.x, target.y, padding, { exclude: target })); + }, + + + pointBlockedByObstacle(x, y, padding = 14, opts = {}) { + const exclude = opts?.exclude || null; + const searchRadius = Math.max(48, Number(padding || 0) + 72); + for (const rect of this.nearbySolidObstacleRects?.(x, y, searchRadius, { exclude, maxChecks: Math.max(10, Number(opts.maxChecks || 0) || 24) }) || []) { + if (exclude && rect?.item === exclude) continue; + if (this.pointInRect?.(x, y, rect, padding)) return true; + } + return false; + }, + + findTarinaiPathWaypoint(actor, target, opts = {}) { + if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null; + const tx = Number(target.x); + const ty = Number(target.y); + const ax = Number(actor.x); + const ay = Number(actor.y); + const targetItem = opts?.targetItem || target?.hostItem || (target?.isStructure || target?.type ? target : null); + const exclude = opts?.exclude || targetItem || null; + const rr = Math.max(8, Number(actor.radius || 20) || 20); + const padding = Math.max(16, Number(opts.padding || 0) || rr * 0.78); + const directClear = !this.pathBlockedByFence?.(ax, ay, tx, ty, padding, { exclude }); + if (directClear) { + actor._pathWaypoint = null; + return null; + } + + const targetId = targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`; + const cached = actor._pathWaypoint || null; + if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0) + && Number.isFinite(cached.x) && Number.isFinite(cached.y) + && !this.pointBlockedByObstacle(cached.x, cached.y, Math.max(6, padding * 0.45), { exclude, maxChecks: 18 }) + && !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) { + return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId }; + } + + const midX = (ax + tx) * 0.5; + const midY = (ay + ty) * 0.5; + const totalD = Math.max(1, Math.hypot(tx - ax, ty - ay)); + const rects = this.nearbySolidObstacleRects?.(midX, midY, totalD * 0.5 + padding + 190, { exclude, maxChecks: Math.max(32, Number(opts.maxChecks || 0) || 46), maxRects: 80 }) || []; + const worldPad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : 28) + rr * 0.45; + const candidates = []; + const add = (x, y, sourceRect, grade = 0) => { + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + if (x < worldPad || y < worldPad || x > this.w - worldPad || y > this.h - worldPad) return; + candidates.push({ x, y, sourceRect, grade }); + }; + const worldPoint = (r, lx, ly) => { + const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0); + const sn = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0); + const cx = Number(r.cx ?? ((Number(r.left || 0) + Number(r.right || 0)) * 0.5)) || 0; + const cy = Number(r.cy ?? ((Number(r.top || 0) + Number(r.bottom || 0)) * 0.5)) || 0; + return { x: cx + lx * c - ly * sn, y: cy + lx * sn + ly * c }; + }; + const addRectCandidates = (r, index = 0) => { + const pad = Math.max(34, padding + rr * 1.45 + 18 + Math.min(18, index * 3)); + if (r.oriented) { + const hw = Math.max(1, Number(r.halfW || 0) || 1) + pad; + const hh = Math.max(1, Number(r.halfH || 0) || 1) + pad; + for (const [lx, ly, grade] of [[-hw, -hh, 0], [hw, -hh, 0], [hw, hh, 0], [-hw, hh, 0], [0, -hh, 1], [hw, 0, 1], [0, hh, 1], [-hw, 0, 1]]) { + const p = worldPoint(r, lx, ly); + add(p.x, p.y, r, grade); + } + } else { + const left = Number(r.left || 0) - pad; + const right = Number(r.right || 0) + pad; + const top = Number(r.top || 0) - pad; + const bottom = Number(r.bottom || 0) + pad; + const cx = (left + right) * 0.5; + const cy = (top + bottom) * 0.5; + for (const [x, y, grade] of [[left, top, 0], [right, top, 0], [right, bottom, 0], [left, bottom, 0], [cx, top, 1], [right, cy, 1], [cx, bottom, 1], [left, cy, 1]]) add(x, y, r, grade); + } + }; + + let blockers = 0; + for (const rect of rects) { + if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor?.(actor, rect.item)) continue; + const inflated = rect?.oriented + ? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding } + : { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding }; + if (!this.segmentIntersectsRect?.(ax, ay, tx, ty, inflated)) continue; + addRectCandidates(rect, blockers); + blockers += 1; + if (blockers >= 4) break; + } + if (!candidates.length) return null; + + let best = null; + let bestScore = Infinity; + const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null; + for (const c of candidates) { + if (this.pointBlockedByObstacle(c.x, c.y, Math.max(5, padding * 0.42), { exclude, maxChecks: 20 })) continue; + const firstBlocked = this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude }); + if (firstBlocked) continue; + const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude }); + const d1 = Math.hypot(c.x - ax, c.y - ay); + const d2 = Math.hypot(tx - c.x, ty - c.y); + const progress = totalD - d2; + const cacheBias = old ? Math.min(90, Math.hypot(c.x - old.x, c.y - old.y) * 0.35) : 0; + const score = d1 + d2 + (secondBlocked ? 780 - progress * 0.45 : 0) + (c.grade || 0) * 26 + cacheBias; + if (score < bestScore) { best = c; bestScore = score; } + } + if (!best) return null; + actor._pathWaypoint = { x: best.x, y: best.y, targetId, until: (this.time || 0) + 0.95 }; + return { x: best.x, y: best.y, dead: false, detour: true, routeTargetId: targetId }; + }, + grassBlockedAt(x, y, self = null, opts = {}) { const ignoreSoft = Boolean(opts?.ignoreSoftPlacement); for (const it of this.nearbyItems(x, y, 96)) { diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 4864d93..d016e12 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -148,6 +148,88 @@ } + + function ensureRobotCleanerEditor() { + let dialog = document.getElementById("robotCleanerEditor"); + if (dialog) return dialog; + dialog = document.createElement("div"); + dialog.id = "robotCleanerEditor"; + dialog.className = "signboard-editor robot-cleaner-editor hidden"; + dialog.innerHTML = ` + `; + document.body.appendChild(dialog); + return dialog; + } + + function openRobotCleanerEditor(robot, worldRef) { + if (!robot || robot.type !== "robot_cleaner") return false; + const api = global.TarinaiItemDynamicToolSystem; + const dialog = ensureRobotCleanerEditor(); + const inputs = [...dialog.querySelectorAll("[data-robot-target]")]; + const closeBtn = dialog.querySelector("#robotCleanerEditorClose"); + const cancel = dialog.querySelector("#robotCleanerCancel"); + const apply = dialog.querySelector("#robotCleanerApply"); + const def = dialog.querySelector("#robotCleanerDefault"); + const highSpeed = dialog.querySelector("#robotCleanerHighSpeed"); + const bits = api?.robotCleanerTargetBits || {}; + const defaultMask = api?.robotCleanerDefaultMask?.() ?? 3; + const readMask = () => inputs.reduce((mask, input) => input.checked ? (mask | (bits[input.dataset.robotTarget] || 0)) : mask, 0); + const writeMask = (mask) => { + for (const input of inputs) input.checked = Boolean((Number(mask) | 0) & (bits[input.dataset.robotTarget] || 0)); + }; + const close = () => { + dialog.classList.add("hidden"); + dialog.__robot = null; + dialog.__worldRef = null; + }; + const commit = (mask) => { + api?.setRobotCleanerMask?.(robot, mask); + api?.setRobotCleanerHighSpeed?.(robot, Boolean(highSpeed?.checked)); + robot.robotCleanerTargetId = ""; + robot.robotCleanerTargetType = ""; + robot.robotCleanerScanTimer = 0; + worldRef?.markItemBucketsDirty?.("robot-cleaner-settings"); + if (worldRef) worldRef.drawListDirty = true; + worldRef?.log?.("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u5bfe\u8c61\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002", "observe"); + if (typeof showToast === "function") showToast("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u3092\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002"); + global.render?.(); + close(); + }; + dialog.__robot = robot; + dialog.__worldRef = worldRef; + writeMask(api?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? defaultMask); + if (highSpeed) highSpeed.checked = Boolean(api?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed); + if (closeBtn) closeBtn.onclick = close; + if (cancel) cancel.onclick = close; + if (def) def.onclick = () => { writeMask(defaultMask); if (highSpeed) highSpeed.checked = false; }; + if (apply) apply.onclick = () => commit(readMask()); + dialog.onclick = (e) => { if (e.target === dialog) close(); }; + dialog.classList.remove("hidden"); + return true; + } + function ensureRotatorEditor() { let dialog = document.getElementById("rotatorEditor"); if (dialog) return dialog; @@ -167,14 +249,14 @@

\u4e2d\u592e\u304c\u56de\u8ee2\u8ef8\u3002\u76f4\u7dda\u30fb\u81ea\u7531\u63cf\u753b\u30fb\u6d88\u3057\u30b4\u30e0\u3092\u9078\u3073\u3001\u30c9\u30e9\u30c3\u30b0\u3067\u8f2a\u90ed\u3092\u7de8\u96c6\u3002

@@ -448,14 +530,14 @@

\u4e2d\u592e\u306e\u8f2a\u90ed\u5168\u4f53\u304c\u3001\u8a2d\u5b9a\u3057\u305f\u65b9\u5411\u3078\u5f80\u5fa9\u79fb\u52d5\u3057\u307e\u3059\u3002\u52d5\u529bOFF\u3067\u306f\u3001\u305f\u308a\u306a\u3044\u30fb\u305a\u3093\u3061\u30fb\u4ed6\u306e\u6a5f\u69cb\u304b\u3089\u62bc\u3055\u308c\u305f\u3068\u304d\u3060\u3051\u52d5\u304d\u307e\u3059\u3002

@@ -769,6 +851,7 @@ 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 === "robot_cleaner") { data.robotCleanerMask = Number(item.robotCleanerMask ?? 3) | 0; data.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); } if (item.type === "rotator") { data.physics = { solid: phyScalar(item, "solid", true) !== false, motorOn: phyScalar(item, "motorOn", true) !== false, motorSpeed: Number(phyScalar(item, "motorSpeed", 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) }; } @@ -784,6 +867,7 @@ function applyCopyBufferToItem(item, data) { if (!item || !data) return item; item.r = Number(data.r || item.r || itemRadiusFor?.(item.type, 12) || 12); + if (item.type === "duplicator") item.r = Math.min(24, Math.max(14, Number(item.r || 24) || 24)); item.amount = Math.max(1, Number(data.amount || item.amount || 1)); if (Number.isFinite(Number(data.seed))) item.seed = Number(data.seed); if (Number.isFinite(Number(data.spin))) item.spin = Number(data.spin); @@ -812,6 +896,14 @@ item.storedFoodLabel = String(data.storedFoodLabel || (item.storedFoodType ? toolLabel(item.storedFoodType) : "")); if (item.roles) item.roles.food = Boolean(item.storedFoodType); } + if (item.type === "robot_cleaner") { + const mask = Number(data.robotCleanerMask); + item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3); + item.robotCleanerHighSpeed = Boolean(data.robotCleanerHighSpeed); + item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60); + item.robotCleanerTargetId = ""; + item.robotCleanerTargetType = ""; + } if (item.type === "rotator") { const ph = data.physics || {}; phySet(item, "solid", ph.solid !== false, "copy"); @@ -882,7 +974,7 @@ function isEditableItem(item) { - return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator"); + return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator"); } function findEditableItemAt(worldRef, x, y) { @@ -906,6 +998,7 @@ function openEditableItemEditor(item, worldRef) { if (!isEditableItem(item)) return false; if (item.type === "signboard") return openSignboardEditor(item, worldRef); + if (item.type === "robot_cleaner") return openRobotCleanerEditor(item, worldRef); if (item.type === "rotator" || item.type === "poison_block") return openRotatorEditor(item, worldRef); if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef); if (item.type === "gate_fence") { @@ -1033,7 +1126,7 @@ for (const soft of items) { const ok = global.TarinaiStructureLifecycle.deleteItem(worldRef, soft, { reason, - userReason: "置きものが重なった", + userReason: "\u7f6e\u304d\u3082\u306e\u304c\u91cd\u306a\u3063\u305f", wake: true, }); if (ok) removed += 1; @@ -1259,6 +1352,7 @@ } if (hit && d < foundItemD) { foundItem = it; foundItemD = d; } } + if (foundItem?.type === "plushie") return foundItem; if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "ball_lodged") { const carrier = (this.items || []).find(it => it && !it.dead && it.type === "ball" && it.id === foundItem.pinBallId); if (carrier) return carrier; @@ -1359,6 +1453,24 @@ return; } } + let plushieTarget = null; + let plushieTargetD = Infinity; + for (let i = this.items.length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || it.type !== "plushie") continue; + const hit = Math.max(34, (it.r || 7) * 4.8); + const d = distXY(x, y, it.x, it.y); + if (d <= hit && d < plushieTargetD) { plushieTarget = it; plushieTargetD = d; } + } + if (plushieTarget && typeof plushieTarget.fling === "function") { + if (plushieTarget.fling(this, x, y, { source: "poke", speed: 720, life: 2.8 })) { + this.effects?.push(new Effect("ring", x, y, { size: 24, life: 0.18, color: "rgba(190,142,92,0.34)" })); + this.drawListDirty = true; + audio.poke?.(); + return; + } + } + let ballTarget = null; let ballTargetD = Infinity; for (let i = this.items.length - 1; i >= 0; i--) { diff --git a/js/world_view.js b/js/world_view.js index 7ed29c9..c9678db 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -35,10 +35,6 @@ return this.events?.emit?.(type, { ...detail, world: this }) || null; }, - on(type, handler) { - return this.events?.on?.(type, handler) || (() => {}); - }, - markSpatialDirty(reason = "manual") { this.spatialDirty = true; this.spatialDirtyReason = reason; diff --git a/scripts/regression_check.py b/scripts/regression_check.py index 303acb5..a345a43 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -247,7 +247,6 @@ def check_social_action_spec_runtime() -> None: source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"]) required_factories = [ "createPanicEscapeActionSpec", - "createFleeActionSpec", "createApproachFriendActionSpec", "createApproachFamilyActionSpec", "createApproachMateActionSpec", @@ -269,7 +268,6 @@ def check_social_action_spec_runtime() -> None: fail("configureTarinaiNeedActions should not be needed after ActionSpec runtime consolidation") forbidden_late_ticks = [ 'set("panic_escape",', - 'set("flee",', 'set("approach_friend",', 'set("approach_parent_or_child",', 'set("approach_mate",', @@ -615,7 +613,6 @@ def check_processing_split() -> None: (social, "function updateMateBehavior"), (social, "function updatePanicBehavior"), (building, "function continueBuildPlan"), - (needs, "function chooseActionForNeed"), ] for source, token in required_pairs: if token not in source: diff --git a/service-worker.js b/service-worker.js index 1a9deb2..4a961ad 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "39.15.65"; +const APP_VERSION = "39.15.75"; 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.