From 0e432c62ae7c86502035fbf7285aa77d180af2da Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 28 Jun 2026 23:07:40 +0900 Subject: [PATCH] hm --- FILES.json | 2 +- GAME_FEATURES.md | 171 +++++++ README.md | 1 + app_manifest.json | 21 +- css/components.css | 55 +- css/layout.css | 3 +- css/mobile.css | 56 +- css/panel.css | 24 +- index.html | 312 ++++++------ js/collision_footprint_system.js | 29 +- js/collision_response_system.js | 423 ++++++++++++++++ js/colony_situation_system.js | 95 ++++ js/constraint_system.js | 89 ++-- js/deterministic_helpers.js | 45 ++ js/history_system.js | 8 +- js/impact_core_system.js | 52 ++ js/impact_response_system.js | 118 +++++ js/input_mode_manager.js | 46 -- js/item_dynamic_ball_system.js | 2 + js/item_dynamic_pin_system.js | 254 +++++++--- js/item_effect_definitions.js | 282 +++++++++++ js/item_food_definitions.js | 59 +++ js/item_lifecycle_legacy_system.js | 91 +--- js/item_lifecycle_step_legacy.js | 12 +- js/item_registry.js | 758 ++-------------------------- js/item_render_runtime.js | 45 +- js/item_tool_definitions.js | 255 ++++++++++ js/item_type_initializers.js | 6 +- js/item_update_scheduler.js | 3 +- js/item_visual_definitions.js | 131 +++++ js/mechanical_shape_bridge.js | 32 ++ js/mechanical_system.js | 91 +++- js/physics_helpers.js | 4 + js/physics_projection_system.js | 57 +++ js/physics_shape_editor_system.js | 70 +++ js/physics_world_system.js | 8 +- js/pin_attachment_system.js | 130 +++++ js/placement_collision_system.js | 41 ++ js/placement_preview_system.js | 204 ++++++++ js/render.js | 189 +++---- js/restore_coordinator.js | 5 +- js/save_codec.js | 216 ++------ js/save_storage.js | 3 +- js/save_system.js | 75 +-- js/sim_core.js | 90 ++-- js/snapshot_system.js | 1 - js/structure_lifecycle.js | 43 +- js/structures.js | 9 +- js/tarinai.js | 29 -- js/tarinai_action_definitions.js | 12 +- js/tarinai_behavior_state.js | 10 +- js/tarinai_behavior_text.js | 21 +- js/tarinai_building_behavior.js | 78 ++- js/tarinai_disease_nest.js | 243 +++++++-- js/tarinai_forced_behavior.js | 14 +- js/tarinai_item_targeting.js | 65 +-- js/tarinai_needs_items.js | 33 +- js/tarinai_nest_sleep_system.js | 151 ++++++ js/tarinai_social_action_runtime.js | 29 -- js/tarinai_social_move_life.js | 31 +- js/tarinai_update_legacy_system.js | 239 +-------- js/tarinai_update_policy.js | 28 +- js/tarinai_update_step_frame.js | 69 ++- js/tarinai_update_step_legacy.js | 10 +- js/tarinai_update_step_movement.js | 12 +- js/ui.js | 3 +- js/ui_bind.js | 115 ++++- js/ui_charts.js | 4 +- js/ui_family_layout.js | 14 +- js/ui_family_render.js | 4 +- js/ui_input_mouse.js | 96 +--- js/ui_input_shared.js | 38 +- js/ui_input_touch.js | 72 +-- js/ui_log.js | 21 +- js/ui_pointer_action_system.js | 122 +++++ js/ui_tools.js | 4 +- js/version.js | 4 +- js/world_combat_effects.js | 498 +++--------------- js/world_environment.js | 95 +++- js/world_family_social.js | 33 +- js/world_placement_log.js | 252 ++++----- js/world_tool_actions.js | 59 ++- js/world_update.js | 70 +-- scripts/ai_inventory.ps1 | 1 + scripts/ai_inventory.py | 1 + scripts/regression_check.py | 66 ++- service-worker.js | 4 +- 87 files changed, 4291 insertions(+), 3075 deletions(-) create mode 100644 GAME_FEATURES.md create mode 100644 js/collision_response_system.js create mode 100644 js/colony_situation_system.js create mode 100644 js/deterministic_helpers.js create mode 100644 js/impact_core_system.js create mode 100644 js/impact_response_system.js create mode 100644 js/item_effect_definitions.js create mode 100644 js/item_food_definitions.js create mode 100644 js/item_tool_definitions.js create mode 100644 js/item_visual_definitions.js create mode 100644 js/mechanical_shape_bridge.js create mode 100644 js/physics_projection_system.js create mode 100644 js/physics_shape_editor_system.js create mode 100644 js/pin_attachment_system.js create mode 100644 js/placement_collision_system.js create mode 100644 js/placement_preview_system.js create mode 100644 js/tarinai_nest_sleep_system.js create mode 100644 js/ui_pointer_action_system.js diff --git a/FILES.json b/FILES.json index 3e7d929..0abb210 100644 --- a/FILES.json +++ b/FILES.json @@ -17,7 +17,7 @@ "groups": [ { "id": "root", - "patterns": [".gitignore", ".htaccess", "app_manifest.json", "favicon.ico", "index.html", "README.md", "AI_MAP.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." }, { diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md new file mode 100644 index 0000000..9619b93 --- /dev/null +++ b/GAME_FEATURES.md @@ -0,0 +1,171 @@ +# tarinai_ Game Features + +This document describes the playable features of `tarinai_`: what the player sees, what tools do, and how tarinai behave. It is intentionally player/design-facing, not an implementation map. + +## Game Overview + +`tarinai_` is an observation sandbox about small creatures called tarinai. The player places food, furniture, hazards, machines, and environment tools, then watches how individuals live, move, eat, sleep, panic, fight, reproduce, get sick, recover, and leave family records. + +The core loop is: + +1. Choose a field and observe the colony. +2. Place tools or objects that change the environment. +3. Select individual tarinai to inspect health, needs, personality, relations, diseases, records, and current behavior. +4. Watch the event log, colony graphs, and family tree to understand long-term outcomes. + +## Main UI + +- **Canvas / field**: the main simulation area. Tarinai, items, effects, ants, weather, and terrain are drawn here. +- **Top buttons**: pause/resume, speed control, ecology guide, sound settings, credits, save, and reset. +- **Mobile mode controls**: auto, camera, and tool modes for touch screens. +- **Right panel tabs**: jump between main game, selected tarinai data, tools, colony, events, and family tree. +- **Tarinai data panel**: appears when a tarinai is selected. Shows health, needs, personality, active effects, relations, inventory, behavior reason, and records. +- **Tool palette**: grouped buttons for operation, food, medicine, habitat, physics, and danger tools. +- **Colony panel**: shows population, deaths, colony mood, ground type, and charts for population, needs, and objects. +- **Event panel**: logs births, deaths, accidents, fights, food events, weather, grass, and relation changes. Optional push-style notifications can be enabled by event type. +- **Family tree**: shows parent-child generations, inherited stats, personality tags, family links, and archived lineage records. +- **Ecology dialog**: in-game guide explaining tarinai needs, stress, food, sleep, personality, family, disease, accidents, tools, and colony reading. +- **Sound menu**: master sound plus voice, notification, and operation categories. + +## Tool Categories + +### Operation + +- **解除 / clear**: cancels the selected tool and returns to normal observe mode. +- **たりないデータ / observe**: select a tarinai or inspect compatible objects such as signboards. +- **追加 / new**: calls a new tarinai into the field. +- **つつく / poke**: pokes tarinai and can push balls. +- **つまむ / pinch**: drags tarinai and some placed objects. +- **コピー / copy**: copies a placed item and pastes a new instance with its settings. +- **戻す / undo** and **進む / redo**: reverse or reapply placement/edit actions. +- **削除 / delete**: removes placed tools, objects, and stains. +- **範囲削除 / area delete**: removes many nearby objects or stains around the cursor. +- **洗浄 / water hose**: drag to clean dirt, zunchi, corpses, and stains. + +### Food + +- **ずんだ餅 / sweet**: favored food; can cure some illness. +- **へこ餅 / love mochi**: makes reproduction behavior more likely for a while. +- **けんか餅 / fight mochi**: makes fighting behavior more likely for a while. +- **草 / grass**: food and comfort source; can grow and be used for simple beds. +- **水滴 / water**: drinkable water, also appears during rain and can be set in duplicators. +- **ずんだ汁 / zunda juice**: clears item effects from a tarinai. +- **複製機 / duplicator**: can be loaded with compatible food, medicine, grass, water, pushpins, or oshibyo and then produces or applies that content. + +### Medicine + +- **ねむり薬 / sleep drug**: causes sleepiness or sleep disease; damage can cure the disease. +- **下剤 / laxative**: causes repeated zunchi and reduces food benefit. +- **プロテイン / protein**: permanent power mode; more damage dealt, lower disease chance. +- **ンイテロプ / niteropu**: permanent weak mode; less damage dealt, higher disease chance. +- **弾薬 / ammo**: temporary high-speed movement and strong collision impact. +- **怪しい薬 / mystery drug**: randomly applies an eligible item effect or disease. +- **水銀 / mercury**: increases lifespan multiplier on each use. +- **巨大薬 / giant drug**: greatly increases size, attack, and speed, but deals ongoing damage. +- **矮小薬 / dwarf drug**: greatly decreases size, attack, and speed, and also deals ongoing damage. + +### Habitat + +- **ずんち / zunchi**: tarinai waste; affects disease and can fertilize grass. +- **スプリンクラー / sprinkler**: cleans one target at a time in its effective radius. +- **干草寝床 / 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. +- **看板 / signboard**: editable text object; inspect with observe after placing. + +### Physics + +- **ヒモ / rope**: connect two targets with a flexible link. +- **棒 / rod**: connect two targets with a rigid link. +- **柵 / fence**: blocks tarinai movement; Q/E rotate by 45 degrees, Shift+Q/E by 5 degrees. +- **バウンス柵 / bounce fence**: fence that rebounds things that hit it. +- **ゲート柵 / gate fence**: click to open or close. +- **回転体 / rotator**: custom mechanical shape that rotates; click to edit shape and speed. +- **毒ブロック / poison block**: custom hazard block; touching it damages skin; click to edit shape. +- **往復体 / reciprocator**: custom mechanical shape that moves back and forth; click to edit shape and speed. + +### Danger + +- **石 / stone**: heavy object; dangerous when dropped or moved into others. +- **げんこつ / genkotsu**: large fist impact on the ground. +- **爆竹 / firecracker**: creates an explosion. +- **画鋲 / pushpin**: rolls when dropped; can lodge in tarinai and cause panic plus continuous damage. +- **おしり鋲 / oshibyo**: blocks zunchi when lodged; pulling it out releases stored zunchi. +- **射撃 / shoot**: attacks with a gun effect. +- **アリの巣 / ant nest**: spawns ants that search for tarinai and carry them to the nest. + +## Tarinai Needs And Stats + +Tarinai behavior is shaped by health, energy, needs, stress, personality, family, disease, and nearby objects. + +Key needs include: + +- **Food / hunger**: hungry tarinai seek grass, sweets, food, water-related options, or other edible targets. +- **Sleep**: sleepy tarinai seek beds, nest boxes, grass beds, or eventually sleep anywhere. +- **Safety**: danger, pain, hazards, ants, fights, and panic increase defensive behavior. +- **Social / relations**: tarinai can seek friends, follow parents, avoid enemies, fight rivals, or approach mates. +- **Fulfillment / comfort**: toys, ground, sun, shelter, and environment affect mood and stress. +- **Health**: damage, disease, stress, and recovery decide whether tarinai live, weaken, or die. + +Stress changes with fear, crowding, dirt, disease, fights, ground type, and environment. High stress makes fleeing, intimidation, sickness, and poor behavior more likely. + +Personality and genetics affect tendencies such as aggression, openness, sociability, nervousness, body size, lifespan, damage, and speed. Some traits are inherited by children and shown in the family tree. + +## Movement And Behavior + +Tarinai are autonomous. Their movement is usually a result of current state, target choice, and local environment. + +Common behavior states: + +- **Idle / wander**: waits, watches, or moves lightly around the field. +- **Seek food**: moves toward edible items when hungry or affected by disease. +- **Eat / drink**: consumes food, grass, water, medicine, or special items. +- **Seek bed / sleep**: finds a bed, nest box, grass bed, or sleeps in place. +- **Play ball**: approaches and interacts with balls. +- **Seek material / build**: gathers material and creates simple structures such as grass beds. +- **Seek friend / follow parent**: moves toward social targets or parents. +- **Fight / seek enemy**: approaches and attacks rivals, especially under fight effects or aggression. +- **Panic / flee**: runs from danger, pain, destroyed owned objects, hazards, or fear triggers. +- **Intimidate**: threatens another tarinai or ants instead of directly fleeing or attacking. +- **Birth ritual**: pre-birth/reproduction interaction with a partner. +- **Sunbath**: rests in good sun to reduce stress. +- **Disease states**: zunchi sickness, sleep disease, explosion disease, and injury sickness can override normal priorities. +- **Frozen**: outside-field preservation state. + +Tarinai can be pushed by physics objects, carried or affected by ants, dragged by the player, attached with ropes/rods, blocked by fences, and damaged by hazards. Some objects become owned or emotionally important, so losing them can trigger panic. + +## Environment + +- **Field types**: park, garden, and cage provide different field sizes. +- **Ground types**: sand, dirt, concrete, blanket, foot massage, ice, and laboratory-style ground affect stress, grass, and movement. +- **Weather**: rain and other weather influence water, mood, and environment effects. +- **Grass ecology**: grass grows, feeds tarinai, comforts them, and can become bed material. +- **Dirt and corpses**: zunchi, splats, corpses, and ant corpses affect hygiene, disease, and observation results. +- **Day/light conditions**: lighting and sun influence rendering and sunbath behavior. + +## Disease And Recovery + +Diseases and item effects can strongly redirect behavior. + +- **Zunchi disease**: changes food/water priorities and makes the tarinai feel unwell. +- **Sleep disease**: causes sleep-focused behavior. +- **Explosion disease**: creates dangerous explosive outcomes and water-seeking pressure. +- **Fight disease / injury sickness**: causes unstable movement or fighting-related behavior. +- **Item effects**: mochi, medicine, size drugs, mercury, ammo, and power modes create temporary or permanent modifiers. + +Recovery depends on the disease or effect. Water, rest, food, medicine, zunda juice, damage, environment cleanup, or time can all matter depending on the case. + +## Family, Records, And Colony Observation + +- Tarinai can form parent-child records and partner links. +- Children inherit parts of their parents' tendencies and are grouped by generation. +- Family tree nodes show portraits, inherited stats, personality tags, and relationship structure. +- Event logs record important simulation events such as birth, death, accidents, fights, weather, grass, and relation changes. +- Colony charts summarize population, needs, and object counts over time. +- Selected tarinai records help explain why an individual is acting a certain way. + +## Save, Reset, And Persistence + +- The app supports manual save and restore through browser storage. +- Reset opens field selection and starts a new field state. +- Service worker caching allows the static app shell to remain available after load, depending on browser support. diff --git a/README.md b/README.md index 36d7f2d..05dd14f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; ## Read Strategy - Start here for architecture, script order constraints, and where to look next. +- For game mechanics, UI, items, and tarinai behavior, read `GAME_FEATURES.md`. - For subsystem work, read `AI_MAP.md` plus only the matching files. - For exact file inventory, run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All` or narrow it: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 ui`. Python equivalent: `python scripts/ai_inventory.py --all`. - For machine-readable routing, read `FILES.json`. diff --git a/app_manifest.json b/app_manifest.json index f625184..46c3a39 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.15.47", + "version": "39.15.63", "css": [ "css/base.css", "css/layout.css", @@ -13,16 +13,27 @@ "js/registry_base.js", "js/disease_registry.js", "js/sound_pack.js", + "js/deterministic_helpers.js", + "js/item_tool_definitions.js", + "js/item_visual_definitions.js", + "js/item_food_definitions.js", + "js/item_effect_definitions.js", "js/item_registry.js", "js/data.js", "js/ground_types.js", "js/input_mode_manager.js", "js/math.js", - "js/physics_helpers.js", "js/collision_footprint_system.js", + "js/physics_helpers.js", + "js/placement_collision_system.js", + "js/placement_preview_system.js", + "js/pin_attachment_system.js", + "js/physics_shape_editor_system.js", "js/mechanical_system.js", + "js/mechanical_shape_bridge.js", "js/constraint_system.js", "js/physics_world_system.js", + "js/physics_projection_system.js", "js/assets.js", "js/audio.js", "js/perf_profiler.js", @@ -63,6 +74,7 @@ "js/tarinai_needs_core.js", "js/tarinai_behavior_text.js", "js/tarinai_forced_behavior.js", + "js/tarinai_nest_sleep_system.js", "js/tarinai_item_targeting.js", "js/tarinai_consumable_behavior.js", "js/tarinai_social_action_runtime.js", @@ -92,6 +104,9 @@ "js/world_view.js", "js/family_graph.js", "js/world_family_social.js", + "js/impact_core_system.js", + "js/impact_response_system.js", + "js/collision_response_system.js", "js/world_combat_effects.js", "js/world_environment.js", "js/world_spatial_budget.js", @@ -110,6 +125,7 @@ "js/world_update_phases.js", "js/system_order.js", "js/simulation.js", + "js/colony_situation_system.js", "js/world_update.js", "js/world_tool_actions.js", "js/world_placement_log.js", @@ -133,6 +149,7 @@ "js/ui_ground.js", "js/ui_tools.js", "js/ui_input_shared.js", + "js/ui_pointer_action_system.js", "js/ui_input_touch.js", "js/ui_input_mouse.js", "js/ui_bind.js", diff --git a/css/components.css b/css/components.css index 0e998ff..58b9abf 100644 --- a/css/components.css +++ b/css/components.css @@ -419,6 +419,7 @@ .log-push-toast.log-push-food::before { background: #e9bd61; } .log-push-toast.log-push-weather::before { background: #7bbbdc; } .log-push-toast.log-push-grass::before { background: #8cc77a; } +.log-push-toast.log-push-event::before { background: #8b82d6; } .log-push-toast.leaving { animation: logPushOut .18s ease both; } @@ -684,60 +685,6 @@ outline: 2px solid rgba(103,178,44,0.58); outline-offset: 2px; } -.save-hash-profile { - display: grid; - gap: 8px; - padding: 9px 10px; - border: 1px solid rgba(115, 91, 62, 0.18); - border-radius: 12px; - background: rgba(255,255,255,0.56); - color: #4a3929; - font-size: 12px; -} -.save-hash-profile:empty { - display: none; -} -.save-profile-summary { - display: flex; - flex-wrap: wrap; - gap: 6px 10px; -} -.save-profile-summary span { - padding: 3px 7px; - border-radius: 999px; - background: rgba(115, 91, 62, 0.08); -} -.save-profile-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); - gap: 5px; -} -.save-profile-grid > div { - display: grid; - grid-template-columns: 1fr auto auto; - gap: 6px; - align-items: center; - padding: 4px 6px; - border-radius: 8px; - background: rgba(255,255,255,0.54); -} -.save-profile-grid em { - opacity: 0.62; - font-style: normal; -} -.save-profile-items { - display: flex; - flex-wrap: wrap; - gap: 5px 7px; - align-items: center; -} -.save-profile-items span { - padding: 2px 6px; - border-radius: 999px; - background: rgba(111, 153, 102, 0.13); -} - - /* v36: pin only the operation tool category under the quick-scroll tabs. */ .tool-category[data-tool-category="operate"]:not(.collapsed) { position: sticky; diff --git a/css/layout.css b/css/layout.css index 7c24ae7..44ec8d3 100644 --- a/css/layout.css +++ b/css/layout.css @@ -11,6 +11,7 @@ .tool[data-tool="poke"] { --tool-emoji: "\1F448"; } .tool[data-tool="pinch"] { --tool-emoji: "\1F90F"; } .tool[data-tool="water_hose"] { --tool-emoji: "\1F6BF"; } +.tool[data-tool="clear_tool"] { --tool-emoji: "\2715"; } .tool[data-tool="copy"] { --tool-emoji: "\29c9 "; } .tool[data-tool="area_delete"] { --tool-emoji: "\d7 "; } .tool[data-tool="rope"] { --tool-emoji: "\2570 "; } @@ -322,6 +323,7 @@ meter { .log-weather { border-left-color: rgba(92, 142, 210, 0.62); } .log-relation { border-left-color: rgba(204, 126, 176, 0.62); } .log-observe { border-left-color: rgba(175, 146, 86, 0.62); } +.log-event { border-left-color: rgba(132, 120, 210, 0.62); } @media (max-width: 980px) { #app { padding: 10px; } @@ -362,4 +364,3 @@ meter { @media (min-width: 921px) { .mobile-mode-controls { display: none !important; } } - diff --git a/css/mobile.css b/css/mobile.css index 877dd25..f671237 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -31,6 +31,7 @@ .log-push-toast.log-push-food::before { background: #e9bd61; } .log-push-toast.log-push-weather::before { background: #7bbbdc; } .log-push-toast.log-push-grass::before { background: #8cc77a; } +.log-push-toast.log-push-event::before { background: #8b82d6; } .log-push-toast.leaving { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; } @keyframes logPushIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @@ -164,6 +165,46 @@ } body.touch-mobile-ui .mobile-mode-controls:not([hidden]) { display: flex; } body:not(.touch-mobile-ui) .mobile-mode-controls { display: none !important; } + +.mobile-rotate-controls { + display: none; + position: absolute; + right: 10px; + bottom: 58px; + z-index: 24; + gap: 6px; + padding: 6px; + border-radius: 14px; + background: rgba(255, 251, 245, 0.92); + box-shadow: 0 8px 22px rgba(80,60,35,.18); + border: 1px solid rgba(120, 95, 60, .16); + backdrop-filter: blur(8px); +} +.mobile-rotate-controls button { + border: 0; + border-radius: 10px; + padding: 7px 10px; + font-size: 12px; + font-weight: 800; + color: #65533f; + background: rgba(255,255,255,.82); + box-shadow: inset 0 -1px 0 rgba(0,0,0,.05); +} +.mobile-rotate-controls button:active { transform: translateY(1px); } +body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) { display: flex; } +body:not(.touch-mobile-ui) .mobile-rotate-controls { display: none !important; } +@media (max-width: 980px) { + body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) { + position: fixed; + left: 50%; + right: auto; + bottom: calc(env(safe-area-inset-bottom, 0px) + 56px); + transform: translateX(-50%); + z-index: 12002; + max-width: calc(100vw - 22px); + } +} + body.touch-mobile-ui.mobile-mode-camera #gameCanvas { cursor: grab; } body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; } @@ -249,10 +290,9 @@ body.touch-mobile-ui .tool-tip-popover { /* v39.15.38: keep desktop layout close to v39.15.36; only hide the PC quick-scroll buttons that duplicate the canvas/tool card. */ @media (min-width: 981px) { .panel-quick-tabs { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); } - .panel-quick-tabs [data-panel-tab="game"], - .panel-quick-tabs [data-panel-tab="tools"] { + .panel-quick-tabs [data-panel-tab="game"] { display: none !important; } } @@ -402,10 +442,9 @@ body.touch-mobile-ui .tool-tip-popover { line-height: 1.15; } .panel-quick-tabs { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); } - .panel-quick-tabs [data-panel-tab="game"], - .panel-quick-tabs [data-panel-tab="tools"] { + .panel-quick-tabs [data-panel-tab="game"] { display: none !important; } } @@ -474,10 +513,9 @@ body.touch-mobile-ui .tool-tip-popover { .panel-quick-tabs { position: sticky; top: 0; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); } - .panel-quick-tabs [data-panel-tab="game"], - .panel-quick-tabs [data-panel-tab="tools"] { + .panel-quick-tabs [data-panel-tab="game"] { display: none !important; } .lineage-main-card { diff --git a/css/panel.css b/css/panel.css index 6cd69fd..222c8d4 100644 --- a/css/panel.css +++ b/css/panel.css @@ -24,6 +24,14 @@ } .lineage-head h2 { margin-bottom: 0; } +.lineage-title-note { + display: inline-block; + margin-left: 10px; + font-size: 12px; + font-weight: 700; + color: rgba(126, 86, 52, 0.78); + vertical-align: middle; +} .lineage-main-content { height: min(78vh, 980px); @@ -125,8 +133,8 @@ body::before { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px 5px; margin-top: 2px; - font-size: 9px; - line-height: 1.12; + font-size: 9.7px; + line-height: 1.13; color: rgba(88, 70, 48, 0.86); letter-spacing: -0.02em; white-space: normal; @@ -147,8 +155,8 @@ body::before { display: block; margin-top: 1px; max-width: 82px; - font-size: 9px; - line-height: 1.12; + font-size: 9.7px; + line-height: 1.13; color: rgba(88, 70, 48, 0.82); white-space: nowrap; overflow: hidden; @@ -212,15 +220,15 @@ body::before { 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: 7.5px; + font-size: 8.2px; font-style: normal; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.family-copy strong { font-size: 11px; } -.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; } +.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; } @@ -440,7 +448,7 @@ body::before { width: 54px; padding: 3px 0; border-radius: 999px; - font-size: 10px; + font-size: 11px; color: var(--muted); text-align: center; background: rgba(255,255,255,0.70); diff --git a/index.html b/index.html index d5ecc3d..74626b8 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -37,11 +37,11 @@
- - + @@ -118,6 +122,7 @@ +
@@ -130,7 +135,7 @@
-

家系図

+

家系図重い場合があるので注意

@@ -196,141 +201,158 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index 7991cdc..1288e5e 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -161,6 +161,18 @@ return Math.max(14, num(item.r, radiusFor(item.type, 12)) || radiusFor(item.type, 12)); } + function isSoftPlacementIgnoredType(type = "") { + const key = String(type || ""); + return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water"; + } + + function placementRectsFor(worldRef, item) { + const solid = worldRef?.solidObstacleRects?.(item) || []; + if (solid.length) return solid; + if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item?.type)) return global.TarinaiMechanicalSystem?.placementRects?.(item) || []; + return solid; + } + function hitTestItem(worldRef, item, x, y, opts = {}) { if (!item || item.dead) return { hit: false, distance: Infinity }; const padding = Math.max(0, num(opts.padding, 0)); @@ -188,24 +200,17 @@ function itemPlacementOverlapBlocked(worldRef, item, opts = {}) { if (!worldRef || !item || item.dead) return true; - const important = opts.important || new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "water"]); + const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box"]); const isPhysicsItem = (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type))); if (!important.has(item.type) && !isPhysicsItem) return false; - const placingGrass = item.type === "grass"; - const itemRects = worldRef.solidObstacleRects?.(item) || []; + const itemRects = placementRectsFor(worldRef, item); const itemRadius = itemFootprintRadius(worldRef, item, itemRects); const search = Math.max(132, itemRadius * 3.2); for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) { if (!other || other === item || other.dead) continue; - if (isPhysicsItem && (other.type === "water" || other.type === "grass_bed")) continue; - if (other.type === "poison_block") { - const body = global.TarinaiPhysicsBodySystem?.ensureBody?.(other, worldRef, { syncFromLegacy: false }); - const solid = body?.collision ? body.collision.solid !== false : true; - if (!solid) continue; - } + if (isSoftPlacementIgnoredType(other.type)) continue; if (!important.has(other.type)) continue; - if (!placingGrass && other.type === "grass") continue; - const otherRects = worldRef.solidObstacleRects?.(other) || []; + const otherRects = placementRectsFor(worldRef, other); const otherRadius = itemFootprintRadius(worldRef, other, otherRects); const hasMechanism = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || global.TarinaiMechanicalSystem?.isMechanicalType?.(other.type); const itemIsFence = typeof global.isFenceItemType === "function" ? global.isFenceItemType(item.type) : /fence/.test(String(item.type || "")); @@ -240,6 +245,8 @@ rectsOverlap, rectCircleOverlap, itemFootprintRadius, + isSoftPlacementIgnoredType, + placementRectsFor, hitTestItem, itemPlacementOverlapBlocked, }); diff --git a/js/collision_response_system.js b/js/collision_response_system.js new file mode 100644 index 0000000..d636d74 --- /dev/null +++ b/js/collision_response_system.js @@ -0,0 +1,423 @@ +"use strict"; + +(function (global) { + const ImpactCore = global.TarinaiImpactCoreSystem || {}; + const PHYSICAL_DAMAGE_SPEED_THRESHOLD = ImpactCore.PHYSICAL_DAMAGE_SPEED_THRESHOLD || 165; + const physicalDamageFromImpactSpeed = ImpactCore.physicalDamageFromImpactSpeed; + const itemHasAttachedLink = ImpactCore.itemHasAttachedLink; + + function pairKey(a, b) { + const aid = String(a?.id ?? "a"); + const bid = String(b?.id ?? "b"); + return aid < bid ? `${aid}:${bid}` : `${bid}:${aid}`; + } + + function normalizeVector(x, y, fallback = null) { + const d = Math.hypot(Number(x) || 0, Number(y) || 0); + if (d > 0.0001) return { x: x / d, y: y / d, d }; + if (fallback) return { x: fallback.x || 1, y: fallback.y || 0, d: 1 }; + return { x: 1, y: 0, d: 1 }; + } + + function separatePair(worldRef, a, b, nx, ny, overlap, aWeight = 0.5, bWeight = 0.5) { + const amount = Math.max(0, Number(overlap || 0) || 0); + if (!worldRef || amount <= 0 || !a || !b) return false; + a.x = clamp((a.x || 0) - nx * amount * aWeight, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); + a.y = clamp((a.y || 0) - ny * amount * aWeight, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); + b.x = clamp((b.x || 0) + nx * amount * bWeight, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); + b.y = clamp((b.y || 0) + ny * amount * bWeight, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); + return true; + } + + + function resolveBallBallCollisions(worldRef, dt) { + if (!worldRef) return undefined; + return (function resolveBallBallCollisionsUnified(dt) { + if ((this.itemCounts?.ball || 0) < 2) return; + const now = this.time || 0; + if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map(); + const balls = this.itemsOfType?.("ball") || (this.items || []).filter(it => it && !it.dead && it.type === "ball"); + for (const a of balls) { + if (!a || a.dead || a.type !== "ball") continue; + const ar = a.r || 18; + const avx = a.vx || 0; + const avy = a.vy || 0; + const aSpeed = Math.hypot(avx, avy); + const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; + for (const b of this.nearbyItems(a.x, a.y, range) || []) { + if (!b || b === a || b.dead || b.type !== "ball") continue; + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue; + const br = b.r || 18; + const hitRadius = ar + br; + let dx = b.x - a.x; + let dy = b.y - a.y; + let d = Math.hypot(dx, dy); + let hitX = a.x; + let hitY = a.y; + if (d > hitRadius) { + const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; + const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; + const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; + const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; + const rvx = (a.x - ax0) - (b.x - bx0); + const rvy = (a.y - ay0) - (b.y - by0); + const rx = ax0 - bx0; + const ry = ay0 - by0; + const aa = rvx * rvx + rvy * rvy; + if (aa <= 0.0001) continue; + const bb = 2 * (rx * rvx + ry * rvy); + const cc = rx * rx + ry * ry - hitRadius * hitRadius; + const disc = bb * bb - 4 * aa * cc; + if (disc < 0) continue; + const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1); + const axHit = ax0 + (a.x - ax0) * u; + const ayHit = ay0 + (a.y - ay0) * u; + const bxHit = bx0 + (b.x - bx0) * u; + const byHit = by0 + (b.y - by0) * u; + dx = bxHit - axHit; + dy = byHit - ayHit; + d = Math.hypot(dx, dy); + hitX = (axHit + bxHit) * 0.5; + hitY = (ayHit + byHit) * 0.5; + if (d > hitRadius + 0.5) continue; + } else { + hitX = (a.x + b.x) * 0.5; + hitY = (a.y + b.y) * 0.5; + } + if (d < 0.001) { + const rvx = (b.vx || 0) - (a.vx || 0); + const rvy = (b.vy || 0) - (a.vy || 0); + const rs = Math.hypot(rvx, rvy); + if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } + else { const ang = deterministicAngle(this, "ball-ball-overlap-normal", a, b); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } + } + const nx = dx / d; + const ny = dy / d; + const rvx = (b.vx || 0) - (a.vx || 0); + const rvy = (b.vy || 0) - (a.vy || 0); + const relNormal = rvx * nx + rvy * ny; + if (relNormal > 0 && d >= hitRadius - 0.5) continue; + this.ballCollisionMemo.set(key, now); + const restitution = 0.86; + const impulse = -(1 + restitution) * relNormal / 2; + if (Number.isFinite(impulse)) { + a.vx = (a.vx || 0) - impulse * nx; + a.vy = (a.vy || 0) - impulse * ny; + b.vx = (b.vx || 0) + impulse * nx; + b.vy = (b.vy || 0) + impulse * ny; + } + const overlap = Math.max(0, hitRadius - d + 0.6); + if (overlap > 0) { + a.x -= nx * overlap * 0.5; + a.y -= ny * overlap * 0.5; + b.x += nx * overlap * 0.5; + b.y += ny * overlap * 0.5; + } + const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0)); + a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38); + b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38); + this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" })); + } + } + + }).call(worldRef, dt); + } + + + function resolveBallInteractions(worldRef, dt) { + if (!worldRef) return undefined; + return (function resolveBallInteractionsUnified(dt) { + if (!this.itemCounts?.ball) return; + for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) { + if (!ball || ball.dead || ball.type !== "ball") continue; + const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); + const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84; + const range = (ball.r || 18) + 72 + sweep; + const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x; + const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y; + const sx = ball.x - px; + const sy = ball.y - py; + const segLenSq = sx * sx + sy * sy; + for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { + if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue; + let hitX = ball.x; + let hitY = ball.y; + if (segLenSq > 1) { + const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1); + hitX = px + sx * u; + hitY = py + sy * u; + } + let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y)); + const hitDistance = (ball.r || 18) + t.radius * 0.74; + if (d > hitDistance) continue; + const now = this.time || 0; + const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18; + if (repeat && ballSpeed < 300) continue; + let nx = (hitX - t.x) / d; + let ny = (hitY - t.y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + if (ballSpeed > 0.01) { + nx = (ball.vx || 0) / ballSpeed; + ny = (ball.vy || 0) / ballSpeed; + } else { + nx = t.facingDir ? t.facingDir() : 1; + ny = deterministicRange(this, "ball-interaction-fallback-ny", -0.22, 0.22, ball, t); + } + } + + const transferredPins = globalThis.TarinaiItemDynamicPinSystem?.transferBallPinsToTarinai?.(ball, t, this) || 0; + if (transferredPins > 0) { + ball.vx = (ball.vx || 0) * 0.72 - nx * Math.min(78, Math.max(18, ballSpeed * 0.06)); + ball.vy = (ball.vy || 0) * 0.72 - ny * Math.min(78, Math.max(18, ballSpeed * 0.06)); + ball.spinVelocity = clamp((ball.spinVelocity || 0) * 0.72 + (nx >= 0 ? -1 : 1) * 5.2, -38, 38); + ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding); + ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding); + ball.lastKickedAt = now; + ball.lastKickerId = t.id; + this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(18, ball.r * 1.05), life: 0.20, color: "rgba(196,65,72,0.46)" })); + if (this.relationNotice(t.id, ball.id || "ball", "ball-pin-transfer", 1.2)) this.log(`${t.name}にボールから鋲が移った。`, "accident", { participants: [t] }); + continue; + } + + const constrainedBall = itemHasAttachedLink(this, ball); + const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016); + const moveVx = sx / frameDt; + const moveVy = sy / frameDt; + const effectiveVx = Math.abs(ball.vx || 0) + Math.abs(ball.vy || 0) > 0.1 ? (ball.vx || 0) : moveVx; + const effectiveVy = Math.abs(ball.vx || 0) + Math.abs(ball.vy || 0) > 0.1 ? (ball.vy || 0) : moveVy; + const incomingSpeed = -(effectiveVx * nx + effectiveVy * ny); + const overlapPush = Math.max(0, hitDistance - d); + const playIntentForPush = t.state === "play_ball" || t.target === ball; + if (constrainedBall && !playIntentForPush && (incomingSpeed > 8 || overlapPush > 1.5)) { + const pushImpulse = clamp(20 + incomingSpeed * 0.48 + overlapPush * 2.6, 20, 150); + t.vx = clamp((t.vx || 0) - nx * pushImpulse + effectiveVx * 0.08, -360, 360); + t.vy = clamp((t.vy || 0) - ny * pushImpulse + effectiveVy * 0.08, -360, 360); + if (overlapPush > 0.4) { + const sep = Math.min(14, overlapPush * 0.58 + 0.8); + t.x = clamp((t.x || 0) - nx * sep, CONFIG.worldPadding, this.w - CONFIG.worldPadding); + t.y = clamp((t.y || 0) - ny * sep, CONFIG.worldPadding, this.h - CONFIG.worldPadding); + ball.x = clamp((ball.x || 0) + nx * sep * 0.28, CONFIG.worldPadding, this.w - CONFIG.worldPadding); + ball.y = clamp((ball.y || 0) + ny * sep * 0.28, CONFIG.worldPadding, this.h - CONFIG.worldPadding); + } + ball.vx = clamp((ball.vx || effectiveVx) * 0.62 + nx * Math.min(42, pushImpulse * 0.22), -720, 720); + ball.vy = clamp((ball.vy || effectiveVy) * 0.62 + ny * Math.min(42, pushImpulse * 0.22), -720, 720); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 4.6, -38, 38); + ball.lastKickedAt = now; + ball.lastKickerId = t.id; + t.target = null; + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.16); + this.markSpatialDirty?.("constrained-ball-push"); + continue; + } + + const isDangerous = ballSpeed >= 285; + if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) { + const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx; + const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; + const damage = physicalDamageFromImpactSpeed(ballSpeed, { min: 3.5, max: 38, scale: 18 }); + t.lastBallDamageAt = now; + this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, { + panic: true, + target: { x: ball.x, y: ball.y }, + fearTimer: 0.95, + thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", + }); + this.applyImpactDamage(t, damage, "\u885d\u7a81", { source: ball }); + const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); + const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); + if (t.addStress) t.addStress(stressGain, { threshold: 8 }); + if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); + else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); + t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46); + t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); + t.fallDir = bvx >= 0 ? 1 : -1; + t.fearTimer = Math.max(t.fearTimer, 0.95); + t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44); + t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); + this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8)); + this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" })); + if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] }); + ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); + ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); + continue; + } + + const relVx = (t.vx || 0) - (ball.vx || 0); + const relVy = (t.vy || 0) - (ball.vy || 0); + const relativeSpeed = Math.hypot(relVx, relVy); + const playIntent = t.state === "play_ball" || t.target === ball; + const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); + const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260); + ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52; + ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52; + const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38); + ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding); + ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding); + ball.lastKickedAt = now; + ball.lastKickerId = t.id; + t.vx -= nx * Math.min(22, impulse * 0.10); + t.vy -= ny * Math.min(22, impulse * 0.10); + t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); + const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; + const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; + if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 }); + if (playful && playIntent) { + t.loneliness = clamp(t.loneliness - 0.9, 0, 100); + t.affection = clamp(t.affection + 0.18, 0, 100); + } + t.goodMode = playIntent || playful ? "smile" : t.goodMode; + if (playIntent && now > (t.nextPlayBubbleAt || 0)) { + t.nextPlayBubbleAt = now + deterministicRange(this, "ball-play-bubble-delay", 2.0, 3.8, ball, t); + this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); + } + if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); + this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); + } + } + + }).call(worldRef, dt); + } + + + function resolveTarinaiHighSpeedCollisions(worldRef, dt) { + if (!worldRef) return undefined; + return (function resolveTarinaiHighSpeedCollisionsUnified(dt) { + const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD; + const now = this.time || 0; + if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); + const live = Array.from(this.tarinai || []) + .filter(t => t && !t.dead && !this.isTarinaiHiddenInNestBox(t)) + .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))); + const candidates = []; + const maxCandidates = Math.max(24, Math.min(320, live.length * 6)); + for (let i = 0; i < live.length; i += 1) { + const a = live[i]; + const avx = a.vx || 0; + const avy = a.vy || 0; + const aSpeed = Math.hypot(avx, avy); + if (aSpeed < threshold) continue; + const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; + const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; + const ax1 = Number.isFinite(a.x) ? a.x : ax0; + const ay1 = Number.isFinite(a.y) ? a.y : ay0; + const asx = ax1 - ax0; + const asy = ay1 - ay0; + for (let j = 0; j < live.length; j += 1) { + const b = live[j]; + if (!b || b === a) continue; + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; + const bvx = b.vx || 0; + const bvy = b.vy || 0; + const relVx = avx - bvx; + const relVy = avy - bvy; + const relSpeed = Math.hypot(relVx, relVy); + if (relSpeed < threshold) continue; + const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; + const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; + const bx1 = Number.isFinite(b.x) ? b.x : bx0; + const by1 = Number.isFinite(b.y) ? b.y : by0; + const bsx = bx1 - bx0; + const bsy = by1 - by0; + const rx0 = ax0 - bx0; + const ry0 = ay0 - by0; + const rvx = asx - bsx; + const rvy = asy - bsy; + const relMoveSq = rvx * rvx + rvy * rvy; + const u = relMoveSq > 0.001 ? clamp(-(rx0 * rvx + ry0 * rvy) / relMoveSq, 0, 1) : 1; + const cx = rx0 + rvx * u; + const cy = ry0 + rvy * u; + const d = Math.max(0.001, Math.hypot(cx, cy)); + const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; + if (d > hitDistance) continue; + const score = (1 - u) * 4 + Math.max(0, hitDistance - d) * 0.12 + Math.min(5, relSpeed / 120); + candidates.push({ a, b, key, relSpeed, u, cx, cy, d, hitDistance, score }); + if (candidates.length >= maxCandidates) break; + } + if (candidates.length >= maxCandidates) break; + } + candidates.sort((p, q) => (p.u - q.u) || (q.score - p.score) || String(p.key).localeCompare(String(q.key))); + const maxImpacts = Math.max(8, Math.min(64, Number(this.maxTarinaiCollisionImpacts || 32) || 32)); + let solved = 0; + for (const c of candidates) { + if (solved >= maxImpacts) break; + const { a, b, key, relSpeed, hitDistance } = c; + if (!a || !b || a.dead || b.dead) continue; + if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; + this.tarinaiCollisionMemo.set(key, now); + solved += 1; + const as = Math.hypot(a.vx || 0, a.vy || 0); + const bs = Math.hypot(b.vx || 0, b.vy || 0); + const impactor = as >= bs ? a : b; + const target = impactor === a ? b : a; + const ivx = impactor.vx || 0; + const ivy = impactor.vy || 0; + const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; + let nx = impactSpeed > 0.001 ? ivx / impactSpeed : 0; + let ny = impactSpeed > 0.001 ? ivy / impactSpeed : 0; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const dx = target.x - impactor.x; + const dy = target.y - impactor.y; + const dd = Math.hypot(dx, dy); + if (dd > 0.001) { nx = dx / dd; ny = dy / dd; } + else { + const angle = (typeof stableUnit === "function" ? stableUnit(key, "tarinai-collision-normal") : 0.5) * Math.PI * 2; + nx = Math.cos(angle); + ny = Math.sin(angle); + } + } + const overlap = Math.max(0, hitDistance - Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0))); + if (overlap > 0.5) { + const sep = Math.min(18, overlap * 0.45 + 0.8); + impactor.x = clamp((impactor.x || 0) - nx * sep * 0.45, CONFIG.worldPadding, this.w - CONFIG.worldPadding); + impactor.y = clamp((impactor.y || 0) - ny * sep * 0.45, CONFIG.worldPadding, this.h - CONFIG.worldPadding); + target.x = clamp((target.x || 0) + nx * sep * 0.55, CONFIG.worldPadding, this.w - CONFIG.worldPadding); + target.y = clamp((target.y || 0) + ny * sep * 0.55, CONFIG.worldPadding, this.h - CONFIG.worldPadding); + this.markSpatialDirty?.("tarinai-highspeed-separation"); + } + const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 }); + this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { + panic: true, + target: { x: impactor.x, y: impactor.y }, + fearTimer: 0.55, + thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", + }); + impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); + impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); + this.applyImpactDamage(target, damage, "\u885d\u7a81", { source: impactor }); + if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81", { source: target }); + for (const t of [target, impactor]) { + if (!t || t.dead) continue; + if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); + else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true }); + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); + t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); + t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); + t.fallDir = nx >= 0 ? 1 : -1; + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + } + this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" })); + if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] }); + } + if (solved && this.spatialDirty) this.rebuildSpatial?.(true, "post-tarinai-highspeed-collision"); + + }).call(worldRef, dt); + } + + + global.TarinaiCollisionResponseSystem = Object.freeze({ + PHYSICAL_DAMAGE_SPEED_THRESHOLD, + physicalDamageFromImpactSpeed, + itemHasAttachedLink, + pairKey, + normalizeVector, + separatePair, + resolveBallBallCollisions, + resolveBallInteractions, + resolveTarinaiHighSpeedCollisions, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/colony_situation_system.js b/js/colony_situation_system.js new file mode 100644 index 0000000..6689e4b --- /dev/null +++ b/js/colony_situation_system.js @@ -0,0 +1,95 @@ +"use strict"; + +// Layer: simulation/colony-situation +// Owns colony mood/situation signal collection and priority arbitration. +(function (global) { + function avgOf(list, fn) { return list.length ? list.reduce((sum, t) => sum + (Number(fn(t)) || 0), 0) / list.length : 0; } + function countOf(list, fn) { return list.reduce((sum, t) => sum + (fn(t) ? 1 : 0), 0); } + function nearestAverage(alive) { + if (alive.length < 2) return Infinity; + let total = 0; + for (const a of alive) { + let best = Infinity; + for (const b of alive) { + if (a === b) continue; + const dx = (a.x || 0) - (b.x || 0); + const dy = (a.y || 0) - (b.y || 0); + const d2 = dx * dx + dy * dy; + if (d2 < best) best = d2; + } + if (Number.isFinite(best)) total += Math.sqrt(best); + } + return total / alive.length; + } + function collect(worldRef) { + const now = worldRef?.time || 0; + const alive = (worldRef?.tarinai || []).filter(t => t && !t.dead); + const n = alive.length; + const ratio = value => n ? value / n : 0; + const panicCount = countOf(alive, t => t.state === "panic" || (t.fearTimer || 0) > 0.2 || (t.panicTargetUntil || 0) > now); + const safetyHighCount = countOf(alive, t => (t.needs?.safety || 0) >= 52 || (t.fearTimer || 0) > 0.35); + const weakCount = countOf(alive, t => (t.energy || 0) < 45 || (t.hunger || 0) > 88 || (t.needs?.health || 0) > 58); + const diseasedCount = countOf(alive, t => t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease); + const breedingCount = countOf(alive, t => (t.loveMochiTimer || 0) > 0.1 || t.state === "birth_ritual" || (t.birthRitualTimer || 0) > 0.1); + const recentDeaths = (Array.isArray(worldRef?.recentDeathTimes) ? worldRef.recentDeathTimes : []).filter(t => now - (Number(t) || -999) <= 30).length; + const nearestAvg = nearestAverage(alive); + const fieldId = String(worldRef?.fieldType || "garden"); + const overcrowdLimit = fieldId === "cage" ? 20 : (fieldId === "park" ? 100 : 50); + return { + now, + alive, + n, + avgEnergy: avgOf(alive, t => t.energy || 0), + avgStress: avgOf(alive, t => t.stress || 0), + avgHunger: avgOf(alive, t => t.hunger || 0), + avgSafety: avgOf(alive, t => t.needs?.safety || 0), + panicCount, + safetyHighCount, + weakCount, + diseasedCount, + breedingCount, + recentDeaths, + nearestAvg, + recentShock: (now - (worldRef?.lastShootAt || -999) <= 8) || (now - (worldRef?.lastGenkotsuAt || -999) <= 8), + panicRatio: ratio(panicCount), + safetyRatio: ratio(safetyHighCount), + weakRatio: ratio(weakCount), + diseaseRatio: ratio(diseasedCount), + breedingRatio: ratio(breedingCount), + overcrowdLimit, + denseCrowd: Number.isFinite(nearestAvg) && nearestAvg < 84 && n >= Math.max(8, Math.floor(overcrowdLimit * 0.55)), + }; + } + function choose(worldRef, m) { + const candidates = []; + const add = (id, priority, ok) => { if (ok) candidates.push({ id, priority }); }; + const devastationSignal = m.recentDeaths >= 3 || (m.n <= 2 && (worldRef?.deadCount || 0) > 0 && (m.recentDeaths > 0 || m.weakRatio >= 0.35 || m.panicRatio >= 0.20)); + const crisisExhaustionSignal = m.avgEnergy < 30 && m.weakRatio >= 0.48; + add("devastation", 120, m.n <= 10 && devastationSignal); + add("crisis", 110, m.recentDeaths >= 2 || crisisExhaustionSignal || (m.avgHunger > 92 && m.weakRatio >= 0.48)); + add("confusion", 100, (m.recentShock && (m.panicRatio >= 0.10 || m.safetyRatio >= 0.18)) || m.panicRatio >= 0.25); + add("fearful", 90, (m.avgSafety >= 50 && m.safetyRatio >= 0.28) || (m.recentShock && m.safetyRatio >= 0.18)); + add("disease_spread", 82, m.diseaseRatio >= 0.25 || (m.diseasedCount >= 3 && m.diseaseRatio >= 0.16)); + add("weakened", 74, (m.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34); + add("breeding", 66, m.breedingRatio >= 0.10 || m.breedingCount >= 2 || m.now - (worldRef?.lastBirthAt || -999) <= 45); + add("happy", 46, m.avgStress < 28 && m.panicRatio < 0.08 && m.diseaseRatio < 0.12 && m.weakRatio < 0.18); + add("overcrowded", 34, m.n >= m.overcrowdLimit || m.denseCrowd); + add("isolated", 28, m.n <= 1 || (m.n <= 3 && m.nearestAvg > Math.max(180, Math.min(worldRef?.w || 900, worldRef?.h || 600) * 0.32))); + if (!candidates.length) return "relaxed"; + candidates.sort((a, b) => b.priority - a.priority); + return candidates[0].id; + } + function evaluate(worldRef, force = false) { + const now = worldRef?.time || 0; + if (!worldRef) return { id: "relaxed", label: "安定", effects: { personality: {} } }; + if (!force && worldRef.colonyMood && now < (worldRef.nextColonyMoodEvalAt || 0)) return worldRef.colonyMood; + const metrics = collect(worldRef); + const id = choose(worldRef, metrics); + const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } }; + worldRef.colonyMood = next; + worldRef.colonySituationMetrics = metrics; + worldRef.nextColonyMoodEvalAt = now + 1.25; + return next; + } + global.TarinaiColonySituationSystem = Object.freeze({ collect, choose, evaluate }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/constraint_system.js b/js/constraint_system.js index 8554365..dda2c6e 100644 --- a/js/constraint_system.js +++ b/js/constraint_system.js @@ -36,13 +36,6 @@ return true; } - function constraintKick(move, dt, scale = 0.055, limit = 90) { - // Constraint projection is primarily positional. Convert only a small, - // capped part of the correction back to velocity so links do not inject - // energy when the frame time spikes or endpoints start deeply overlapped. - const step = Math.max(0.028, Math.min(0.055, Number(dt || 0.016) || 0.016)); - return clamp(move / step * scale, -limit, limit); - } function rectNearPointAabb(rect, x, y, padding = 0) { if (!rect) return false; @@ -81,7 +74,8 @@ item._lastConstraintSpatialDirtyY = y; item._lastConstraintSpatialDirtyR = r; item._nextConstraintSpatialDirtyAt = now + Math.max(0.016, Number(minInterval || 0.065) || 0.065); - world.markSpatialDirty?.(reason); + const marked = global.TarinaiPhysicsProjectionSystem?.markSpatialDirty?.(world, item, reason, threshold, minInterval) ?? false; + if (!marked) world.markSpatialDirty?.(reason); if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.marked = (world.constraintDirtyStatsThisFrame.marked || 0) + 1; return true; } @@ -327,6 +321,17 @@ return { x: (obj.x || 0) + lx * c - ly * s, y: (obj.y || 0) + lx * s + ly * c, obj }; } + + function endpointCanRemainAnchored(endpoint, point) { + if (!endpoint || !point) return false; + if (point.obj && !point.obj.dead) return true; + // When a linked Tarinai dies, its live id is released before link cleanup. + // Keep the affected endpoint as a frozen world-space anchor instead of + // deleting the rope/rod item; otherwise unrelated links can be swept up by + // the lifecycle pass that removes zero-amount link items. + return endpoint.kind === "tarinai" && Number.isFinite(Number(point.x)) && Number.isFinite(Number(point.y)); + } + function endpointMass(obj) { if (!obj || obj.dead) return Infinity; if (obj._heldByPlayer || obj.playerHeld) return Infinity; @@ -337,7 +342,7 @@ if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) { if (obj.type === "rotator") return Infinity; // anchored pivot: links may torque it, not translate it if (obj.type === "reciprocator") return mechanicalPowered(obj) ? 18.0 : 5.8; - if (obj.type === "poison_block") return Math.max(2.4, ps(obj, "mass", 4.8)); + if (obj.type === "poison_block") return Math.max(0.35, ps(obj, "mass", 0.45)); return 8.0; } if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5; @@ -361,13 +366,10 @@ return changed; } if (obj.radius && obj.name) { + obj.prevX = Number.isFinite(Number(obj.x)) ? Number(obj.x) : obj.prevX; + obj.prevY = Number.isFinite(Number(obj.y)) ? Number(obj.y) : obj.prevY; obj.x += moveX; obj.y += moveY; - const kickScale = mode === "rod" ? 0.020 : 0.052; - const kickLimit = mode === "rod" ? 54 : 105; - if (mode === "rod") { obj.vx = (obj.vx || 0) * 0.90; obj.vy = (obj.vy || 0) * 0.90; } - obj.vx = clamp((obj.vx || 0) + constraintKick(moveX, dt, kickScale, kickLimit), -150, 150); - obj.vy = clamp((obj.vy || 0) + constraintKick(moveY, dt, kickScale, kickLimit), -150, 150); if (obj.state === "sleep" || obj.sleeping) { global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(obj, dt, `constraint-${mode}`); obj._sleepExternalMotionUntil = Math.max(Number(obj._sleepExternalMotionUntil || 0) || 0, (Number(obj.world?.time || 0) || 0) + (mode === "rod" ? 1.1 : 0.6)); @@ -399,9 +401,6 @@ obj.y = next.y; } const changed = Math.abs(ps(obj, "railPhase", 0) - beforePhase) > 0.0001; - const current = ps(obj, "slideSpeed", 0); - const kick = constraintKick(along, dt, mode === "rod" ? (powered ? 0.0025 : 0.0055) : (powered ? 0.0045 : 0.012), mode === "rod" ? (powered ? 12 : 26) : (powered ? 22 : 58)); - pset(obj, "slideSpeed", clamp(current * (powered ? 0.70 : (mode === "rod" ? 0.72 : 0.88)) + kick, -120, 120), "constraint-slide-project"); if (changed) commitConstraintMovedMechanical(obj, "constraint-reciprocator-project"); return changed; } @@ -417,25 +416,6 @@ obj.prevY = beforeY; obj.x = beforeX + px; obj.y = beforeY + py; - const len = Math.max(0.001, Math.hypot(px, py)); - const nx = px / len; - const ny = py / len; - const vx = ps(obj, "xv", obj.vx || 0); - const vy = ps(obj, "yv", obj.vy || 0); - const intoCorrection = vx * nx + vy * ny; - let nextVx = vx; - let nextVy = vy; - if (intoCorrection < 0) { - nextVx -= nx * intoCorrection * 0.50; - nextVy -= ny * intoCorrection * 0.50; - } - if (mode === "rod") { nextVx *= 0.86; nextVy *= 0.86; } - nextVx += constraintKick(px, dt, mode === "rod" ? 0.006 : 0.012, mode === "rod" ? 20 : 36); - nextVy += constraintKick(py, dt, mode === "rod" ? 0.006 : 0.012, mode === "rod" ? 20 : 36); - pset(obj, "xv", clamp(nextVx, -165, 165), "constraint-poison-project"); - pset(obj, "yv", clamp(nextVy, -165, 165), "constraint-poison-project"); - obj.vx = ps(obj, "xv", obj.vx || 0); - obj.vy = ps(obj, "yv", obj.vy || 0); commitConstraintMovedMechanical(obj, "constraint-poison-project"); global.TarinaiMechanicalSystem?.wakeItem?.(obj, "constraint-poison-project"); return true; @@ -458,9 +438,6 @@ obj.prevY = obj.y; obj.x += moveX; obj.y += moveY; - if (mode === "rod") { obj.vx = (obj.vx || 0) * 0.90; obj.vy = (obj.vy || 0) * 0.90; } - obj.vx = clamp((obj.vx || 0) + constraintKick(moveX, dt, mode === "rod" ? 0.016 : 0.042, mode === "rod" ? 42 : 78), -125, 125); - obj.vy = clamp((obj.vy || 0) + constraintKick(moveY, dt, mode === "rod" ? 0.016 : 0.042, mode === "rod" ? 42 : 78), -125, 125); return true; } @@ -617,6 +594,8 @@ } function rectLocalPointForConstraint(r, x, y) { + const shared = global.TarinaiCollisionFootprints?.rectLocalPoint; + if (typeof shared === "function") return shared(r, x, y); const dx = x - (r.cx ?? 0); const dy = y - (r.cy ?? 0); const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0); @@ -664,7 +643,7 @@ if (d < 0.001) { dx = 0; dy = -1; d = 1; } nx = dx / d; ny = dy / d; overlap = radius - d; } - const push = Math.min(Math.max(0, overlap) + 0.35, Math.max(4, radius * 0.9)); + const push = Math.min(Math.max(0, overlap), Math.max(2, radius * 0.9)); p.x += nx * push; p.y += ny * push; // Do not let collision projection become a full Verlet velocity on the next frame. @@ -785,15 +764,10 @@ if (d >= minD) continue; if (d < 0.001) { d = 1; } const nx = dx / d || 0, ny = dy / d || -1; - const push = Math.min(minD - d + 0.4, Math.max(6, minD * 0.56)); - p.x += nx * push * 0.82; - p.y += ny * push * 0.82; - t.x -= nx * push * 0.10; - t.y -= ny * push * 0.10; - t.vx = clamp((t.vx || 0) - nx * 16, -160, 160); - t.vy = clamp((t.vy || 0) - ny * 16, -160, 160); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.08); - c = true; + const push = Math.min(Math.max(0, minD - d), Math.max(3, minD * 0.48)); + p.x += nx * push; + p.y += ny * push; + c = push > 0.001 || c; } for (const rect of getRopeObstacleRects()) { if (rect?.item?.id && endpointIds.has(rect.item.id)) continue; @@ -858,7 +832,7 @@ item.world = worldRef || item.world || null; const a = endpointWorld(ep(item, 0), worldRef); const b = endpointWorld(ep(item, 1), worldRef); - if (!a || !b || !a.obj || !b.obj) { + if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) { item.amount = 0; if (worldRef) worldRef.drawListDirty = true; return true; @@ -870,7 +844,7 @@ let changed = applyDistanceConstraint(item, a, b, dt, length, "rope"); const aa = changed ? endpointWorld(ep(item, 0), worldRef) : a; const bb = changed ? endpointWorld(ep(item, 1), worldRef) : b; - if (!aa || !bb || !aa.obj || !bb.obj) { + if (!endpointCanRemainAnchored(ep(item, 0), aa) || !endpointCanRemainAnchored(ep(item, 1), bb)) { item.amount = 0; if (worldRef) worldRef.drawListDirty = true; return true; @@ -893,7 +867,7 @@ item.world = worldRef || item.world || null; const a = endpointWorld(ep(item, 0), worldRef); const b = endpointWorld(ep(item, 1), worldRef); - if (!a || !b || !a.obj || !b.obj) { + if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) { item.amount = 0; if (worldRef) worldRef.drawListDirty = true; return true; @@ -949,8 +923,13 @@ function endpointRuntimeStamp(endpoint, worldRef) { const obj = resolveEndpointObject(endpoint, worldRef); - if (!obj || obj.dead) return { stamp: "missing", missing: true, obj: null }; const q = (v, scale = 10) => Math.round((Number(v) || 0) * scale); + if (!obj || obj.dead) { + if (endpoint?.kind === "tarinai" && Number.isFinite(Number(endpoint.x)) && Number.isFinite(Number(endpoint.y))) { + return { stamp: `tarinai-anchor:${endpoint.id || "?"}:${q(endpoint.x)}:${q(endpoint.y)}`, missing: false, obj: null }; + } + return { stamp: "missing", missing: true, obj: null }; + } let stamp = `${endpoint?.kind || "item"}:${obj.id || "?"}:${obj.type || (obj.name ? "tarinai" : "entity")}:${q(obj.x)}:${q(obj.y)}`; if (obj.radius && obj.name) stamp += `:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${obj.state || ""}`; else if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) { @@ -1036,6 +1015,8 @@ worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 }; return 0; } + // Rotate the starting point each frame so a large rope/rod set does not + // starve low-score links that are always beyond the per-frame budget. const cursor = Math.max(0, Math.min(links.length - 1, Number(worldRef._constraintBudgetCursor || 0) || 0)); const ordered = links.slice(cursor).concat(links.slice(0, cursor)); let ran = 0; @@ -1055,6 +1036,8 @@ } pending.push({ item, score: linkScheduleScore(item, worldRef) }); } + // Active/strained links run first, but cursor rotation above preserves + // fairness when maxLinks prevents solving every pending link. pending.sort((a, b) => (b.score - a.score) || ((a.item.id || 0) - (b.item.id || 0))); for (const entry of pending) { if (ran >= maxLinks) break; diff --git a/js/deterministic_helpers.js b/js/deterministic_helpers.js new file mode 100644 index 0000000..93deb9f --- /dev/null +++ b/js/deterministic_helpers.js @@ -0,0 +1,45 @@ +"use strict"; + +// Layer: core/deterministic +// Shared deterministic pseudo-random helpers used by behavior, item runtime, +// and visual effects. Previously this bootstrap lived inline in several +// modules; keeping it here prevents helper drift and load-order surprises. +(function ensureDeterministicHelpers(global) { + if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; + const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); + const keyPart = value => { + if (value == null) return "null"; + if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; + if (typeof value === "string" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; + if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; + const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; + const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; + return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; + } + return String(value); + }; + const hashUnit = (seed, salt = "") => { + const str = `${seed}:${salt}`; + let h = 2166136261; + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; + }; + global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); + global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); + global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); + global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); + global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); + global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); + global.TarinaiDeterministicHelpers = Object.freeze({ keyPart, hashUnit }); +})(typeof window !== "undefined" ? window : globalThis); + +// Keep the historical bare helper names available to legacy scripts while the +// implementation lives on globalThis. +var deterministicFrame = (typeof window !== "undefined" ? window : globalThis).deterministicFrame; +var deterministicUnit = (typeof window !== "undefined" ? window : globalThis).deterministicUnit; +var deterministicRange = (typeof window !== "undefined" ? window : globalThis).deterministicRange; +var deterministicSigned = (typeof window !== "undefined" ? window : globalThis).deterministicSigned; +var deterministicChance = (typeof window !== "undefined" ? window : globalThis).deterministicChance; +var deterministicAngle = (typeof window !== "undefined" ? window : globalThis).deterministicAngle; diff --git a/js/history_system.js b/js/history_system.js index 52c5c96..e715499 100644 --- a/js/history_system.js +++ b/js/history_system.js @@ -41,11 +41,11 @@ return true; } - function restore(worldRef, entry, source) { + function restore(worldRef, entry) { if (!worldRef || !entry?.snapshot || !global.TarinaiRestoreCoordinator?.restoreSnapshot) return false; worldRef._historyRestoring = true; try { - global.TarinaiRestoreCoordinator.restoreSnapshot(cloneSnapshot(entry.snapshot), worldRef, { source, syncUi: true }); + global.TarinaiRestoreCoordinator.restoreSnapshot(cloneSnapshot(entry.snapshot), worldRef, { syncUi: true }); worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot?.createSnapshot?.(worldRef)); return true; } finally { @@ -60,7 +60,7 @@ const prev = world._undoStack.pop(); if (current) world._redoStack.push({ snapshot: cloneSnapshot(current), key: snapshotKey(current), label: "redo", time: world.time || 0 }); if (world._redoStack.length > MAX_HISTORY) world._redoStack.splice(0, world._redoStack.length - MAX_HISTORY); - const ok = restore(world, prev, "undo"); + const ok = restore(world, prev); return { ok, label: prev.label || "" }; } @@ -71,7 +71,7 @@ const next = world._redoStack.pop(); if (current) world._undoStack.push({ snapshot: cloneSnapshot(current), key: snapshotKey(current), label: "undo", time: world.time || 0 }); if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY); - const ok = restore(world, next, "redo"); + const ok = restore(world, next); return { ok, label: next.label || "" }; } diff --git a/js/impact_core_system.js b/js/impact_core_system.js new file mode 100644 index 0000000..8991aa2 --- /dev/null +++ b/js/impact_core_system.js @@ -0,0 +1,52 @@ +"use strict"; + +// Layer: physics/impact-core +// Shared impact constants and link-attachment helpers used by drop impacts, +// ball collisions, and high-speed tarinai collisions. +(function (global) { + const PHYSICAL_DAMAGE_SPEED_THRESHOLD = 165; + + function physicalDamageFromImpactSpeed(speed = 0, { min = 0, max = 58, scale = 8.6 } = {}) { + const v = Math.max(0, Number(speed) || 0); + if (v < PHYSICAL_DAMAGE_SPEED_THRESHOLD) return 0; + const raw = (v - PHYSICAL_DAMAGE_SPEED_THRESHOLD) / Math.max(1, scale); + return clamp(raw, min, max); + } + + function linkEndpointMatchesItem(endpoint, item) { + if (!endpoint || !item) return false; + if (endpoint.kind && endpoint.kind !== "item") return false; + if (endpoint.id && item.id && endpoint.id === item.id) return true; + const dx = Number(endpoint.x) - Number(item.x || 0); + const dy = Number(endpoint.y) - Number(item.y || 0); + return Number.isFinite(dx) && Number.isFinite(dy) && Math.hypot(dx, dy) <= Math.max(14, (item.r || 18) * 0.75); + } + + function itemHasAttachedLink(worldRef, item) { + if (!worldRef || !item || !item.id) return false; + const pb = global.TarinaiPhysicsBodySystem || null; + const links = []; + if (worldRef.itemsOfType) { + for (const linkType of ["rope", "rod"]) { + for (const link of worldRef.itemsOfType(linkType) || []) if (link && !link.dead) links.push(link); + } + } else { + for (const link of worldRef.items || []) if (link && !link.dead && (link.type === "rope" || link.type === "rod")) links.push(link); + } + for (const link of links) { + const endpoints = [pb?.endpoint?.(link, 0), pb?.endpoint?.(link, 1)]; + const fallback = link.physicsConstraint?.endpoints || link.constraint?.endpoints || []; + if (!endpoints[0] && fallback[0]) endpoints[0] = fallback[0]; + if (!endpoints[1] && fallback[1]) endpoints[1] = fallback[1]; + if (linkEndpointMatchesItem(endpoints[0], item) || linkEndpointMatchesItem(endpoints[1], item)) return true; + } + return false; + } + + global.TarinaiImpactCoreSystem = Object.freeze({ + PHYSICAL_DAMAGE_SPEED_THRESHOLD, + physicalDamageFromImpactSpeed, + linkEndpointMatchesItem, + itemHasAttachedLink, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/impact_response_system.js b/js/impact_response_system.js new file mode 100644 index 0000000..94a9e10 --- /dev/null +++ b/js/impact_response_system.js @@ -0,0 +1,118 @@ +"use strict"; + +(function (global) { + const ImpactCore = global.TarinaiImpactCoreSystem || {}; + const PHYSICAL_DAMAGE_SPEED_THRESHOLD = ImpactCore.PHYSICAL_DAMAGE_SPEED_THRESHOLD || 165; + const physicalDamageFromImpactSpeed = ImpactCore.physicalDamageFromImpactSpeed; + const itemHasAttachedLink = ImpactCore.itemHasAttachedLink; + + function itemDropImpact(worldRef, it) { + if (!worldRef) return undefined; + return (function itemDropImpactUnified(it) { + if (!it || it.dead || it.type === "water") return; + const isStone = it.type === "stone"; + const isGenkotsu = it.type === "genkotsu"; + const dropFallHeight = isGenkotsu ? Math.max(360, (it.r || 88) * 5.8) : 170; + const dropDuration = Math.max(0.08, Number(it.dropMax || 0.42) || 0.42); + const impactSpeed = dropFallHeight / dropDuration; + const radius = Math.max(32, Math.min(220, (it.r || 14) * 1.35 + impactSpeed * 0.055)); + const damage = physicalDamageFromImpactSpeed(impactSpeed, { min: 1.2, max: isGenkotsu ? 58 : 34, scale: 8.6 }); + const push = clamp((impactSpeed - 120) * 0.62, 42, isGenkotsu ? 1040 : 260); + const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3\u306b\u6f70\u3055\u308c\u305f" : (impactSpeed >= PHYSICAL_DAMAGE_SPEED_THRESHOLD ? "\u9ad8\u901f\u3067\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u885d\u7a81" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f")); + if (isGenkotsu) { this.lastGenkotsuAt = this.time || 0; it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); this.triggerGroundShake?.(2.0, 10); } + let hit = 0; + for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; + const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); + const p = clamp(1 - d / (radius + t.radius), 0, 1); + if (p <= 0) continue; + let nx = (t.x - it.x) / d; + let ny = (t.y - it.y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = deterministicAngle(this, "drop-impact-overlap-normal", it, t); + nx = Math.cos(a); + ny = Math.sin(a); + } + t.vx += nx * push * (0.35 + p); + t.vy += ny * push * (0.35 + p) - Math.min(210, push * 0.22) * p; + const dealtDamage = damage * (0.35 + p * 0.65); + if (dealtDamage > 0) t.damage(dealtDamage, reason); + if (isGenkotsu) { + t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); + if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 }); + t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); + t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044"; + } else { + t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b"; + } + t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); + t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); + t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); + t.fallDir = nx >= 0 ? 1 : -1; + hit += 1; + } + let ballHit = 0; + if (isGenkotsu && this.itemCounts?.ball) { + const ballRange = radius + Math.max(72, (it.r || 88) * 0.78); + const balls = this.nearbyItems?.(it.x, it.y, ballRange + 42, true) || this.items || []; + for (const ball of balls) { + if (!ball || ball.dead || ball.type !== "ball") continue; + const d = Math.max(1, distXY(ball.x, ball.y, it.x, it.y)); + const p = clamp(1 - d / (ballRange + (ball.r || 18)), 0, 1); + if (p <= 0) continue; + let nx = (ball.x - it.x) / d; + let ny = (ball.y - it.y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = deterministicAngle(this, "genkotsu-ball-impact-normal", it, ball); + nx = Math.cos(a); + ny = Math.sin(a); + } + const ballPush = clamp(push * (0.44 + p * 0.86), 160, 1080); + ball.prevX = Number.isFinite(ball.x) ? ball.x : (ball.prevX || 0); + ball.prevY = Number.isFinite(ball.y) ? ball.y : (ball.prevY || 0); + ball.vx = clamp((ball.vx || 0) + nx * ballPush, -1180, 1180); + ball.vy = clamp((ball.vy || 0) + ny * ballPush - Math.min(180, ballPush * 0.12) * p, -1180, 1180); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (10 + p * 12), -48, 48); + ball.lastPokedAt = this.time || 0; + ballHit += 1; + this.effects?.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, (ball.r || 18) * 1.1), life: 0.20, color: "rgba(255,255,255,0.52)" })); + } + if (ballHit > 0) this.markSpatialDirty?.("genkotsu-ball-impact"); + } + if (damage > 0 && typeof damageNearbyStructures === "function") damageNearbyStructures(this, it.x, it.y, radius, damage, it); + const antHit = damage > 0 ? this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { + push: p => push * (0.62 + p * 1.25), + }) : 0; + if (isGenkotsu) { + this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75, { limit: 96, effectLimit: 6 }); + this.blastLoosePinsFrom?.(it.x, it.y, radius + 92, it, 2.75, "genkotsu-impact"); + const existingEffects = (this.effects || []).length; + const ringCount = existingEffects > 180 ? 2 : 3; + const burstCount = existingEffects > 180 ? 4 : 7; + for (let r = 0; r < ringCount; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 42, life: 0.32 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.68)" : "rgba(74,57,42,0.38)" })); + for (let i = 0; i < burstCount; i++) this.effects.push(new Effect("fight", it.x + deterministicRange(this, "genkotsu-impact-effect-x", -38, 38, it, i), it.y + deterministicRange(this, "genkotsu-impact-effect-y", -22, 28, it, i), { size: deterministicRange(this, "genkotsu-impact-effect-size", 12, 22, it, i), life: deterministicRange(this, "genkotsu-impact-effect-life", 0.22, 0.42, it, i), color: "rgba(255,205,46,0.78)" })); + } else { + this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); + } + if (hit > 0 || antHit > 0 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45)); + if (isGenkotsu) audio.genkotsuImpact?.(); + else if (isStone) audio.stoneImpact?.(); + else if (hit > 0 || antHit > 0) audio.poke?.(); + else audio.drop?.(); + if (hit > 0 || antHit > 0) { + const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177"); + const victims = []; + if (hit > 0) victims.push(`${hit}\u5339\u306e\u305f\u308a\u306a\u3044`); + if (antHit > 0) victims.push(`${antHit}\u5339\u306e\u30a2\u30ea`); + this.log(`${label}\u304c\u843d\u3061\u3001${victims.join("\u3068")}\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); + } + + }).call(worldRef, it); + } + + global.TarinaiImpactResponseSystem = Object.freeze({ + PHYSICAL_DAMAGE_SPEED_THRESHOLD, + physicalDamageFromImpactSpeed, + itemDropImpact, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/input_mode_manager.js b/js/input_mode_manager.js index db64903..d96ae1f 100644 --- a/js/input_mode_manager.js +++ b/js/input_mode_manager.js @@ -8,12 +8,6 @@ constructor() { this.storageKey = STORAGE_KEY; this.currentMode = this.loadMode(); - this.gestureState = { - active: false, - kind: "", - startedAt: 0, - pointerCount: 0, - }; this.classification = this.classify(); } @@ -29,7 +23,6 @@ setMode(mode = "auto") { this.currentMode = MODES.includes(mode) ? mode : "auto"; try { global.localStorage?.setItem(STORAGE_KEY, this.currentMode); } catch (_) {} - this.emit("input:mode", this.snapshot()); return this.currentMode; } @@ -67,54 +60,15 @@ refresh() { const previous = this.classification; const next = this.classify(); - if (JSON.stringify(previous) !== JSON.stringify(next)) this.emit("input:classification", this.snapshot()); return next; } - beginGesture(kind = "", pointerCount = 1) { - this.gestureState = { - active: true, - kind: String(kind || ""), - startedAt: performance?.now?.() ?? Date.now(), - pointerCount: Math.max(0, Number(pointerCount) || 0), - }; - return this.gestureState; - } - - endGesture() { - this.gestureState = { ...this.gestureState, active: false, pointerCount: 0 }; - return this.gestureState; - } - - canPanCanvas() { - if (!this.classification?.touchFirst) return true; - return this.currentMode === "auto" || this.currentMode === "camera"; - } - - canPlaceTools() { - if (!this.classification?.touchFirst) return true; - return this.currentMode === "auto" || this.currentMode === "tool"; - } - - canFamilyTreeCaptureTouch() { - if (!this.classification?.touchFirst) return true; - return true; - } - snapshot() { return Object.freeze({ ...this.classification, currentMode: this.currentMode, - gestureState: { ...this.gestureState }, - canPanCanvas: this.canPanCanvas(), - canPlaceTools: this.canPlaceTools(), - canFamilyTreeCaptureTouch: this.canFamilyTreeCaptureTouch(), }); } - - emit(type, detail) { - global.TarinaiEvents?.emit?.(type, detail); - } } global.InputModeManager = InputModeManager; diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index 8b919d2..7140312 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -18,12 +18,14 @@ if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; } if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; } resolveObstacleCollisions(item, dt, worldRef); + global.TarinaiItemDynamicPinSystem?.tryBallPickup?.(item, worldRef); item.x = clamp(item.x, p, worldRef.w - p); item.y = clamp(item.y, p, worldRef.h - p); const groundFriction = Math.pow(0.72, dt); item.vx *= groundFriction; item.vy *= groundFriction; } + global.TarinaiItemDynamicPinSystem?.tryBallPickup?.(item, worldRef); item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt + (item.vx || 0) * dt / Math.max(8, item.r || 18); item.spinVelocity *= Math.pow(0.65, dt); if (Math.hypot(item.vx || 0, item.vy || 0) < 0.18) { item.vx = 0; item.vy = 0; } diff --git a/js/item_dynamic_pin_system.js b/js/item_dynamic_pin_system.js index febcf4a..c283e54 100644 --- a/js/item_dynamic_pin_system.js +++ b/js/item_dynamic_pin_system.js @@ -1,33 +1,4 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); // Layer: entity-runtime/items/dynamic-system @@ -35,7 +6,121 @@ // behavior. Prototype methods delegate here. (function (global) { function isPin(item) { - return Boolean(item && typeof global.isPinType === "function" && global.isPinType(item.type)); + return Boolean(global.TarinaiPinAttachmentSystem?.isPin?.(item) || (item && typeof global.isPinType === "function" && global.isPinType(item.type))); + } + + function ballById(worldRef, id) { + if (!worldRef || !id) return null; + return (worldRef.items || []).find(it => it && !it.dead && it.type === "ball" && it.id === id) || null; + } + + function removeFromBall(item, worldRef = null) { + const ball = ballById(worldRef, item?.pinBallId) || null; + if (ball?.stuckPinIds) ball.stuckPinIds = ball.stuckPinIds.filter(id => id !== item.id); + if (ball && ball.stuckPinId === item?.id) ball.stuckPinId = ""; + if (item) item.pinBallId = ""; + return ball; + } + + function updateBallLodged(item, dt, worldRef) { + const ball = ballById(worldRef, item.pinBallId); + if (!ball) { + detach(item, worldRef, "ball-lost"); + return; + } + if (global.TarinaiPinAttachmentSystem?.applyBallPose?.(item, ball, worldRef)) return; + const angle = (ball.spin || 0) + (item.pinBallOffsetAngle || 0); + const distance = Math.max(4, Number(item.pinBallOffsetDistance || (ball.r || 18) * 0.55) || (ball.r || 18) * 0.55); + item.prevX = item.x; + item.prevY = item.y; + item.x = ball.x + Math.cos(angle) * distance; + item.y = ball.y + Math.sin(angle) * distance; + item.vx = ball.vx || 0; + item.vy = ball.vy || 0; + item.pinVisualSide = Math.cos(angle) >= 0 ? 1 : -1; + item.spin = angle + (item.type === "oshibyo" ? 0.18 : Math.PI * 0.5); + item.deletable = false; + if (Number.isFinite(ball.spinVelocity)) item.spinVelocity = ball.spinVelocity; + if (worldRef) worldRef.drawListDirty = true; + } + + function attachToBall(item, ball, worldRef) { + if (!isPin(item) || !ball || ball.dead || ball.type !== "ball" || item.dead) return false; + if (item.pinState === "lodged" || item.pinState === "ball_lodged") return false; + const now = worldRef?.time || 0; + if ((item.noStickUntil || 0) > now) return false; + const angle = deterministicAngle(worldRef, "pin-ball-stick-angle", item, ball, now); + const dist = Math.max(4, (ball.r || 18) * deterministicRange(worldRef, "pin-ball-stick-distance", 0.24, 0.78, item, ball, now)); + item.pinState = "ball_lodged"; + item.pinBallId = ball.id; + item.pinTargetId = ""; + item.pinBallOffsetAngle = angle - (ball.spin || 0); + item.pinBallOffsetDistance = dist; + item.pinVisualSide = Math.cos(angle) >= 0 ? 1 : -1; + item.deletable = false; + item.noStickUntil = now + 0.16; + ball.stuckPinIds = Array.isArray(ball.stuckPinIds) ? ball.stuckPinIds : []; + if (!ball.stuckPinIds.includes(item.id)) ball.stuckPinIds.push(item.id); + ball.stuckPinId = ball.stuckPinIds[0] || item.id; + updateBallLodged(item, 0, worldRef); + worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(12, (item.r || 8) * 1.4), life: 0.16, color: item.type === "oshibyo" ? "rgba(73,119,205,0.42)" : "rgba(214,72,72,0.42)" })); + return true; + } + + function transferFromBallToTarinai(item, ball, t, worldRef) { + if (!item || item.dead || item.pinState !== "ball_lodged" || !ball || !t || t.dead) return false; + const now = worldRef?.time || 0; + if ((item._ballPinTransferredAt || -999) + 0.18 > now) return false; + removeFromBall(item, worldRef); + item.pinState = "loose"; + item.pinTargetId = ""; + item.noStickUntil = 0; + item.noStickTargetId = ""; + item.noStickTargetUntil = 0; + item.x = ball.x + deterministicRange(worldRef, "pin-transfer-x", -(ball.r || 18) * 0.25, (ball.r || 18) * 0.25, item, ball, t); + item.y = ball.y + deterministicRange(worldRef, "pin-transfer-y", -(ball.r || 18) * 0.25, (ball.r || 18) * 0.25, item, ball, t); + item._ballPinTransferredAt = now; + const ok = attach(item, t, worldRef, distXY(item.x, item.y, t.x, t.y)); + if (!ok) attachToBall(item, ball, worldRef); + return ok; + } + + function transferBallPinsToTarinai(ball, t, worldRef) { + if (!ball || !t || t.dead) return 0; + const ids = Array.isArray(ball.stuckPinIds) ? [...ball.stuckPinIds] : (ball.stuckPinId ? [ball.stuckPinId] : []); + let moved = 0; + for (const id of ids) { + const pin = (worldRef?.items || []).find(it => it && !it.dead && it.id === id && isPin(it)); + if (pin && transferFromBallToTarinai(pin, ball, t, worldRef)) moved += 1; + } + return moved; + } + + function tryBallPickup(ball, worldRef) { + if (!ball || ball.dead || ball.type !== "ball" || !worldRef?.nearbyItems) return 0; + const speed = Math.hypot(ball.vx || 0, ball.vy || 0); + const range = (ball.r || 18) + speed * Math.max(0.016, Number(worldRef.dt || 0.016) || 0.016) + 28; + const candidates = worldRef.nearbyItems(ball.x, ball.y, range) || []; + let picked = 0; + const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x; + const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y; + const sx = ball.x - px; + const sy = ball.y - py; + const len2 = sx * sx + sy * sy; + for (const pin of candidates) { + if (!isPin(pin) || pin.dead || pin.pinState === "lodged" || pin.pinState === "ball_lodged") continue; + let hitX = ball.x; + let hitY = ball.y; + if (len2 > 1) { + const u = clamp(((pin.x - px) * sx + (pin.y - py) * sy) / len2, 0, 1); + hitX = px + sx * u; + hitY = py + sy * u; + } + const hit = (ball.r || 18) + Math.max(5, pin.r || 8) + 2; + if (distXY(hitX, hitY, pin.x, pin.y) > hit) continue; + if (attachToBall(pin, ball, worldRef)) picked += 1; + } + return picked; } function update(item, dt, worldRef) { @@ -45,8 +130,13 @@ updateLodged(item, dt, worldRef); return true; } + if (item.pinState === "ball_lodged") { + updateBallLodged(item, dt, worldRef); + return true; + } if (global.TarinaiPhysics?.applyKinematicItemMotion) { global.TarinaiPhysics.applyKinematicItemMotion(item, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 }); + global.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(item, dt, worldRef); } else { item.prevX = item.x; item.prevY = item.y; @@ -77,6 +167,17 @@ return attach(item, best, worldRef, bestD); } + function oshibyoAnchorFor(t, visualSide, item = { type: "oshibyo" }) { + return global.TarinaiPinAttachmentSystem?.tarinaiAnchor?.(item, t, { visualSide }) || { + x: t.x + visualSide * (t?.radius || 16) * 0.96, + y: t.y + (t?.radius || 16) * 0.50, + angle: visualSide * 0.48, + distance: (t?.radius || 16) * 0.96, + offsetY: (t?.radius || 16) * 0.48, + visualSide, + }; + } + function attach(item, t, worldRef, d = null) { if (!t || t.dead) return false; if (t.stuckPushpinId && t.stuckPushpinId !== item.id) return false; @@ -88,12 +189,16 @@ item.pinState = "lodged"; item.deletable = false; item.pinTargetId = t.id; + removeFromBall(item, worldRef); + const sharedAnchor = global.TarinaiPinAttachmentSystem?.tarinaiAnchor?.(item, t, { dx, dy, distance: distToTarget }); const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = isOshibyo ? -1 : faceDir; + const faceSide = faceDir >= 0 ? 1 : -1; + const visualSide = sharedAnchor?.visualSide ?? (isOshibyo ? -faceSide : faceSide); item.pinVisualSide = visualSide; - item.pinAttachAngle = isOshibyo ? -0.46 : Math.atan2(dy || -1, dx || visualSide); - item.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.88 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); - item.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + const oshibyoAnchor = isOshibyo ? (sharedAnchor || oshibyoAnchorFor(t, visualSide, item)) : null; + item.pinAttachAngle = isOshibyo ? oshibyoAnchor.angle : (sharedAnchor?.angle ?? Math.atan2(dy || -1, dx || visualSide)); + item.pinAttachDistance = isOshibyo ? oshibyoAnchor.distance : (sharedAnchor?.distance ?? clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74)); + item.pinOffsetY = isOshibyo ? oshibyoAnchor.offsetY : (sharedAnchor?.offsetY ?? clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38)); item.pinDamageTick = 0; item.pinFallCheckTimer = 0; item.vx = 0; @@ -102,8 +207,8 @@ item.spin = item.pinAttachAngle; t.stuckPushpinId = item.id; t.sleeping = false; - t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8); + if (!isOshibyo) t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); + if (!isOshibyo) t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.8); if (isOshibyo) { t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f"; worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); @@ -118,7 +223,7 @@ t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); } - if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); + if (!isOshibyo && t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); if ((worldRef.time || 0) >= (item.pinLogAt || -999) + 1.2) { item.pinLogAt = worldRef.time || 0; worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); @@ -137,16 +242,21 @@ const isOshibyo = Boolean(behavior?.blocksZunchi); t.stuckPushpinId = item.id; item.deletable = false; - const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = isOshibyo ? -1 : faceDir; - item.pinVisualSide = visualSide; - item.x = isOshibyo ? t.x - (t.radius || 16) * 0.88 : t.x + visualSide * (t.radius || 16) * 0.36; - item.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04; - item.prevX = item.x; - item.prevY = item.y; - item.spin = isOshibyo ? -0.52 : visualSide * 0.28; - item.vx = t.vx || 0; - item.vy = t.vy || 0; + const sharedPose = global.TarinaiPinAttachmentSystem?.applyTarinaiPose?.(item, t, worldRef); + if (!sharedPose) { + const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); + const faceSide = faceDir >= 0 ? 1 : -1; + const visualSide = isOshibyo ? -faceSide : faceSide; + item.pinVisualSide = visualSide; + const oshibyoAnchor = isOshibyo ? oshibyoAnchorFor(t, visualSide, item) : null; + item.x = isOshibyo ? oshibyoAnchor.x : t.x + visualSide * (t.radius || 16) * 0.36; + item.y = isOshibyo ? oshibyoAnchor.y : t.y - (t.radius || 16) * 0.04; + item.prevX = item.x; + item.prevY = item.y; + item.spin = isOshibyo ? oshibyoAnchor.angle : visualSide * 0.28; + item.vx = t.vx || 0; + item.vy = t.vy || 0; + } if (isOshibyo) { if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044"; return; @@ -154,7 +264,7 @@ item.pinDamageTick = (item.pinDamageTick || 0) + dt; while (item.pinDamageTick >= 0.55) { item.pinDamageTick -= 0.55; - if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); + if (!isOshibyo && t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); if (deterministicChance(worldRef, "pin-pain-bubble", 0.22, item, t)) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); @@ -178,6 +288,7 @@ } function detach(item, worldRef, reason = "released") { + const carriedBall = removeFromBall(item, worldRef); const t = worldRef?.liveTarinaiById?.(item.pinTargetId) || null; const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null; const wasOshibyo = Boolean(behavior?.blocksZunchi); @@ -204,22 +315,51 @@ item.vy = 0; item.spinVelocity = 0; } else { - item.vx = deterministicRange(worldRef, "pin-detach-vx", -24, 24, item, t, reason); - item.vy = deterministicRange(worldRef, "pin-detach-vy", -10, 18, item, t, reason); - item.spinVelocity = deterministicRange(worldRef, "pin-detach-spin-velocity", -1.6, 1.6, item, t, reason); - item.spin += deterministicRange(worldRef, "pin-detach-spin", -0.25, 0.25, item, t, reason); + let nx = 0; + let ny = 1; if (t) { - item.x = clamp(t.x + deterministicRange(worldRef, "pin-detach-x", -(t.radius || 16) * 0.75, (t.radius || 16) * 0.75, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30)); - item.y = clamp(t.y + deterministicRange(worldRef, "pin-detach-y", (t.radius || 16) * 0.12, (t.radius || 16) * 0.72, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30)); + const dx = Number(item.x || 0) - Number(t.x || 0); + const dy = Number(item.y || 0) - Number(t.y || 0); + const d = Math.hypot(dx, dy); + if (d > 0.001) { nx = dx / d; ny = dy / d; } + else { nx = Math.sign(item.pinVisualSide || 0) || (t.facingDir?.() || 1); ny = 0.28; } + const len = Math.hypot(nx, ny) || 1; + nx /= len; ny /= len; + const dropR = Math.max(8, (item.r || 8) + (t.radius || 16) * 0.58); + const pad = CONFIG.worldPadding || 30; + item.x = clamp((t.x || 0) + nx * dropR, pad, Math.max(pad, (worldRef?.w || item.x) - pad)); + item.y = clamp((t.y || 0) + ny * dropR + (wasOshibyo ? 4 : 0), pad, Math.max(pad, (worldRef?.h || item.y) - pad)); + } else if (carriedBall) { + const dx = Number(item.x || 0) - Number(carriedBall.x || 0); + const dy = Number(item.y || 0) - Number(carriedBall.y || 0); + const d = Math.hypot(dx, dy) || 1; + nx = dx / d; + ny = dy / d; } + const baseVx = Number(carriedBall?.vx || t?.vx || 0) || 0; + const baseVy = Number(carriedBall?.vy || t?.vy || 0) || 0; + item.vx = clamp(baseVx * 0.45 + nx * 18, -90, 90); + item.vy = clamp(baseVy * 0.45 + Math.max(12, ny * 18), -60, 110); + item.spinVelocity = clamp((item.spinVelocity || 0) * 0.35 + (Math.sign(nx) || 1) * 0.9, -2.2, 2.2); worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); - if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); + if (reason === "fall" && t) worldRef?.log?.(`${t.name}の画鋲が抜け落ちた。`, "observe", { participants: [t] }); } - if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { worldRef.spawnBurstZunchi(t || item, storedZunchi, item); } } - global.TarinaiItemDynamicPinSystem = Object.freeze({ update, isPin, tryStick, attach, updateLodged, detach }); + global.TarinaiItemDynamicPinSystem = Object.freeze({ + update, + isPin, + tryStick, + attach, + updateLodged, + detach, + attachToBall, + transferFromBallToTarinai, + transferBallPinsToTarinai, + tryBallPickup, + updateBallLodged, + }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_effect_definitions.js b/js/item_effect_definitions.js new file mode 100644 index 0000000..f2c6ac0 --- /dev/null +++ b/js/item_effect_definitions.js @@ -0,0 +1,282 @@ +"use strict"; + +// Item effect behavior, timers, and display metadata. +(function (global) { + const rawDefinitions = { + laxative: { + label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60, + tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`, + }, + ammo: { + label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18, + tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`, + }, + protein: { + label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d", + }, + niteropu: { + label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d", + }, + mercury: { + label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply", + tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`, + }, + giant_drug: { + label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", + }, + dwarf_drug: { + label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", + }, + love_mochi: { + label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`, + }, + fight_mochi: { + label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`, + }, + sleep_drug: { + label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"], + }, + mystery_drug: { + label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"], + }, + zunda_juice: { + label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"], + }, + disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] }, + disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] }, + }; + + Object.assign(rawDefinitions.laxative, { + modifiers: { hungerReliefMultiplier: 0 }, + tickInterval: 3, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.ammo, { + modifiers: { speedMultiplier: 10 }, + visualEffectId: "fall", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.protein, { + modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 }, + visualEffectId: "fall_up_red", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.niteropu, { + modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 }, + visualEffectId: "fall_down_purple", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.mercury, { + modifiers: { lifeMultiplierPerUse: 1.3 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.giant_drug, { + modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.dwarf_drug, { + modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) { + Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" }); + } + Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false }); + + Object.assign(rawDefinitions.love_mochi, { + onApply(tarinai, context = {}) { + const scale = context.scale ?? 1; + tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + deterministicRange(tarinai.world, "love-mochi-duration", 4, 12, tarinai, context.item)); + const nutrition = Number(context.nutrition) || 0; + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); + if (context.source === "eating") { + if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); + } + return true; + }, + }); + Object.assign(rawDefinitions.fight_mochi, { + onApply(tarinai, context = {}) { + const scale = context.scale ?? 1; + tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + deterministicRange(tarinai.world, "fight-mochi-duration", 10, 18, tarinai, context.item)); + const nutrition = Number(context.nutrition) || 0; + if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); + if (context.source === "eating") { + tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); + if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); + } + return true; + }, + }); + Object.assign(rawDefinitions.sleep_drug, { + onApply(tarinai, context = {}) { + if (context.source === "eating") { + const nutrition = Number(context.nutrition) || 0; + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 }); + tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100)); + tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); + tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); + const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); + const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; + if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); + if (!tarinai.sleepDisease && deterministicChance(tarinai.world, "sleep-drug-disease", Math.min(0.98, sleepChance), tarinai, context.item)) return tarinai.infectSleepDisease?.(context.item || null) || true; + return true; + } + return tarinai.infectSleepDisease?.(context.item || null) || true; + }, + }); + Object.assign(rawDefinitions.laxative, { + onApply(tarinai, context = {}) { + return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1))); + }, + onTick(tarinai) { + tarinai.forceLaxativePoop?.(); + return true; + }, + }); + Object.assign(rawDefinitions.ammo, { + onApply(tarinai, context = {}) { + return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1))); + }, + }); + Object.assign(rawDefinitions.protein, { + onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); }, + }); + Object.assign(rawDefinitions.niteropu, { + onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); }, + }); + Object.assign(rawDefinitions.mercury, { + onApply(tarinai) { return tarinai.setMercuryEffect?.(); }, + }); + Object.assign(rawDefinitions.giant_drug, { + onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); }, + onTick(tarinai) { + tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); + if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25); + return true; + }, + }); + Object.assign(rawDefinitions.dwarf_drug, { + onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); }, + onTick(tarinai) { + tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); + if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25); + return true; + }, + }); + Object.assign(rawDefinitions.zunda_juice, { + onApply(tarinai) { return tarinai.clearAllItemEffects?.(); }, + }); + Object.assign(rawDefinitions.disease_zunchi, { + onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; }, + }); + Object.assign(rawDefinitions.disease_sleep, { + onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; }, + }); + Object.assign(rawDefinitions.disease_explosion, { + onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; }, + }); + Object.assign(rawDefinitions.disease_fight, { + onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; }, + }); + + class EffectRegistry extends global.TarinaiRegistryBase { + label(id = "") { return super.label(id, id || "\u4e0d\u660e"); } + color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } + display(id = "", timer = 0, tarinai = null) { + const def = this.get(id); + if (!def) return ""; + return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || ""); + } + apply(id = "", tarinai = null, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onApply !== "function") return false; + return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this })); + } + tick(id = "", tarinai = null, dt = 0, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onTick !== "function") return false; + return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this })); + } + remove(id = "", tarinai = null, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onRemove !== "function") return false; + return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this })); + } + modifier(id = "", key = "", fallback = 1) { + const value = this.get(id)?.modifiers?.[key]; + return Number.isFinite(value) ? value : fallback; + } + timerValue(tarinai = null, id = "") { + const def = this.get(id); + if (!def || !tarinai) return 0; + if (def.timerProp) return Number(tarinai[def.timerProp] || 0); + if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0); + return 0; + } + isActive(tarinai = null, id = "") { + if (!tarinai) return false; + const def = this.get(id); + if (!def) return false; + if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id; + if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id; + if (id === "mercury") return tarinai.lifeItemMode === "mercury"; + return this.timerValue(tarinai, id) > 0.04; + } + activeSummary(tarinai = null) { + const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"]; + return order + .filter(id => this.isActive(tarinai, id)) + .map(id => ({ + id, + label: this.label(id), + detail: this.display(id, this.timerValue(tarinai, id), tarinai), + })); + } + labels() { return super.labels(); } + colors() { return super.colors(); } + randomPool() { + const itemEffects = [...this.defs.entries()] + .filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease"))) + .map(([id]) => id) + .filter(id => id !== "mystery_drug" && id !== "zunda_juice"); + const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || []; + return [...new Set([...itemEffects, ...diseases])]; + } + permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); } + timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); } + debugSnapshot() { + return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" })); + } + } + + const registry = new EffectRegistry(rawDefinitions); + global.TarinaiEffectRegistry = registry; + global.TarinaiEffectDefinitions = Object.freeze(rawDefinitions); +})(typeof window !== "undefined" ? window : globalThis); + diff --git a/js/item_food_definitions.js b/js/item_food_definitions.js new file mode 100644 index 0000000..1994181 --- /dev/null +++ b/js/item_food_definitions.js @@ -0,0 +1,59 @@ +"use strict"; + +// Food and medicine consumption metadata. + +(function (global) { + const rawDefinitions = { + food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 }, + sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] }, + love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" }, + fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" }, + sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" }, + laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true }, + protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" }, + niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" }, + ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" }, + mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" }, + mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" }, + giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" }, + dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" }, + zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, + grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + water: { label: "\u6c34\u6ef4", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } + }; + + class FoodRegistry extends global.TarinaiRegistryBase { + servingTypes() { + return [...this.defs.entries()] + .filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest)) + .map(([id]) => id); + } + typesByInterest(...interests) { + const wanted = new Set(interests.flat().filter(Boolean)); + return [...this.defs.entries()] + .filter(([, def]) => wanted.has(def.interest)) + .map(([id]) => id); + } + paramEffectTypes() { + return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id); + } + nutrition(type = "", fallback = 0) { + const value = this.get(type)?.nutrition; + return Number.isFinite(value) ? value : fallback; + } + hungerRelief(type = "", fallback = 0) { + const value = this.get(type)?.hungerRelief; + return Number.isFinite(value) ? value : fallback; + } + effectId(type = "") { return this.get(type)?.effectId || ""; } + passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; } + hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; } + debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); } + } + + const registry = new FoodRegistry(rawDefinitions); + global.TarinaiFoodRegistry = registry; + global.TarinaiFoodDefinitions = Object.freeze(rawDefinitions); +})(typeof window !== "undefined" ? window : globalThis); + diff --git a/js/item_lifecycle_legacy_system.js b/js/item_lifecycle_legacy_system.js index 627eb7b..2cd3056 100644 --- a/js/item_lifecycle_legacy_system.js +++ b/js/item_lifecycle_legacy_system.js @@ -1,95 +1,12 @@ "use strict"; // Layer: entity-runtime/items/legacy-lifecycle -// Compatibility fallback for the pre-pipeline monolithic Item update body. -// Normal runtime goes through item_lifecycle_pipeline.js; this exists only so -// old callers and partial-load environments have a stable fallback. +// Compatibility shim. The former monolithic fallback was removed so item +// behavior has a single source of truth: TarinaiItemLifecyclePipeline. (function (global) { function updateOne(item, dt, worldRef = global.world) { - item.age += dt; - if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt); - if (item.dropTimer > 0) { - const beforeDrop = item.dropTimer; - item.dropTimer = Math.max(0, item.dropTimer - dt); - if (beforeDrop > 0 && item.dropTimer <= 0 && !item.dropImpactDone) { - item.dropImpactDone = true; - if (worldRef.itemDropImpact) worldRef.itemDropImpact(item); - } - } - if (item.type === "water") item.amount -= dt * 0.9; - if (isServingFoodType(item.type)) { - const interval = passiveFoodDecayInterval(worldRef); - item.passiveFoodDecayTimer = (item.passiveFoodDecayTimer || 0) + dt; - let ticks = 0; - while (item.passiveFoodDecayTimer >= interval && ticks < 3) { - item.passiveFoodDecayTimer -= interval; - ticks++; - if (Number.isFinite(item.foodServingsRemaining)) { - const before = Math.max(0, item.foodServingsRemaining || 0); - const lost = Math.min(before, interval * passiveFoodDecayRate(item)); - if (lost > 0) { - item.foodServingsRemaining = Math.max(0, before - lost); - item.amount = item.foodServingsRemaining; - const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING; - worldRef?.registerFoodSpoilage?.(lost * penalty, item); - worldRef?.markTerrainDirty?.("food-passive-decay"); - worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true }); - if (item.foodServingsRemaining <= 0.015) item.amount = 0; - } - } else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(item.type)) { - const before = item.amount || 0; - const lost = Math.min(before, interval * 0.12); - item.amount -= lost; - if (lost > 0) { - const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? 0.002; - worldRef?.registerFoodSpoilage?.(lost * penalty, item); - worldRef?.markTerrainDirty?.("food-passive-decay"); - worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true }); - } - } - } - } - if (item.type === "trace") item.amount -= dt * 1.35; - if (item.type === "splat") item.amount -= dt * 1.05; - if (item.type === "ant_corpse") item.amount -= dt * 0.018; - if (item.type === "firecracker") { - item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt); - if (item.fuseTimer <= 0) { - if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(item); - else item.amount = 0; - } - } - if (item.type === "genkotsu") { - item.impactFlash = Math.max(0, (item.impactFlash || 0) - dt); - if (item.dropImpactDone) { - item.lingerTimer = Math.max(0, (item.lingerTimer || 0) - dt); - if ((item.lingerTimer || 0) <= 0) item.amount = 0; - } else { - item.amount = Math.max(item.amount || 0, 100); - } - } - if (item.type === "ball") item.updateBall(dt, worldRef); - if (item.type === "fan") item.updateFan(dt, worldRef); - if (item.type === "rotator") item.updateRotator(dt, worldRef); - if (item.type === "gate_fence") item.amount = 999; - if (item.type === "duplicator") item.updateDuplicator(dt, worldRef); - if (isPinType(item.type)) item.updatePushpin(dt, worldRef); - if (item.type === "zunchi") item.updateZunchiMotion(dt, worldRef); - if (item.type === "grass") { - // Grass is intentionally not simulated per item. World.update randomly - // advances one grass by one discrete stage every few seconds. - normalizeGrassStage(item); - } else if (item.type === "zunchi") { - item.lifecycleTimer += dt; - const interval = item.lifecycleInterval || (0.95 + stableUnit(item.id, "zunchi-life") * 0.45); - if (item.lifecycleTimer >= interval) { - const lifecycleDt = Math.min(item.lifecycleTimer, 2.4); - item.lifecycleTimer = 0; - item.updateZunchi(lifecycleDt, worldRef); - } - } - - return true; + if (!item || item.dead || !global.TarinaiItemLifecyclePipeline?.updateOne) return false; + return global.TarinaiItemLifecyclePipeline.updateOne(item, dt, worldRef) !== false; } global.TarinaiItemLifecycleLegacySystem = Object.freeze({ updateOne }); diff --git a/js/item_lifecycle_step_legacy.js b/js/item_lifecycle_step_legacy.js index 6b02e6c..b70383a 100644 --- a/js/item_lifecycle_step_legacy.js +++ b/js/item_lifecycle_step_legacy.js @@ -1,14 +1,12 @@ "use strict"; // Layer: entity-runtime/items/lifecycle-step -// Fallback step for environments where the split item lifecycle pipeline cannot -// run. The captured legacy Item.prototype.update is kept by item_runtime.js. +// Compatibility shim for old step lists. Do not call a captured monolithic +// update body; delegate to the concrete pipeline only. (function (global) { - function update(item, dt, worldRef, context = {}) { - const legacyUpdate = context.legacyUpdate; - if (!item || item.dead || typeof legacyUpdate !== "function") return { done: true, ok: false }; - legacyUpdate.call(item, dt, worldRef); - return { done: true, ok: true }; + function update(item, dt, worldRef) { + const ok = global.TarinaiItemLifecyclePipeline?.updateOne?.(item, dt, worldRef); + return { done: true, ok: ok !== false }; } global.TarinaiItemLegacyLifecycleStep = Object.freeze({ update }); diff --git a/js/item_registry.js b/js/item_registry.js index ddd59df..d3d428a 100644 --- a/js/item_registry.js +++ b/js/item_registry.js @@ -1,696 +1,18 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); - - -// Unified item definition source: tools, item traits, visuals, food metadata, and item effects. - -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" }, - 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" }, - undo: { id: "undo", label: "\u623b\u3059", placeable: false, scalable: false, iconText: "\u21b6", tooltip: "\u76f4\u524d\u306e\u64cd\u4f5c\u3092\u53d6\u308a\u6d88\u3059\u3002" }, - redo: { id: "redo", label: "\u9032\u3080", placeable: false, scalable: false, iconText: "\u21b7", tooltip: "\u53d6\u308a\u6d88\u3057\u305f\u64cd\u4f5c\u3092\u3084\u308a\u76f4\u3059\u3002" }, - poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, - pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u4e00\u90e8\u306e\u7f6e\u304d\u3082\u306e\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002" }, - new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, - water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, - rope: { id: "rope", label: "\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" }, - 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" }, - fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, - sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, - - laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" }, - protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" }, - niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" }, - ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" }, - mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" }, - mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" }, - giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" }, - dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" }, - zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" }, - grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, - stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, - genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" }, - bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, - nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, - 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: "\u98df\u3079\u7269\u30fb\u85ac\u30fb\u753b\u92f2\u30fb\u304a\u3057\u308a\u92f2\u3092\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002\u92f2\u7cfb\u306f\u89e6\u308c\u305f\u305f\u308a\u306a\u3044\u306b\u523a\u3055\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" }, - fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, - fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, - bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, - bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, - gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, - rotator: { id: "rotator", itemType: "rotator", label: "\u56de\u8ee2\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21bb", collisionShape: "custom_rotator", tooltip: "\u56de\u8ee2\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" }, - poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" }, - reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" }, - water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" }, - trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, - splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true }, - food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true }, - ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true }, -}); - - -const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), - food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), - hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), - pin: new Set(["pushpin", "oshibyo"]), - kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), - sleepFurniture: new Set(["bed", "nest_box"]), - draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "sprinkler", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), -}); - - -const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"])); -const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"])); -const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"])); -const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]); -const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES])); -function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); } -function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); } -function isLinkItemType(type = "") { return LINK_ITEM_TYPES.has(String(type || "")); } -function isPhysicsToolId(id = "") { return PHYSICS_TOOL_IDS.includes(String(id || "")); } -function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); } -function defaultItemAngle(type = "") { - const t = String(type || ""); - if (t === "fence_v" || t === "bounce_fence_v") return Math.PI / 2; - return 0; -} -function normalizedItemAngle(angle = 0, fallback = 0) { - const twoPi = Math.PI * 2; - const n = Number(angle); - const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0); - return ((base % twoPi) + twoPi) % twoPi; -} -function itemAngleFor(itemOrType = "") { - if (itemOrType && typeof itemOrType === "object") return normalizedItemAngle(itemOrType.angle, defaultItemAngle(itemOrType.type)); - return defaultItemAngle(String(itemOrType || "")); -} -function rotatableToolIds() { - return Object.values(TOOL_DEFINITIONS).filter(def => isRotatableItemType(def.itemType || def.id)).map(def => def.id); -} - -function itemHasTrait(type = "", trait = "") { - return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || ""))); -} - -function isPinType(type = "") { return itemHasTrait(type, "pin"); } -function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } -function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } -function lodgedPinFor(tarinai = null, worldRef = null) { - if (!tarinai?.stuckPushpinId) return null; - const items = worldRef?.items || tarinai.world?.items || []; - return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null; -} -function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); } -function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); } - -const PIN_BEHAVIORS = Object.freeze({ - pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, - oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true }, -}); - -function pinBehaviorFor(type = "") { - return PIN_BEHAVIORS[String(type || "")] || null; -} - - -let ITEM_VISUAL_DEFINITIONS = null; -let FOOD_REGISTRY = null; -let FOOD_DEFINITIONS = null; -let EFFECT_REGISTRY = null; -let EFFECT_DEFINITIONS = null; - -const TOOL_CATEGORIES = Object.freeze([ - { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose"] }, - { id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] }, - { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, - { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "sprinkler", "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"] }, -]); - -function toolCategories() { - return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0)); -} - -function toolDefinition(id = "") { - return TOOL_DEFINITIONS[id] || null; -} - -function toolLabel(id = "") { - const def = toolDefinition(id); - return def?.label || id || ""; -} - -function toolItemType(id = "") { - const def = toolDefinition(id); - return def?.placeable ? (def.itemType || def.id) : null; -} - -function itemDefinition(type = "") { - return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null; -} - -function itemRadiusFor(type = "", fallback = 12) { - return itemDefinition(type)?.radius ?? fallback; -} - -function itemAmountFor(type = "", fallback = 80) { - return itemDefinition(type)?.amount ?? fallback; -} - -const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); -const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }); -const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 }); -function normalizedToolSizeFor(worldRef = null, type = "") { - const def = itemDefinition(type); - if (!def?.scalable) return "medium"; - const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium"; - return TOOL_SIZE_ORDER.includes(value) ? value : "medium"; -} -function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; } -function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; } - -function scalableToolIds() { - return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); -} - -function toolTipsFromDefinitions() { - return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip])); -} - -(function (global) { - const raw = String.raw`{ - "water": { - "renderer": "oval_droplet", - "shape": "horizontal_oval_liquid", - "palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"] - }, - "zunda_juice": { - "renderer": "oval_droplet", - "shape": "horizontal_oval_liquid", - "palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"] - }, - "mercury": { - "renderer": "oval_droplet", - "shape": "horizontal_oval_liquid", - "palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] - }, - "sleep_drug": { - "renderer": "sleep_tablet", - "shape": "moon_tablet", - "palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["tablet", "moon", "purple"] - }, - "laxative": { - "renderer": "laxative_tablet", - "shape": "rounded_tablet_zunchi_motif", - "palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["tablet", "zunchi motif", "tan"] - }, - "mystery_drug": { - "renderer": "mystery_tablet", - "shape": "question_mark_tablet", - "palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["tablet", "question mark", "purple"] - }, - "ammo": { - "renderer": "bullet", - "shape": "bullet", - "palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["bullet", "gold"] - }, - "protein": { - "renderer": "powder_pile", - "shape": "powder_pile", - "palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["powder", "orange"] - }, - "niteropu": { - "renderer": "powder_pile", - "shape": "powder_pile", - "palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["powder", "purple"] - }, - "giant_drug": { - "renderer": "split_pill", - "shape": "split_pill", - "palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" }, - "scale": 1.08, - "uiPreviewScale": 1.08, - "fieldScale": 1.08, - "accessibleShapeTraits": ["pill", "large", "orange"] - }, - "dwarf_drug": { - "renderer": "split_pill", - "shape": "split_pill", - "palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" }, - "scale": 0.92, - "uiPreviewScale": 0.92, - "fieldScale": 0.92, - "accessibleShapeTraits": ["pill", "small", "purple"] - }, - "ball": { - "renderer": "emoji", - "shape": "soccer_ball", - "palette": {}, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["round", "soccer ball"], - "emojiFallback": "\u26bd" - } - }`; - - const defs = Object.freeze(JSON.parse(raw)); - - function itemVisualDefinition(type = "") { - return defs[String(type || "")] || null; - } - - function itemVisualPalette(type = "") { - return itemVisualDefinition(type)?.palette || null; - } - - ITEM_VISUAL_DEFINITIONS = defs; - global.itemVisualDefinition = itemVisualDefinition; - global.itemVisualPalette = itemVisualPalette; -})(typeof window !== "undefined" ? window : globalThis); - -(function (global) { - const rawDefinitions = { - food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 }, - sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] }, - love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" }, - fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" }, - sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" }, - laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true }, - protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" }, - niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" }, - ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" }, - mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" }, - mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" }, - giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" }, - dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" }, - zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, - grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, - water: { label: "\u6c34\u6ef4", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, - ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } - }; - - class FoodRegistry extends global.TarinaiRegistryBase { - servingTypes() { - return [...this.defs.entries()] - .filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest)) - .map(([id]) => id); - } - typesByInterest(...interests) { - const wanted = new Set(interests.flat().filter(Boolean)); - return [...this.defs.entries()] - .filter(([, def]) => wanted.has(def.interest)) - .map(([id]) => id); - } - paramEffectTypes() { - return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id); - } - nutrition(type = "", fallback = 0) { - const value = this.get(type)?.nutrition; - return Number.isFinite(value) ? value : fallback; - } - hungerRelief(type = "", fallback = 0) { - const value = this.get(type)?.hungerRelief; - return Number.isFinite(value) ? value : fallback; - } - effectId(type = "") { return this.get(type)?.effectId || ""; } - passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; } - hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; } - debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); } - } - - const registry = new FoodRegistry(rawDefinitions); - FOOD_REGISTRY = registry; - FOOD_DEFINITIONS = Object.freeze(rawDefinitions); -})(typeof window !== "undefined" ? window : globalThis); - -(function (global) { - const rawDefinitions = { - laxative: { - label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60, - tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`, - }, - ammo: { - label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18, - tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`, - }, - protein: { - label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power", - tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d", - }, - niteropu: { - label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power", - tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d", - }, - mercury: { - label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply", - tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`, - }, - giant_drug: { - label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size", - tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", - }, - dwarf_drug: { - label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size", - tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", - }, - love_mochi: { - label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer", - tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`, - }, - fight_mochi: { - label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer", - tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`, - }, - sleep_drug: { - label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"], - }, - mystery_drug: { - label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"], - }, - zunda_juice: { - label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"], - }, - disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] }, - disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] }, - disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] }, - disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] }, - }; - - Object.assign(rawDefinitions.laxative, { - modifiers: { hungerReliefMultiplier: 0 }, - tickInterval: 3, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.ammo, { - modifiers: { speedMultiplier: 10 }, - visualEffectId: "fall", - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.protein, { - modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 }, - visualEffectId: "fall_up_red", - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.niteropu, { - modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 }, - visualEffectId: "fall_down_purple", - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.mercury, { - modifiers: { lifeMultiplierPerUse: 1.3 }, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.giant_drug, { - modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 }, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.dwarf_drug, { - modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 }, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) { - Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" }); - } - Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false }); - - Object.assign(rawDefinitions.love_mochi, { - onApply(tarinai, context = {}) { - const scale = context.scale ?? 1; - tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + deterministicRange(tarinai.world, "love-mochi-duration", 4, 12, tarinai, context.item)); - const nutrition = Number(context.nutrition) || 0; - if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); - if (context.source === "eating") { - if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); - } - return true; - }, - }); - Object.assign(rawDefinitions.fight_mochi, { - onApply(tarinai, context = {}) { - const scale = context.scale ?? 1; - tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + deterministicRange(tarinai.world, "fight-mochi-duration", 10, 18, tarinai, context.item)); - const nutrition = Number(context.nutrition) || 0; - if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); - if (context.source === "eating") { - tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); - if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); - } - return true; - }, - }); - Object.assign(rawDefinitions.sleep_drug, { - onApply(tarinai, context = {}) { - if (context.source === "eating") { - const nutrition = Number(context.nutrition) || 0; - if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 }); - tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100)); - tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); - tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); - const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); - const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; - if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); - if (!tarinai.sleepDisease && deterministicChance(tarinai.world, "sleep-drug-disease", Math.min(0.98, sleepChance), tarinai, context.item)) return tarinai.infectSleepDisease?.(context.item || null) || true; - return true; - } - return tarinai.infectSleepDisease?.(context.item || null) || true; - }, - }); - Object.assign(rawDefinitions.laxative, { - onApply(tarinai, context = {}) { - return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1))); - }, - onTick(tarinai) { - tarinai.forceLaxativePoop?.(); - return true; - }, - }); - Object.assign(rawDefinitions.ammo, { - onApply(tarinai, context = {}) { - return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1))); - }, - }); - Object.assign(rawDefinitions.protein, { - onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); }, - }); - Object.assign(rawDefinitions.niteropu, { - onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); }, - }); - Object.assign(rawDefinitions.mercury, { - onApply(tarinai) { return tarinai.setMercuryEffect?.(); }, - }); - Object.assign(rawDefinitions.giant_drug, { - onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); }, - onTick(tarinai) { - tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); - if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25); - return true; - }, - }); - Object.assign(rawDefinitions.dwarf_drug, { - onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); }, - onTick(tarinai) { - tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); - if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25); - return true; - }, - }); - Object.assign(rawDefinitions.zunda_juice, { - onApply(tarinai) { return tarinai.clearAllItemEffects?.(); }, - }); - Object.assign(rawDefinitions.disease_zunchi, { - onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; }, - }); - Object.assign(rawDefinitions.disease_sleep, { - onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; }, - }); - Object.assign(rawDefinitions.disease_explosion, { - onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; }, - }); - Object.assign(rawDefinitions.disease_fight, { - onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; }, - }); - - class EffectRegistry extends global.TarinaiRegistryBase { - label(id = "") { return super.label(id, id || "\u4e0d\u660e"); } - color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } - display(id = "", timer = 0, tarinai = null) { - const def = this.get(id); - if (!def) return ""; - return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || ""); - } - apply(id = "", tarinai = null, context = {}) { - const def = this.get(id); - if (!def || !tarinai || typeof def.onApply !== "function") return false; - return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this })); - } - tick(id = "", tarinai = null, dt = 0, context = {}) { - const def = this.get(id); - if (!def || !tarinai || typeof def.onTick !== "function") return false; - return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this })); - } - remove(id = "", tarinai = null, context = {}) { - const def = this.get(id); - if (!def || !tarinai || typeof def.onRemove !== "function") return false; - return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this })); - } - modifier(id = "", key = "", fallback = 1) { - const value = this.get(id)?.modifiers?.[key]; - return Number.isFinite(value) ? value : fallback; - } - timerValue(tarinai = null, id = "") { - const def = this.get(id); - if (!def || !tarinai) return 0; - if (def.timerProp) return Number(tarinai[def.timerProp] || 0); - if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0); - return 0; - } - isActive(tarinai = null, id = "") { - if (!tarinai) return false; - const def = this.get(id); - if (!def) return false; - if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id; - if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id; - if (id === "mercury") return tarinai.lifeItemMode === "mercury"; - return this.timerValue(tarinai, id) > 0.04; - } - activeSummary(tarinai = null) { - const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"]; - return order - .filter(id => this.isActive(tarinai, id)) - .map(id => ({ - id, - label: this.label(id), - detail: this.display(id, this.timerValue(tarinai, id), tarinai), - })); - } - labels() { return super.labels(); } - colors() { return super.colors(); } - randomPool() { - const itemEffects = [...this.defs.entries()] - .filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease"))) - .map(([id]) => id) - .filter(id => id !== "mystery_drug" && id !== "zunda_juice"); - const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || []; - return [...new Set([...itemEffects, ...diseases])]; - } - permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); } - timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); } - debugSnapshot() { - return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" })); - } - } - - const registry = new EffectRegistry(rawDefinitions); - EFFECT_REGISTRY = registry; - EFFECT_DEFINITIONS = Object.freeze(rawDefinitions); -})(typeof window !== "undefined" ? window : globalThis); - +// Public facade joining the split item registry sources. (function (global) { function itemTypeDefinition(type = "") { const id = String(type || ""); - const tool = typeof itemDefinition === "function" ? itemDefinition(id) : null; - const food = FOOD_DEFINITIONS?.[id] || null; - const effectId = food?.effectId || (EFFECT_DEFINITIONS?.[id] ? id : ""); - const effect = effectId ? (EFFECT_DEFINITIONS?.[effectId] || null) : null; - const visual = ITEM_VISUAL_DEFINITIONS?.[id] || null; + const tool = typeof global.itemDefinition === "function" ? global.itemDefinition(id) : null; + const foodDefinitions = global.TarinaiFoodDefinitions || {}; + const effectDefinitions = global.TarinaiEffectDefinitions || {}; + const visualDefinitions = global.TarinaiItemVisualDefinitions || {}; + const traits = global.ITEM_TRAITS || global.TarinaiToolRegistry?.traits || {}; + const food = foodDefinitions[id] || null; + const effectId = food?.effectId || (effectDefinitions[id] ? id : ""); + const effect = effectId ? (effectDefinitions[effectId] || null) : null; + const visual = visualDefinitions[id] || null; return { id, tool, @@ -703,7 +25,7 @@ function toolTipsFromDefinitions() { amount: tool?.amount, placeable: Boolean(tool?.placeable), scalable: Boolean(tool?.scalable), - traits: Object.fromEntries(Object.keys(ITEM_TRAITS).map(trait => [trait, itemHasTrait(id, trait)])), + traits: Object.fromEntries(Object.keys(traits).map(trait => [trait, typeof global.itemHasTrait === "function" ? global.itemHasTrait(id, trait) : false])), }; } @@ -712,45 +34,41 @@ function toolTipsFromDefinitions() { } function itemTypeDebugSnapshot() { + const toolDefinitions = global.TOOL_DEFINITIONS || global.TarinaiToolRegistry?.definitions || {}; + const foodDefinitions = global.TarinaiFoodDefinitions || {}; + const effectDefinitions = global.TarinaiEffectDefinitions || {}; + const visualDefinitions = global.TarinaiItemVisualDefinitions || {}; const ids = new Set([ - ...Object.values(TOOL_DEFINITIONS).map(def => def.itemType || def.id), - ...Object.keys(FOOD_DEFINITIONS || {}), - ...Object.keys(EFFECT_DEFINITIONS || {}), - ...Object.keys(ITEM_VISUAL_DEFINITIONS || {}), + ...Object.values(toolDefinitions).map(def => def.itemType || def.id), + ...Object.keys(foodDefinitions), + ...Object.keys(effectDefinitions), + ...Object.keys(visualDefinitions), ]); return [...ids].sort().map(id => itemTypeDefinition(id)); } + global.itemTypeDefinition = itemTypeDefinition; + global.itemTypeLabel = itemTypeLabel; + global.itemTypeDebugSnapshot = itemTypeDebugSnapshot; + global.TarinaiItemRegistry = Object.freeze({ definition: itemTypeDefinition, label: itemTypeLabel, debugSnapshot: itemTypeDebugSnapshot, - tool: toolDefinition, - item: itemDefinition, - food: FOOD_REGISTRY, - effect: EFFECT_REGISTRY, - visual: itemVisualDefinition, - hasTrait: itemHasTrait, - radius: itemRadiusFor, - amount: itemAmountFor, - categories: toolCategories, - physicsToolIds: () => [...PHYSICS_TOOL_IDS], - isFence: isFenceItemType, - isMechanical: isMechanicalItemType, - isLink: isLinkItemType, - scalableToolIds, - pinBehavior: pinBehaviorFor, + tool: global.toolDefinition, + item: global.itemDefinition, + food: global.TarinaiFoodRegistry, + effect: global.TarinaiEffectRegistry, + visual: global.itemVisualDefinition, + hasTrait: global.itemHasTrait, + radius: global.itemRadiusFor, + amount: global.itemAmountFor, + categories: global.toolCategories, + physicsToolIds: () => [...(global.PHYSICS_TOOL_IDS || [])], + isFence: global.isFenceItemType, + isMechanical: global.isMechanicalItemType, + isLink: global.isLinkItemType, + scalableToolIds: global.scalableToolIds, + pinBehavior: global.pinBehaviorFor, }); })(typeof window !== "undefined" ? window : globalThis); - -if (typeof window !== "undefined") { - window.isFenceItemType = isFenceItemType; - window.isMechanicalItemType = isMechanicalItemType; - window.isLinkItemType = isLinkItemType; - window.isPhysicsToolId = isPhysicsToolId; - window.isRotatableItemType = isRotatableItemType; - window.defaultItemAngle = defaultItemAngle; - window.normalizedItemAngle = normalizedItemAngle; - window.itemAngleFor = itemAngleFor; - window.rotatableToolIds = rotatableToolIds; -} diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index 107bf76..173723d 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -78,7 +78,7 @@ draw(ctx, t, lighting = null) { const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55); if (isPinType(this.type) && this.pinState === "lodged") { const owner = world?.liveTarinaiById?.(this.pinTargetId) || null; - if (owner?.insideNestBoxId) return; + if (owner && world?.isTarinaiHiddenInNestBox?.(owner)) return; } const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null; const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1); @@ -590,21 +590,25 @@ draw(ctx, t, lighting = null) { } else if (this.type === "ball") { const speed = Math.hypot(this.vx || 0, this.vy || 0); const moving = clamp(speed / 260, 0, 1); + const r = this.r || 18; ctx.save(); ctx.rotate(this.spin || 0); ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)"; ctx.shadowBlur = 2 + moving * 3; ctx.shadowOffsetY = 2; - ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; + // Use the original soccer-ball emoji as the visible sprite. The + // collision center remains item.x/item.y; rendering is centered via + // textAlign/textBaseline rather than replacing the emoji with vector art. + ctx.font = `${Math.max(18, r * 2.05)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText("\u26bd", 0, 1); + ctx.fillText("⚽", 0, 0); if (moving > 0.55) { - ctx.globalAlpha = 0.14 + moving * 0.10; + ctx.globalAlpha *= 0.10 + moving * 0.10; ctx.shadowColor = "transparent"; ctx.fillStyle = "#ffffff"; ctx.beginPath(); - ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2); + ctx.ellipse(-r * 0.42, -r * 0.45, r * 0.25, r * 0.12, -0.45, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); @@ -727,15 +731,8 @@ draw(ctx, t, lighting = null) { ctx.globalAlpha *= powered ? 1 : 0.72; ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5"; ctx.lineWidth = thick; - ctx.shadowColor = powered ? (styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)") : "rgba(88,80,96,0.20)"; - ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.16) : Math.max(4, thick * 0.28)); - strokeSegments(ctx, this, segments, "body"); ctx.shadowBlur = 0; - if (tier !== "low") { - ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)"; - ctx.lineWidth = Math.max(1.4, thick * 0.22); - strokeSegments(ctx, this, segments, "body"); - } + strokeSegments(ctx, this, segments, "body"); ctx.restore(); ctx.fillStyle = powered ? (styleNight ? "#3c324e" : "#f4eaff") : (styleNight ? "#34343a" : "#ece8ef"); ctx.strokeStyle = powered ? (styleNight ? "#a28cc8" : "#6e4fa4") : "rgba(92,88,98,0.82)"; @@ -762,28 +759,12 @@ draw(ctx, t, lighting = null) { ctx.globalAlpha *= enabled ? 1 : 0.48; ctx.strokeStyle = styleNight ? "#4d8e4e" : "#5cac4d"; ctx.lineWidth = thick; - ctx.shadowColor = styleNight ? "rgba(92,190,86,0.34)" : "rgba(62,170,54,0.28)"; - ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.18) : Math.max(4, thick * 0.32)); - strokeSegments(ctx, this, segments, "body"); ctx.shadowBlur = 0; + strokeSegments(ctx, this, segments, "body"); ctx.setLineDash([]); - if (tier !== "low") { - ctx.strokeStyle = styleNight ? "rgba(226,255,218,0.46)" : "rgba(255,255,255,0.50)"; - ctx.lineWidth = Math.max(1.4, thick * 0.22); - strokeSegments(ctx, this, segments, "body"); - } ctx.restore(); - ctx.fillStyle = styleNight ? "#263b27" : "#efffe9"; - ctx.strokeStyle = styleNight ? "#80c56e" : "#3e8f35"; - ctx.lineWidth = 2.2; - ctx.beginPath(); - ctx.arc(0, 0, Math.max(8, thick * 0.74), 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = styleNight ? "#d8ffd0" : "#26711f"; - ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("\u6bd2", 0, 0); + // v39.15.53: 毒ブロックは物理輪郭だけを安定描画する。 + // 中央の塗りつぶし/文字はキャッシュ更新時に明滅して見えるため描画しない。 } else if (this.type === "reciprocator") { const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); const pb = window.TarinaiPhysicsBodySystem; diff --git a/js/item_tool_definitions.js b/js/item_tool_definitions.js new file mode 100644 index 0000000..f6f3ea9 --- /dev/null +++ b/js/item_tool_definitions.js @@ -0,0 +1,255 @@ +"use strict"; + +// Tool definitions, item traits, categories, and basic type helpers. +(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: "選択中の道具ボタンを解除し、通常の観察状態に戻す。" }, + 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" }, + undo: { id: "undo", label: "\u623b\u3059", placeable: false, scalable: false, iconText: "\u21b6", tooltip: "\u76f4\u524d\u306e\u64cd\u4f5c\u3092\u53d6\u308a\u6d88\u3059\u3002" }, + redo: { id: "redo", label: "\u9032\u3080", placeable: false, scalable: false, iconText: "\u21b7", tooltip: "\u53d6\u308a\u6d88\u3057\u305f\u64cd\u4f5c\u3092\u3084\u308a\u76f4\u3059\u3002" }, + poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, + pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u4e00\u90e8\u306e\u7f6e\u304d\u3082\u306e\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002" }, + new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, + water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, + rope: { id: "rope", label: "\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" }, + 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" }, + fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, + + laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" }, + protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" }, + niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" }, + ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" }, + mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" }, + mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" }, + giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" }, + dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" }, + zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" }, + grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, + stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, + genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" }, + bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, + nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, + 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: "\u98df\u3079\u7269\u30fb\u85ac\u30fb\u753b\u92f2\u30fb\u304a\u3057\u308a\u92f2\u3092\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002\u92f2\u7cfb\u306f\u89e6\u308c\u305f\u305f\u308a\u306a\u3044\u306b\u523a\u3055\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" }, + fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, + rotator: { id: "rotator", itemType: "rotator", label: "\u56de\u8ee2\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21bb", collisionShape: "custom_rotator", tooltip: "\u56de\u8ee2\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" }, + poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" }, + reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" }, + water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" }, + trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, + splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true }, + food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true }, + ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true }, +}); + + +const ITEM_TRAITS = Object.freeze({ + obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), + food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), + hazard: new Set(["firecracker", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), + pin: new Set(["pushpin", "oshibyo"]), + kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), + sleepFurniture: new Set(["bed", "nest_box", "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"]), +}); + + +const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"])); +const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"])); +const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"])); +const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]); +const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES])); +function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); } +function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); } +function isLinkItemType(type = "") { return LINK_ITEM_TYPES.has(String(type || "")); } +function isPhysicsToolId(id = "") { return PHYSICS_TOOL_IDS.includes(String(id || "")); } +function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); } +function defaultItemAngle(type = "") { + const t = String(type || ""); + if (t === "fence_v" || t === "bounce_fence_v") return Math.PI / 2; + return 0; +} +function normalizedItemAngle(angle = 0, fallback = 0) { + const twoPi = Math.PI * 2; + const n = Number(angle); + const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0); + return ((base % twoPi) + twoPi) % twoPi; +} +function itemAngleFor(itemOrType = "") { + if (itemOrType && typeof itemOrType === "object") return normalizedItemAngle(itemOrType.angle, defaultItemAngle(itemOrType.type)); + return defaultItemAngle(String(itemOrType || "")); +} +function rotatableToolIds() { + return Object.values(TOOL_DEFINITIONS).filter(def => isRotatableItemType(def.itemType || def.id)).map(def => def.id); +} + +function itemHasTrait(type = "", trait = "") { + return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || ""))); +} + +function isPinType(type = "") { return itemHasTrait(type, "pin"); } +function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } +function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } +function lodgedPinFor(tarinai = null, worldRef = null) { + if (!tarinai?.stuckPushpinId) return null; + const items = worldRef?.items || tarinai.world?.items || []; + return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null; +} +function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); } +function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); } + +const PIN_BEHAVIORS = Object.freeze({ + pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, + oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true }, +}); + +function pinBehaviorFor(type = "") { + return PIN_BEHAVIORS[String(type || "")] || null; +} + + +let ITEM_VISUAL_DEFINITIONS = null; +let FOOD_REGISTRY = null; +let FOOD_DEFINITIONS = null; +let EFFECT_REGISTRY = null; +let EFFECT_DEFINITIONS = null; + +const TOOL_CATEGORIES = Object.freeze([ + { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "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: "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"] }, +]); + +function toolCategories() { + return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0)); +} + +function toolDefinition(id = "") { + return TOOL_DEFINITIONS[id] || null; +} + +function toolLabel(id = "") { + const def = toolDefinition(id); + return def?.label || id || ""; +} + +function toolItemType(id = "") { + const def = toolDefinition(id); + return def?.placeable ? (def.itemType || def.id) : null; +} + +function itemDefinition(type = "") { + return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null; +} + +function itemRadiusFor(type = "", fallback = 12) { + return itemDefinition(type)?.radius ?? fallback; +} + +function itemAmountFor(type = "", fallback = 80) { + return itemDefinition(type)?.amount ?? fallback; +} + +const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); +const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }); +const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 }); +function normalizedToolSizeFor(worldRef = null, type = "") { + const def = itemDefinition(type); + if (!def?.scalable) return "medium"; + const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium"; + return TOOL_SIZE_ORDER.includes(value) ? value : "medium"; +} +function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; } +function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; } + +function scalableToolIds() { + return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); +} + +function toolTipsFromDefinitions() { + return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip])); +} + + + Object.assign(global, { + TOOL_DEFINITIONS, + ITEM_TRAITS, + FENCE_ITEM_TYPES, + MECHANICAL_ITEM_TYPES, + LINK_ITEM_TYPES, + PHYSICS_TOOL_IDS, + ROTATABLE_ITEM_TYPES, + isFenceItemType, + isMechanicalItemType, + isLinkItemType, + isPhysicsToolId, + isRotatableItemType, + defaultItemAngle, + normalizedItemAngle, + itemAngleFor, + rotatableToolIds, + itemHasTrait, + isPinType, + isSleepFurnitureType, + isLodgedPin, + lodgedPinFor, + lodgedPinBehaviorFor, + hasLodgedPinEffect, + PIN_BEHAVIORS, + pinBehaviorFor, + TOOL_CATEGORIES, + toolCategories, + toolDefinition, + toolLabel, + toolItemType, + itemDefinition, + itemRadiusFor, + itemAmountFor, + TOOL_SIZE_ORDER, + TOOL_SIZE_LABELS, + TOOL_SIZE_SCALES, + normalizedToolSizeFor, + toolSizeScaleForValue, + toolSizeScaleFor, + scalableToolIds, + toolTipsFromDefinitions, + }); + + global.TarinaiToolRegistry = Object.freeze({ + definitions: TOOL_DEFINITIONS, + traits: ITEM_TRAITS, + categories: toolCategories, + definition: toolDefinition, + label: toolLabel, + itemType: toolItemType, + item: itemDefinition, + radius: itemRadiusFor, + amount: itemAmountFor, + hasTrait: itemHasTrait, + physicsToolIds: () => [...PHYSICS_TOOL_IDS], + rotatableToolIds, + scalableToolIds, + pinBehavior: pinBehaviorFor, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 408ef63..927d53b 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -10,7 +10,7 @@ function initializeItemTypeState(item, type, x, y) { if (type === "water" || type === "zunda_juice") item.roles.drink = true; if (["sweet", "water", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true; - if (["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "splat"].includes(type)) item.roles.danger = true; + if (["firecracker", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true; if (type === "grass") item.roles.grassMaterial = true; if (type === "grass") { item.grassStage = Math.floor(rand(0, 2.999)); @@ -133,8 +133,8 @@ function initializeItemTypeState(item, type, x, y) { collision: { solid: true, hazard: true }, hazard: { kind: "poison", damage: 7 }, sleep: { awakeUntil: 0 }, - mass: 4.8, - inertia: 36000, + mass: 0.45, + inertia: 3200, }; item.prevX = x; item.prevY = y; diff --git a/js/item_update_scheduler.js b/js/item_update_scheduler.js index dd4b0f5..bad6a44 100644 --- a/js/item_update_scheduler.js +++ b/js/item_update_scheduler.js @@ -192,6 +192,7 @@ const mechanicalWorld = physicsWorld?.mechanical || (global.TarinaiMechanicalSystem?.updateWorld?.(worldRef, dt, { maxPairs: physicsBudget.maxPairs, maxLinks: 0, skipLinks: true, maxSubsteps: physicsBudget.maxSubsteps }) || null); const constraintWorld = physicsWorld ? physicsWorld.constraintRan : (global.TarinaiConstraintSystem?.updateWorld?.(worldRef, dt, { maxLinks: Math.min(180, physicsBudget.maxLinks) }) || 0); const postConstraintPairs = physicsWorld ? physicsWorld.postConstraintPairs : (constraintWorld ? (global.TarinaiMechanicalSystem?.resolveMechanicalPairs?.(worldRef, dt, { maxPairs: Math.min(80, physicsBudget.postMaxPairs) }) || 0) : 0); + const poisonContactRemoved = worldRef.resolvePoisonBlockItemContacts?.({ maxPoison: 24, maxTargets: 80 }) || 0; if (physicsEnd) physicsEnd(); state.signature = schedulerSignature(worldRef); worldRef._itemUpdateSchedulerStats = { @@ -211,7 +212,7 @@ mechanicalStats: worldRef._mechanicalWorldStats || null, constraintStats: worldRef._constraintWorldStats || null, }; - return ran + (physicsWorld?.ran || mechanicalWorld?.ran || 0) + constraintWorld; + return ran + (physicsWorld?.ran || mechanicalWorld?.ran || 0) + constraintWorld + poisonContactRemoved; } finally { if (end) end(); } diff --git a/js/item_visual_definitions.js b/js/item_visual_definitions.js new file mode 100644 index 0000000..8f85020 --- /dev/null +++ b/js/item_visual_definitions.js @@ -0,0 +1,131 @@ +"use strict"; + +// Item visual metadata and palettes. +(function (global) { + const raw = String.raw`{ + "water": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"] + }, + "zunda_juice": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"] + }, + "mercury": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] + }, + "sleep_drug": { + "renderer": "sleep_tablet", + "shape": "moon_tablet", + "palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "moon", "purple"] + }, + "laxative": { + "renderer": "laxative_tablet", + "shape": "rounded_tablet_zunchi_motif", + "palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "zunchi motif", "tan"] + }, + "mystery_drug": { + "renderer": "mystery_tablet", + "shape": "question_mark_tablet", + "palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "question mark", "purple"] + }, + "ammo": { + "renderer": "bullet", + "shape": "bullet", + "palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["bullet", "gold"] + }, + "protein": { + "renderer": "powder_pile", + "shape": "powder_pile", + "palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["powder", "orange"] + }, + "niteropu": { + "renderer": "powder_pile", + "shape": "powder_pile", + "palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["powder", "purple"] + }, + "giant_drug": { + "renderer": "split_pill", + "shape": "split_pill", + "palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" }, + "scale": 1.08, + "uiPreviewScale": 1.08, + "fieldScale": 1.08, + "accessibleShapeTraits": ["pill", "large", "orange"] + }, + "dwarf_drug": { + "renderer": "split_pill", + "shape": "split_pill", + "palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" }, + "scale": 0.92, + "uiPreviewScale": 0.92, + "fieldScale": 0.92, + "accessibleShapeTraits": ["pill", "small", "purple"] + }, + "ball": { + "renderer": "emoji", + "shape": "soccer_ball", + "palette": {}, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["round", "soccer ball"], + "emojiFallback": "\u26bd" + } + }`; + + const defs = Object.freeze(JSON.parse(raw)); + + function itemVisualDefinition(type = "") { + return defs[String(type || "")] || null; + } + + function itemVisualPalette(type = "") { + return itemVisualDefinition(type)?.palette || null; + } + + global.TarinaiItemVisualDefinitions = defs; + global.itemVisualDefinition = itemVisualDefinition; + global.itemVisualPalette = itemVisualPalette; + global.TarinaiItemVisualRegistry = Object.freeze({ definitions: defs, definition: itemVisualDefinition, palette: itemVisualPalette }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/mechanical_shape_bridge.js b/js/mechanical_shape_bridge.js new file mode 100644 index 0000000..dbecf7d --- /dev/null +++ b/js/mechanical_shape_bridge.js @@ -0,0 +1,32 @@ +"use strict"; + +// Layer: physics/mechanical-shape-bridge +// Single read-only facade for mechanical shape/footprint queries. It keeps +// world, placement, and renderer code from binding directly to the body solver. +(function (global) { + function mech() { return global.TarinaiMechanicalSystem || null; } + + function isMechanicalType(itemOrType) { return Boolean(mech()?.isMechanicalType?.(itemOrType)); } + function worldSegments(item) { return mech()?.worldSegments?.(item) || []; } + function obstacleRects(item) { return mech()?.obstacleRects?.(item) || []; } + function placementRects(item) { return mech()?.placementRects?.(item) || obstacleRects(item); } + function poisonHazardRects(item) { return mech()?.poisonHazardRects?.(item) || []; } + function boundsAabb(item) { return mech()?.boundsAabb?.(item) || null; } + function hazardAabb(item) { return mech()?.hazardAabb?.(item) || null; } + function extent(item) { return mech()?.extent?.(item) || 0; } + function reach(item) { return mech()?.reach?.(item) || extent(item); } + function hitTest(item, x, y, worldRef, opts = {}) { return mech()?.hitTest?.(item, x, y, worldRef, opts) || { hit: false, distance: Infinity }; } + + global.TarinaiMechanicalShapeSystem = Object.freeze({ + isMechanicalType, + worldSegments, + obstacleRects, + placementRects, + poisonHazardRects, + boundsAabb, + hazardAabb, + extent, + reach, + hitTest, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/mechanical_system.js b/js/mechanical_system.js index 8aa45da..4293ac0 100644 --- a/js/mechanical_system.js +++ b/js/mechanical_system.js @@ -434,6 +434,11 @@ return rectsFor(item, "obstacle"); } + function placementRects(item) { + if (!item || item.dead || !isMechanicalType(item)) return []; + return rectsFor(item, "obstacle"); + } + function poisonHazardRects(item) { if (!item || item.dead || item.type !== "poison_block") return []; return rectsFor(item, "hazard"); @@ -487,7 +492,7 @@ body.velocity = body.velocity || { x: 0, y: 0, angular: 0, linear: 0 }; const vel = body.velocity; const pose = body.pose || {}; - const mass = Math.max(1.2, ps(item, "mass", 4.8)); + const mass = Math.max(0.20, ps(item, "mass", 0.45)); const prevVx = num(vel.x, item.vx); const prevVy = num(vel.y, item.vy); const nextVx = clamp(prevVx + num(fx) * 0.42 * scale / mass, -260, 260); @@ -501,7 +506,7 @@ const rx = num(contactX) - num(pose.x, item.x); const ry = num(contactY) - num(pose.y, item.y); const torque = rx * num(fy) - ry * num(fx); - const inertia = Math.max(12000, ps(item, "inertia", 36000)); + const inertia = Math.max(1200, ps(item, "inertia", 3200)); vel.angular = clamp(num(vel.angular, ps(item, "spin", 0)) + torque / inertia * scale, -2.2, 2.2); const changed = Math.hypot(nextVx - prevVx, nextVy - prevVy) > 0.002 || Math.abs(torque) > 0.001; if (changed) { commitBody(item, "poison-block-impulse"); wakeItem(item, "poison-block-impulse"); } @@ -648,7 +653,7 @@ function mechanicalSeparationWeight(item, nx, ny) { if (!item || item.dead || item.playerHeld || item._heldByPlayer) return 0; - if (item.type === "poison_block") return 1 / Math.max(1.2, ps(item, "mass", 4.8)); + if (item.type === "poison_block") return 1 / Math.max(0.20, ps(item, "mass", 0.45)); if (item.type === "reciprocator") { const a = axis(item); const projection = Math.abs(num(nx) * a.x + num(ny) * a.y); @@ -715,9 +720,9 @@ const wb = mechanicalSeparationWeight(b, nx, ny); const sum = wa + wb; if (sum <= 0.0001) return false; - // Solve most of the penetration as position, not velocity. Capping avoids - // large teleports when old saves spawn bodies deeply overlapped. - const correction = Math.min(overlap + 0.35, Math.max(2.2, Math.min(18, overlap * 0.82))); + // Resolve the actual penetration only. The former extra bias made bodies + // visibly pop when many mechanical items touched at once. + const correction = Math.min(Math.max(0, overlap - 0.05) * 0.92, 18); const ax = -nx * correction * (wa / sum); const ay = -ny * correction * (wa / sum); const bx = nx * correction * (wb / sum); @@ -735,7 +740,7 @@ if (!item || !info) return false; const overlap = Math.max(0, num(info.overlap)); if (overlap <= 0.001) return false; - const correction = Math.min(overlap + 0.4, Math.max(2.5, Math.min(20, overlap * 0.88))); + const correction = Math.min(Math.max(0, overlap - 0.05) * 0.94, 20); const moved = moveMechanicalBodyForSeparation(item, -num(info.nx) * correction, -num(info.ny) * correction, dt, "fence-separation"); if (moved) { worldRef?.markSpatialDirty?.("mechanical-fence-separation"); @@ -1001,7 +1006,6 @@ if (a.type === "rotator") brakePoweredRotatorOnMechanicalContact(a, dt, worldRef, "mechanical"); if (b.type === "rotator") brakePoweredRotatorOnMechanicalContact(b, dt, worldRef, "mechanical"); - if (!recentlyHandled) worldRef?.effects?.push(new Effect("ring", info.x, info.y, { size: Math.max(14, Math.min(28, 12 + info.overlap)), life: 0.14, color: "rgba(184,132,230,0.34)" })); if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact"); } return true; } @@ -1058,7 +1062,6 @@ worldRef._mechanicalContactAt = worldRef._mechanicalContactAt || Object.create(null); if ((worldRef._mechanicalContactAt[key] || -999) + 0.08 <= now) { worldRef._mechanicalContactAt[key] = now; - worldRef.effects?.push(new Effect("ring", info.x, info.y, { size: Math.max(14, Math.min(30, 13 + info.overlap)), life: 0.13, color: "rgba(145,120,94,0.34)" })); } changed = true; } @@ -1185,12 +1188,11 @@ if (Math.abs(num(moved)) <= 0.001) { const now = Number(worldRef?.time || item.world?.time || 0) || 0; if ((item._reciprocatorMovedAt || -999) + 0.18 < now) { - // The motor is powered but the pose did not change for several frames. - // Reset only rail-local transient state; do not touch user speed/travel. + // Do not nudge the rail body forward. Treat a stalled powered rail as + // blocked and let the existing blocked/reverse path decide the next move. pset(item, "slideSpeed", 0, "rail-stall-slide-clear"); if (!changed && Math.abs(phase) < 1 - 0.000001) { - integrateReciprocatorPhase(item, dir * motorSpeed, dt, true, "rail-stall-nudge"); - changed = true; + changed = noteReciprocatorBlocked(item, dt, worldRef, "rail-stall-blocked") || changed; } } } else { @@ -1271,6 +1273,59 @@ return changed; } + function resolvePoisonBlockTarinaiContacts(item, worldRef, dt = 0.016) { + if (!item || item.dead || item.type !== "poison_block" || !worldRef?.tarinai) return false; + if (ps(item, "solid", true) === false) return false; + const rects = obstacleRects(item); + if (!rects.length) return false; + const radius = reach(item) + 96; + const source = worldRef.nearbyTarinai?.(item.x, item.y, radius + 48) || worldRef.tarinai || []; + let changed = false; + let contacts = 0; + for (const t of source) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const rr = Math.max(8, (Number(t.radius) || 22) * 0.74); + if (Math.hypot(num(t.x) - num(item.x), num(t.y) - num(item.y)) > radius + rr) continue; + for (const rect of rects) { + if (!worldRef.circleOverlapsObstacleRect?.(t.x, t.y, rr, rect, 1.0)) continue; + const beforeX = num(t.x); + const beforeY = num(t.y); + const pushed = worldRef.pushTarinaiOutOfRect?.(t, rect, rr, { + maxPasses: 1, + maxPush: Math.max(14, rr * 1.65), + slop: 0.45, + reason: "poison-block-direct-tarinai-contact", + }); + if (!pushed) continue; + contacts += 1; + changed = true; + const dx = num(t.x) - beforeX; + const dy = num(t.y) - beforeY; + const d = Math.hypot(dx, dy); + if (d > 0.001) { + // Eating/sleeping bodies can be nearly stationary. Feed a small, + // opposite impulse back into the passive block so the contact is not + // visually perceived as the block tunneling through the Tarinai. + const nx = dx / d; + const ny = dy / d; + const speed = Math.hypot(ps(item, "xv", 0), ps(item, "yv", 0)); + const impulse = Math.min(34, 5.5 + d * 0.85 + speed * 0.035); + applyImpulse(item, num(t.x) - nx * rr, num(t.y) - ny * rr, -nx * impulse, -ny * impulse, 0.42); + t.lastSolidObstacleCollisionSource = item; + t.lastSolidObstacleCollisionAt = worldRef.time || 0; + } + break; + } + if (contacts >= 10) break; + } + if (changed) { + commitBody(item, "poison-block-tarinai-contact"); + worldRef.markSpatialDirty?.("poison-block-tarinai-contact"); + worldRef.drawListDirty = true; + } + return changed; + } + function updatePoisonBlock(item, dt, worldRef, opts = {}) { if (!item || item.dead || item.type !== "poison_block") return false; applyBodyState(item); @@ -1305,6 +1360,7 @@ item.angle = normalizeAngle(beforeA + ps(item, "spin", 0) * subDt); changed = constrainPassiveBlockInsideWorld(item, worldRef) || changed; if (hasContacts) changed = resolveInteractions(item, subDt, worldRef) || changed; + changed = resolvePoisonBlockTarinaiContacts(item, worldRef, subDt) || changed; changed = changed || Math.hypot(num(item.x) - beforeX, num(item.y) - beforeY) > 0.001 || Math.abs(num(item.angle) - beforeA) > 0.0001; } const friction = Math.pow(0.42, stepTime); @@ -1472,6 +1528,9 @@ const active = all.filter(r => r.active); if (!active.length) return []; const minUseFocused = Number(opts.focusThreshold || 48) || 48; + // When only a few bodies are awake, build the broadphase around those + // bodies plus nearby mechanical neighbors instead of scanning every static + // collider in the field. if (!worldRef?.nearbyObstacles || all.length <= minUseFocused || active.length > Math.max(10, all.length * 0.45)) return all; const chosen = new Set(active.map(r => r.item)); const mech = global.TarinaiMechanicalSystem; @@ -1508,6 +1567,8 @@ let cells = (maxX - minX + 1) * (maxY - minY + 1); if (!Number.isFinite(cells) || cells <= 0) cells = 1; if (cells > maxCellsPerBody) { + // Oversized AABBs can explode grid insertion cost. Center-bucketing + // keeps the frame bounded; the later narrowphase still checks geometry. const key = `${Math.floor(((a.left + a.right) * 0.5) / cellSize)}:${Math.floor(((a.top + a.bottom) * 0.5) / cellSize)}`; let bucket = grid.get(key); if (!bucket) { bucket = []; grid.set(key, bucket); } @@ -1574,6 +1635,8 @@ pairs += 1; if (pairCandidates.length < maxCandidates) pairCandidates.push({ a, b, key, score }); else { + // Keep only the highest-priority candidates so pathological overlap + // scenes stay bounded before narrowphase resolution. let worst = 0; for (let k = 1; k < pairCandidates.length; k += 1) if (pairCandidates[k].score < pairCandidates[worst].score) worst = k; if (score > pairCandidates[worst].score) pairCandidates[worst] = { a, b, key, score }; @@ -1768,6 +1831,7 @@ extent, worldSegments, obstacleRects, + placementRects, poisonHazardRects, boundsAabb, hazardBoundsAabb, @@ -1803,6 +1867,7 @@ updateRotator, updateReciprocator, updatePoisonBlock, + resolvePoisonBlockTarinaiContacts, applyPokeImpulse, hitTest, reach, diff --git a/js/physics_helpers.js b/js/physics_helpers.js index 98d5ba5..b2db0b5 100644 --- a/js/physics_helpers.js +++ b/js/physics_helpers.js @@ -3,6 +3,8 @@ (function (global) { function rectLocalPoint(rect, x, y) { + const shared = global.TarinaiCollisionFootprints?.rectLocalPoint; + if (typeof shared === "function") return shared(rect, x, y); const dx = x - (rect.cx ?? 0); const dy = y - (rect.cy ?? 0); const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(rect.angle || 0); @@ -12,6 +14,8 @@ function rectWorldVector(rect, x, y) { + const shared = global.TarinaiCollisionFootprints?.rectWorldVector; + if (typeof shared === "function") return shared(rect, x, y); const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(rect.angle || 0); const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(rect.angle || 0); return { x: x * c - y * ss, y: x * ss + y * c }; diff --git a/js/physics_projection_system.js b/js/physics_projection_system.js new file mode 100644 index 0000000..830e423 --- /dev/null +++ b/js/physics_projection_system.js @@ -0,0 +1,57 @@ +"use strict"; + +// Layer: physics/projection-bridge +// Shared tiny helpers for positional projection used by links, mechanical +// bodies, and circle/circle collision code. +(function (global) { + function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; } + + function clampInsideWorld(worldRef, obj, radius = 0) { + if (!worldRef || !obj) return false; + const pad = Math.max(CONFIG.worldPadding || 30, num(radius, 0)); + const ox = num(obj.x), oy = num(obj.y); + obj.x = clamp(ox, pad, worldRef.w - pad); + obj.y = clamp(oy, pad, worldRef.h - pad); + return obj.x !== ox || obj.y !== oy; + } + + function projectPair(worldRef, a, b, nx, ny, overlap, weights = {}) { + const amount = Math.max(0, num(overlap)); + if (!worldRef || !a || !b || amount <= 0) return false; + const aw = Number.isFinite(Number(weights.a)) ? Number(weights.a) : 0.5; + const bw = Number.isFinite(Number(weights.b)) ? Number(weights.b) : 0.5; + a.x = num(a.x) - num(nx) * amount * aw; + a.y = num(a.y) - num(ny) * amount * aw; + b.x = num(b.x) + num(nx) * amount * bw; + b.y = num(b.y) + num(ny) * amount * bw; + clampInsideWorld(worldRef, a, a.r || a.radius || 0); + clampInsideWorld(worldRef, b, b.r || b.radius || 0); + return true; + } + + function markSpatialDirty(worldRef, item, reason = "projection", threshold = 0.35, minInterval = 0.065) { + const world = worldRef || item?.world || null; + if (!world || !item) return false; + const now = Number(world.time || 0) || 0; + const x = num(item.x), y = num(item.y), r = num(item.r || item.radius || 0); + const lastX = Number(item._lastProjectionSpatialDirtyX); + const lastY = Number(item._lastProjectionSpatialDirtyY); + const lastR = Number(item._lastProjectionSpatialDirtyR); + const missing = !Number.isFinite(lastX) || !Number.isFinite(lastY) || !Number.isFinite(lastR); + const moved = missing || Math.hypot(x - lastX, y - lastY) > threshold || Math.abs(r - lastR) > threshold; + const due = now >= Number(item._nextProjectionSpatialDirtyAt || 0); + if (!moved && !due) return false; + item._lastProjectionSpatialDirtyX = x; + item._lastProjectionSpatialDirtyY = y; + item._lastProjectionSpatialDirtyR = r; + item._nextProjectionSpatialDirtyAt = now + Math.max(0.016, num(minInterval, 0.065)); + world.markSpatialDirty?.(reason); + return true; + } + + global.TarinaiPhysicsProjectionSystem = Object.freeze({ + clampInsideWorld, + projectPair, + markSpatialDirty, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/physics_shape_editor_system.js b/js/physics_shape_editor_system.js new file mode 100644 index 0000000..75ca3cf --- /dev/null +++ b/js/physics_shape_editor_system.js @@ -0,0 +1,70 @@ +"use strict"; + +// Layer: ui/physics-shape-editor +// Shared button labels/templates for rotator, reciprocator, and poison-block +// segment editors. Low-level editor pointer behavior remains in placement log. +(function (global) { + const MODE_LABELS = Object.freeze({ + line: "📏 直線", + free: "✎ 自由描画", + erase: "⌫ 消しゴム", + }); + const TEMPLATE_LABELS = Object.freeze({ + bar: "┃ 棒", + cross: "+ 十字", + circle: "○ 円", + }); + const ACTION_LABELS = Object.freeze({ + undo: "↶ 一つ戻す", + clear: "🗑 全消し", + }); + function templateSegments(name) { + if (name === "bar") return [[-130, 0, 130, 0]]; + if (name === "cross") return [[-118, 0, 118, 0], [0, -90, 0, 90]]; + if (name === "circle") { + const segments = []; + const n = 18, r = 108; + for (let i = 0; i < n; i += 1) { + const a = i / n * Math.PI * 2; + const b = (i + 1) / n * Math.PI * 2; + segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]); + } + return segments; + } + return null; + } + function decorateToolbar(dialog, prefix = "rotator") { + if (!dialog) return false; + for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) { + const key = btn.dataset[`${prefix}Mode`]; + if (MODE_LABELS[key]) { btn.textContent = MODE_LABELS[key]; btn.title = MODE_LABELS[key].replace(/^[^\s]+\s*/, ""); } + } + for (const btn of dialog.querySelectorAll(`[data-${prefix}-template]`)) { + const key = btn.dataset[`${prefix}Template`]; + if (TEMPLATE_LABELS[key]) { btn.textContent = TEMPLATE_LABELS[key]; btn.title = TEMPLATE_LABELS[key].replace(/^[^\s]+\s*/, ""); } + } + const undo = dialog.querySelector(`#${prefix}Undo`); + const clear = dialog.querySelector(`#${prefix}Clear`); + if (undo) undo.textContent = ACTION_LABELS.undo; + if (clear) clear.textContent = ACTION_LABELS.clear; + return true; + } + function pointSegmentDistance(p, seg) { + if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity; + const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0; + const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0; + const dx = x2 - x1, dy = y2 - y1; + const lenSq = dx * dx + dy * dy; + if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1); + const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq)); + return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u)); + } + global.TarinaiPhysicsShapeEditorSystem = Object.freeze({ + MODE_LABELS, + TEMPLATE_LABELS, + ACTION_LABELS, + decorateToolbar, + templateSegments, + pointSegmentDistance, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/physics_world_system.js b/js/physics_world_system.js index 4b6e9fa..46b5408 100644 --- a/js/physics_world_system.js +++ b/js/physics_world_system.js @@ -71,8 +71,8 @@ collision: { solid: true, hazard: type === "poison_block" }, hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null, sleep: { awakeUntil: 0 }, - mass: type === "poison_block" ? 4.8 : 1, - inertia: type === "poison_block" ? 36000 : 22000, + mass: type === "poison_block" ? 0.45 : 1, + inertia: type === "poison_block" ? 3200 : 22000, }; } @@ -232,8 +232,8 @@ case "slideSpeed": b.velocity.linear = num(value); break; case "solid": b.collision.solid = !!value; break; case "damage": b.hazard = b.hazard || { kind: "poison", damage: 7 }; b.hazard.damage = Math.max(1, num(value, 7)); break; - case "mass": b.mass = Math.max(0.2, num(value, 4.8)); break; - case "inertia": b.inertia = Math.max(1, num(value, 36000)); break; + case "mass": b.mass = Math.max(0.12, num(value, 0.45)); break; + case "inertia": b.inertia = Math.max(1, num(value, item.type === "poison_block" ? 3200 : 36000)); break; case "railOn": b.motor.powered = !!value; break; case "railMotorSpeed": b.motor.speed = Math.max(0, num(value, 92)); break; case "railTravel": if (b.rail) b.rail.travel = Math.max(24, num(value, 150)); break; diff --git a/js/pin_attachment_system.js b/js/pin_attachment_system.js new file mode 100644 index 0000000..90eff95 --- /dev/null +++ b/js/pin_attachment_system.js @@ -0,0 +1,130 @@ +"use strict"; + +// Layer: entity-runtime/items/pin-attachment +// Centralizes pushpin/oshibyo attachment geometry, target classification, and +// render attachment placement. Motion/effects still live in dynamic systems. +(function (global) { + function isPin(itemOrType) { + const type = typeof itemOrType === "string" ? itemOrType : itemOrType?.type; + return Boolean(type && typeof global.isPinType === "function" && global.isPinType(type)); + } + function behaviorFor(itemOrType) { + const type = typeof itemOrType === "string" ? itemOrType : itemOrType?.type; + return typeof global.pinBehaviorFor === "function" ? global.pinBehaviorFor(type) : null; + } + function isOshibyo(itemOrType) { + return Boolean(behaviorFor(itemOrType)?.blocksZunchi); + } + function tarinaiVisualSide(item, tarinai) { + const faceDir = tarinai?.facingDir ? tarinai.facingDir() : (tarinai?.facing || 1); + const faceSide = faceDir >= 0 ? 1 : -1; + return isOshibyo(item) ? -faceSide : faceSide; + } + function tarinaiAnchor(item, tarinai, opts = {}) { + const r = tarinai?.radius || 16; + const visualSide = Number.isFinite(opts.visualSide) ? opts.visualSide : tarinaiVisualSide(item, tarinai); + if (isOshibyo(item)) { + const sprite = String(tarinai?.visibleSpriteId || (tarinai?.rawSpriteId ? tarinai.rawSpriteId() : "") || ""); + const backSprite = sprite === "back" || sprite === "tarinai12_back" || /(^|_)back$/i.test(sprite); + const xFactor = backSprite ? 0.62 : 0.96; + const yFactor = backSprite ? 0.28 : 0.50; + const angleFactor = backSprite ? 0.32 : 0.48; + const distFactor = backSprite ? 0.70 : 0.96; + return { + visualSide, + x: (tarinai?.x || 0) + visualSide * r * xFactor, + y: (tarinai?.y || 0) + r * yFactor, + angle: visualSide * angleFactor, + distance: r * distFactor, + offsetY: r * yFactor, + }; + } + const dx = Number.isFinite(opts.dx) ? opts.dx : 0; + const dy = Number.isFinite(opts.dy) ? opts.dy : -1; + const dist = Number.isFinite(opts.distance) ? opts.distance : Math.hypot(dx, dy); + const clampFn = global.clamp || ((v, lo, hi) => Math.max(lo, Math.min(hi, v))); + return { + visualSide, + x: (tarinai?.x || 0) + visualSide * r * 0.36, + y: (tarinai?.y || 0) - r * 0.04, + angle: Math.atan2(dy || -1, dx || visualSide), + poseAngle: visualSide * 0.28, + distance: clampFn(dist || r * 0.58, r * 0.20, r * 0.74), + offsetY: clampFn(dy, -r * 0.74, r * 0.38), + }; + } + function applyTarinaiPose(item, tarinai, worldRef = null) { + if (!item || !tarinai) return null; + const anchor = tarinaiAnchor(item, tarinai); + item.pinVisualSide = anchor.visualSide; + item.x = anchor.x; + item.y = anchor.y; + item.prevX = item.x; + item.prevY = item.y; + item.spin = isOshibyo(item) ? anchor.angle : (anchor.poseAngle ?? anchor.angle); + item.vx = tarinai.vx || 0; + item.vy = tarinai.vy || 0; + item.deletable = false; + if (worldRef) worldRef.drawListDirty = true; + return anchor; + } + function ballAnchor(item, ball) { + const angle = (ball?.spin || 0) + (item?.pinBallOffsetAngle || 0); + const distance = Math.max(4, Number(item?.pinBallOffsetDistance || (ball?.r || 18) * 0.55) || (ball?.r || 18) * 0.55); + return { + angle, + distance, + visualSide: Math.cos(angle) >= 0 ? 1 : -1, + x: (ball?.x || 0) + Math.cos(angle) * distance, + y: (ball?.y || 0) + Math.sin(angle) * distance, + spin: angle + (isOshibyo(item) ? 0.18 : Math.PI * 0.5), + }; + } + function applyBallPose(item, ball, worldRef = null) { + if (!item || !ball) return null; + const anchor = ballAnchor(item, ball); + item.prevX = item.x; + item.prevY = item.y; + item.x = anchor.x; + item.y = anchor.y; + item.vx = ball.vx || 0; + item.vy = ball.vy || 0; + item.pinVisualSide = anchor.visualSide; + item.spin = anchor.spin; + item.deletable = false; + if (Number.isFinite(ball.spinVelocity)) item.spinVelocity = ball.spinVelocity; + if (worldRef) worldRef.drawListDirty = true; + return anchor; + } + function isAttachedToEntity(pin, entity) { + if (!pin || !entity || !isPin(pin)) return false; + if (entity.type === "ball") return pin.pinState === "ball_lodged" && pin.pinBallId === entity.id; + const isTarinaiEntity = typeof global.Tarinai !== "undefined" && entity instanceof global.Tarinai; + return isTarinaiEntity && pin.pinState === "lodged" && pin.pinTargetId === entity.id; + } + function drawAttachedForEntity(args = {}) { + const { renderStack, entity, visibleRect, worldRef, time, drawnIds, isVisible } = args; + if (!renderStack || !entity || !drawnIds || typeof isVisible !== "function") return 0; + const source = entity.type === "ball" ? (renderStack.ballLodgedPins || []) : (renderStack.lodgedPins || []); + let drawn = 0; + for (const it of source) { + if (!it || drawnIds.has(it.id) || !isAttachedToEntity(it, entity)) continue; + if (isVisible(it, visibleRect)) it.draw?.(global.ctx || args.ctx, time ?? worldRef?.time ?? 0, args.lighting || null); + drawnIds.add(it.id); + drawn += 1; + } + return drawn; + } + global.TarinaiPinAttachmentSystem = Object.freeze({ + isPin, + behaviorFor, + isOshibyo, + tarinaiVisualSide, + tarinaiAnchor, + applyTarinaiPose, + ballAnchor, + applyBallPose, + isAttachedToEntity, + drawAttachedForEntity, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/placement_collision_system.js b/js/placement_collision_system.js new file mode 100644 index 0000000..9f9382e --- /dev/null +++ b/js/placement_collision_system.js @@ -0,0 +1,41 @@ +"use strict"; + +// Layer: physics/placement-collision +// Single facade for placement, copy, and pointer hit testing. Existing callers +// should use this instead of reaching into raw footprint helpers directly. +(function (global) { + function footprints() { return global.TarinaiCollisionFootprints || null; } + + function hitTestItem(worldRef, item, x, y, opts = {}) { + return footprints()?.hitTestItem?.(worldRef, item, x, y, opts) || { hit: false, distance: Infinity }; + } + + function placementRectsFor(worldRef, item) { + return footprints()?.placementRectsFor?.(worldRef, item) || []; + } + + function isSoftPlacementIgnoredType(type) { + return Boolean(footprints()?.isSoftPlacementIgnoredType?.(type)); + } + + function itemPlacementOverlapBlocked(worldRef, item, opts = {}) { + return Boolean(footprints()?.itemPlacementOverlapBlocked?.(worldRef, item, opts)); + } + + function rectsOverlap(a, b, margin = 0) { + return Boolean(footprints()?.rectsOverlap?.(a, b, margin)); + } + + function rectCircleOverlap(rect, cx, cy, radius, margin = 0) { + return Boolean(footprints()?.rectCircleOverlap?.(rect, cx, cy, radius, margin)); + } + + global.TarinaiPlacementCollisionSystem = Object.freeze({ + hitTestItem, + placementRectsFor, + isSoftPlacementIgnoredType, + itemPlacementOverlapBlocked, + rectsOverlap, + rectCircleOverlap, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/placement_preview_system.js b/js/placement_preview_system.js new file mode 100644 index 0000000..593c314 --- /dev/null +++ b/js/placement_preview_system.js @@ -0,0 +1,204 @@ +"use strict"; + +// Layer: physics/placement-preview +// Single source of truth for placement preview geometry and the boundary part +// of placement blocking. Render and actual placement both delegate here so a +// green preview means the same footprint the click will try to place. +(function (global) { + function num(v, fallback = 0) { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; + } + function itemRadius(type, fallback = 12) { + return typeof global.itemRadiusFor === "function" ? global.itemRadiusFor(type, fallback) : fallback; + } + function toolTypeFor(worldRef) { + return typeof global.toolItemType === "function" ? global.toolItemType(worldRef?.tool || "") : ""; + } + function toolSizeScale(worldRef, type) { + return worldRef?.toolSizeScale ? worldRef.toolSizeScale(type) : 1; + } + function toolSizeName(worldRef, type) { + return worldRef?.toolSizeFor ? worldRef.toolSizeFor(type) : (worldRef?.toolSize || "medium"); + } + function isRotatable(type) { + return typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type); + } + function defaultAngle(type) { + return typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; + } + function rectOutside(worldRef, rect) { + if (!worldRef || !rect) return true; + const pad = global.CONFIG?.worldPadding || 30; + return num(rect.left) < pad || num(rect.top) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.bottom) > (worldRef.h || 0) - pad; + } + function circleOutside(worldRef, x, y, radius) { + if (!worldRef) return true; + const pad = global.CONFIG?.worldPadding || 30; + const r = Math.max(0, num(radius)); + return num(x) - r < pad || num(y) - r < pad || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0) - pad; + } + function orientedAabb(rect) { + if (!rect) return null; + if (!rect.oriented) return rect; + const fp = global.TarinaiCollisionFootprints; + const pts = fp?.rectCorners?.(rect, 0) || []; + if (!pts.length) { + const reach = Math.max(num(rect.halfW), num(rect.halfH)); + return { left: num(rect.cx) - reach, right: num(rect.cx) + reach, top: num(rect.cy) - reach, bottom: num(rect.cy) + reach }; + } + return { + left: Math.min(...pts.map(p => num(p.x))), + right: Math.max(...pts.map(p => num(p.x))), + top: Math.min(...pts.map(p => num(p.y))), + bottom: Math.max(...pts.map(p => num(p.y))), + }; + } + function rectsFor(worldRef, item) { + return (global.TarinaiPlacementCollisionSystem || global.TarinaiCollisionFootprints)?.placementRectsFor?.(worldRef, item) || worldRef?.solidObstacleRects?.(item) || []; + } + function footprintRadius(worldRef, item, rects = null) { + return (global.TarinaiCollisionFootprints || global.TarinaiPlacementCollisionSystem)?.itemFootprintRadius?.(worldRef, item, rects) + || Math.max(14, num(item?.r, itemRadius(item?.type, 12)) || itemRadius(item?.type, 12)); + } + function ensureMechanicalPreview(item, worldRef) { + if (!item || !global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) return item; + const pb = global.TarinaiPhysicsBodySystem; + pb?.ensureBody?.(item, worldRef || null, { syncFromLegacy: false }); + if (item.type === "rotator") { + pb?.setScalar?.(item, "motorSpeed", Math.PI * 0.65, "preview"); + pb?.setScalar?.(item, "motorOn", true, "preview"); + pb?.setScalar?.(item, "thickness", 12, "preview"); + pb?.setSegments?.(item, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview"); + } else if (item.type === "poison_block") { + pb?.setScalar?.(item, "thickness", 14, "preview"); + item.world = worldRef || null; + } else if (item.type === "reciprocator") { + pb?.setScalar?.(item, "railOn", true, "preview"); + pb?.setScalar?.(item, "railMotorSpeed", 92, "preview"); + pb?.setScalar?.(item, "railTravel", 150, "preview"); + pb?.setScalar?.(item, "railPhase", 0, "preview"); + pb?.setScalar?.(item, "railAnchorX", item.x, "preview"); + pb?.setScalar?.(item, "railAnchorY", item.y, "preview"); + pb?.setScalar?.(item, "thickness", 12, "preview"); + pb?.setSegments?.(item, [[-78, 0, 78, 0]], "preview"); + } + return item; + } + function makePreviewItem(worldRef, type, x, y, opts = {}) { + if (!worldRef || !type) return null; + const scale = opts.sizeScale ?? toolSizeScale(worldRef, type); + const sizeName = opts.toolSize ?? toolSizeName(worldRef, type); + const r = (opts.r ?? itemRadius(type, 12)) * scale; + const item = { type, x: num(x), y: num(y), r, toolSize: sizeName, foodServingScale: scale, dead: false, isPlacementPreview: true }; + if (isRotatable(type)) item.angle = opts.angle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type)); + if (type === "reciprocator") { + const axis = item.angle ?? defaultAngle(type); + global.TarinaiPhysicsBodySystem?.setScalar?.(item, "railAxis", axis, "preview-axis"); + item.angle = defaultAngle(type); + } + ensureMechanicalPreview(item, worldRef); + return item; + } + function boundsBlocked(worldRef, item, opts = {}) { + if (!worldRef || !item || item.dead) return true; + const rects = opts.rects || rectsFor(worldRef, item); + if (rects.length) return rects.some(rect => rectOutside(worldRef, orientedAabb(rect))); + const type = item.type || ""; + const radius = opts.radius ?? Math.max(14, (num(item.r, itemRadius(type, 12)) || itemRadius(type, 12)) * (type === "bed" ? 1.55 : 1.25)); + return circleOutside(worldRef, item.x, item.y, radius); + } + function clampPointFor(worldRef, item, x = item?.x || 0, y = item?.y || 0) { + if (!worldRef || !item) return { x, y }; + const pad = global.CONFIG?.worldPadding || 30; + const rects = rectsFor(worldRef, item); + if (rects.length) { + let left = 0, right = 0, top = 0, bottom = 0; + for (const rect of rects) { + const aabb = orientedAabb(rect); + if (!aabb) continue; + left = Math.max(left, num(item.x) - num(aabb.left)); + right = Math.max(right, num(aabb.right) - num(item.x)); + top = Math.max(top, num(item.y) - num(aabb.top)); + bottom = Math.max(bottom, num(aabb.bottom) - num(item.y)); + } + return { x: global.clamp ? global.clamp(x, pad + left, (worldRef.w || 0) - pad - right) : Math.max(pad + left, Math.min((worldRef.w || 0) - pad - right, x)), y: global.clamp ? global.clamp(y, pad + top, (worldRef.h || 0) - pad - bottom) : Math.max(pad + top, Math.min((worldRef.h || 0) - pad - bottom, y)) }; + } + const radius = Math.max(14, (num(item.r, itemRadius(item.type, 12)) || itemRadius(item.type, 12)) * (item.type === "bed" ? 1.55 : 1.25)); + return { x: global.clamp ? global.clamp(x, pad + radius, (worldRef.w || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.w || 0) - pad - radius, x)), y: global.clamp ? global.clamp(y, pad + radius, (worldRef.h || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.h || 0) - pad - radius, y)) }; + } + function geometryFor(worldRef, item) { + if (!worldRef || !item) return null; + ensureMechanicalPreview(item, worldRef); + if ((item.type === "fence_v" || item.type === "fence_h" || item.type === "bounce_fence" || item.type === "bounce_fence_v" || item.type === "gate_fence") && worldRef.fenceRect) { + return { rect: worldRef.fenceRect(item), rects: [] }; + } + if (item.type === "nest_box" && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] }; + const rects = rectsFor(worldRef, item); + return { rect: null, rects }; + } + function blockedFor(worldRef, item, opts = {}) { + const geometry = opts.geometry || geometryFor(worldRef, item) || {}; + const rects = geometry.rects && geometry.rects.length ? geometry.rects : (geometry.rect ? [geometry.rect] : null); + const b = boundsBlocked(worldRef, item, { rects }); + if (b) return true; + if (opts.skipWorldBlocked) return false; + return Boolean(worldRef?.placementBlocked?.(item) || (worldRef?.fencePlacementBlocked?.(item) && worldRef?.isFenceType?.(item.type))); + } + function previewForItem(worldRef, item, opts = {}) { + if (!worldRef || !item) return null; + const geometry = geometryFor(worldRef, item) || {}; + const rects = geometry.rects || []; + const radius = footprintRadius(worldRef, item, rects); + const bounds = boundsBlocked(worldRef, item, { rects: rects.length ? rects : (geometry.rect ? [geometry.rect] : null) }); + return { + type: item.type, + x: item.x, + y: item.y, + r: item.r || radius, + rect: geometry.rect || null, + rects, + boundsBlocked: bounds, + blocked: opts.skipBlocked ? bounds : (bounds || Boolean(worldRef.placementBlocked?.(item) || worldRef.fencePlacementBlocked?.(item))), + }; + } + function previewForWorld(worldRef) { + const p = worldRef?.pointer; + const type = toolTypeFor(worldRef); + if (!p?.inside || !type) return null; + const x = num(p.x), y = num(p.y); + const tmp = makePreviewItem(worldRef, type, x, y); + 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"); + const limitBlocked = !(worldRef.canAddGrass?.(1) ?? true) && !replacingGrass; + const softBlocked = Boolean(worldRef.grassBlockedAt?.(x, y, tmp, { ignoreSoftPlacement: true }) || worldRef.grassOnTarinaiAt?.(x, y)); + const b = limitBlocked || softBlocked || circleOutside(worldRef, x, y, Math.max(14, r * 1.55)); + return { type, x, y, r, blocked: b, item: tmp }; + } + const geometry = geometryFor(worldRef, tmp) || {}; + const rectsForBounds = geometry.rects && geometry.rects.length ? geometry.rects : (geometry.rect ? [geometry.rect] : null); + const ownBounds = extra.boundsRadius ? circleOutside(worldRef, x, y, extra.boundsRadius) : boundsBlocked(worldRef, tmp, { rects: rectsForBounds }); + const blocked = ownBounds || Boolean(worldRef.placementBlocked?.(tmp)); + return { type, x, y, r, rect: geometry.rect || null, rects: geometry.rects || [], influence: extra.influence || null, blocked, item: tmp }; + } + global.TarinaiPlacementPreviewSystem = Object.freeze({ + makePreviewItem, + rectOutside, + circleOutside, + boundsBlocked, + clampPointFor, + geometryFor, + blockedFor, + forItem: previewForItem, + forWorld: previewForWorld, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index 3237644..7a92663 100644 --- a/js/render.js +++ b/js/render.js @@ -466,86 +466,33 @@ function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) { function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale = 1) { if (!influence) return; + const x = Number(influence.x || 0) || 0; + const y = Number(influence.y || 0) || 0; + const radius = Math.max(1, Number(influence.radius || 0) || 0); + if (!(radius > 0)) return; ctx.save(); - ctx.setLineDash([10, 7]); - ctx.lineWidth = 1.6; + ctx.globalAlpha = (blocked ? 0.30 : 0.40) * Math.max(0.2, Number(alphaScale || 1) || 1); + ctx.setLineDash([12, 9]); + ctx.lineWidth = 2.0; + ctx.strokeStyle = blocked ? "rgba(194,70,58,0.72)" : "rgba(80,174,222,0.82)"; + ctx.fillStyle = blocked ? "rgba(232,84,72,0.055)" : "rgba(80,174,222,0.075)"; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.setLineDash([3, 9]); + ctx.globalAlpha *= 0.72; + ctx.beginPath(); + ctx.arc(x, y, radius * 0.55, 0, Math.PI * 2); + ctx.stroke(); ctx.restore(); } function placementPreviewFor(world) { - const p = world?.pointer; - const type = toolItemType(world?.tool || ""); - if (!p?.inside || !type) return null; - const pad = CONFIG.worldPadding || 30; - let x = p.x; - let y = p.y; - const sizeScale = world?.toolSizeScale ? world.toolSizeScale(type) : 1; - const sizeName = world?.toolSizeFor ? world.toolSizeFor(type) : "medium"; - const r = itemRadiusFor(type, 12) * sizeScale; - const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad)); - const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad; - let blocked = false; - let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false }; - if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0); - if (type === "reciprocator") { - const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; - pb?.ensureBody?.(tmp, world, { syncFromLegacy: false }); - pb?.setScalar?.(tmp, "railAxis", tmp.angle, "preview-axis"); - tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0; - pb?.syncPoseFromItem?.(tmp); - } - if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) { - const rawRect = world.fenceRect(tmp); - return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) }; - } - if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { - const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; - pb?.ensureBody?.(tmp, world, { syncFromLegacy: false }); - pb?.setScalar?.(tmp, "motorSpeed", Math.PI * 0.65, "preview"); - pb?.setScalar?.(tmp, "motorOn", true, "preview"); - pb?.setScalar?.(tmp, "thickness", 12, "preview"); - pb?.setSegments?.(tmp, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview"); - const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || []; - const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null; - return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; - } - if (type === "poison_block" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { - const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; - // Use the actual default poison-block physics footprint for placement - // feedback. Previously it fell through to the generic oval preview, which - // did not match the placed shape. - pb?.ensureBody?.(tmp, null, { syncFromLegacy: false }); - tmp.world = null; - const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || []; - const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null; - return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; - } - if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { - const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; - pb?.ensureBody?.(tmp, world, { syncFromLegacy: false }); - pb?.setScalar?.(tmp, "railOn", true, "preview"); - pb?.setScalar?.(tmp, "railMotorSpeed", 92, "preview"); - pb?.setScalar?.(tmp, "railTravel", 150, "preview"); - pb?.setScalar?.(tmp, "railPhase", 0, "preview"); - pb?.setScalar?.(tmp, "railAnchorX", x, "preview"); - pb?.setScalar?.(tmp, "railAnchorY", y, "preview"); - pb?.setScalar?.(tmp, "thickness", 12, "preview"); - pb?.setSegments?.(tmp, [[-78, 0, 78, 0]], "preview"); - const rects = world.reciprocatorObstacleRects(tmp) || []; - const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null; - return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; - } - if (type === "nest_box" && world.nestBoxBaseRect) { - const rect = world.nestBoxBaseRect(tmp); - return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) }; - } - if (type === "grass") { - blocked = !(world.canAddGrass?.(1) ?? true) || !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55)); - } - if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp); - return { type, x, y, r, blocked }; + return TARINAI_RENDER_GLOBAL.TarinaiPlacementPreviewSystem?.forWorld?.(world) || null; } + function drawPlacementPreview(ctx, world) { const preview = placementPreviewFor(world); if (!preview) return; @@ -614,19 +561,10 @@ function drawOperationToolPreview(ctx, world) { const type = world.copyBuffer.type || ""; const item = world.copyPreviewItemAt?.(p.x, p.y); if (item) { - const pad = typeof CONFIG !== "undefined" ? (CONFIG.worldPadding ?? 0) : 0; - const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad)); - const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad; - const previewRects = world.solidObstacleRects?.(item) || []; - const boundsBlocked = previewRects.length - ? previewRects.some(rr => rectOutside(rr.oriented ? { - left: (rr.cx || p.x) - (rr.halfW || 10) - (rr.halfH || 5), - right: (rr.cx || p.x) + (rr.halfW || 10) + (rr.halfH || 5), - top: (rr.cy || p.y) - (rr.halfW || 10) - (rr.halfH || 5), - bottom: (rr.cy || p.y) + (rr.halfW || 10) + (rr.halfH || 5), - } : rr)) - : circleOutside(p.x, p.y, Math.max(14, (item.r || itemRadiusFor?.(type, 16) || 16) * 1.25)); - const blocked = boundsBlocked || Boolean(world.placementBlocked?.(item) || world.fencePlacementBlocked?.(item)); + const placementInfo = TARINAI_RENDER_GLOBAL.TarinaiPlacementPreviewSystem?.forItem?.(world, item) || null; + const previewRects = placementInfo?.rects || world.solidObstacleRects?.(item) || []; + const boundsBlocked = Boolean(placementInfo?.boundsBlocked); + const blocked = Boolean(placementInfo ? placementInfo.blocked : (world.placementBlocked?.(item) || world.fencePlacementBlocked?.(item))); let copyPreviewDrew = false; if (typeof item.draw === "function") { ctx.save(); @@ -694,6 +632,30 @@ function drawOperationToolPreview(ctx, world) { ctx.restore(); return; } + + if (world?.tool === "water_hose") { + const radius = world.waterHoseRadius?.() || 138; + ctx.save(); + ctx.globalAlpha = 0.48; + ctx.lineWidth = 2.1; + ctx.setLineDash([8, 7]); + ctx.strokeStyle = "rgba(72,166,216,0.84)"; + ctx.fillStyle = "rgba(72,166,216,0.10)"; + ctx.beginPath(); + ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 0.72; + ctx.beginPath(); + ctx.moveTo(p.x - 10, p.y); + ctx.lineTo(p.x + 10, p.y); + ctx.moveTo(p.x, p.y - 10); + ctx.lineTo(p.x, p.y + 10); + ctx.stroke(); + ctx.restore(); + return; + } if (world?.tool !== "area_delete") return; const radius = world.areaDeleteRadius?.() || 138; ctx.save(); @@ -1000,7 +962,7 @@ function syncCarriedPlushieToOwner(it, worldRef) { function ensureWorldRenderStack(worldRef) { if (!worldRef._renderStack) { - worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] }; + worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [] }; } const stack = worldRef._renderStack; const signature = renderStackSignature(worldRef); @@ -1010,6 +972,7 @@ function ensureWorldRenderStack(worldRef) { stack.layered.length = 0; stack.carriedPlushies.length = 0; stack.lodgedPins.length = 0; + if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0; for (const it of worldRef.items || []) { if (!it || it.dead || isCachedTerrainItem(it)) continue; @@ -1017,6 +980,10 @@ function ensureWorldRenderStack(worldRef) { stack.lodgedPins.push(it); continue; } + if (isPinType(it.type) && it.pinState === "ball_lodged") { + (stack.ballLodgedPins || (stack.ballLodgedPins = [])).push(it); + continue; + } if (it.isStructure && it.type === "plushie" && it.carriedById) { stack.carriedPlushies.push(it); continue; @@ -1042,12 +1009,13 @@ function ensureWorldRenderStack(worldRef) { function collectVisibleRenderStack(worldRef, visibleRect) { const end = window.TarinaiPerf?.begin?.("render.stackBuild") || null; try { - if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] }; + if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [] }; const stack = worldRef._visibleRenderStack; stack.backItems.length = 0; stack.layered.length = 0; stack.carriedPlushies.length = 0; stack.lodgedPins.length = 0; + if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0; const seen = new Set(); const addItem = (it) => { if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef); @@ -1057,6 +1025,10 @@ function collectVisibleRenderStack(worldRef, visibleRect) { stack.lodgedPins.push(it); return; } + if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "ball_lodged") { + (stack.ballLodgedPins || (stack.ballLodgedPins = [])).push(it); + return; + } if (it.isStructure && it.type === "plushie" && it.carriedById) { stack.carriedPlushies.push(it); return; @@ -1071,6 +1043,8 @@ function collectVisibleRenderStack(worldRef, visibleRect) { }; worldRef.ensureSpatial?.("render-visible"); if (worldRef.spatial?.nearbyRectInto) { + // Pull only visible spatial buckets, then do a render-radius check for + // oversized sprites/effects that extend outside their origin cell. const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []); itemScratch.length = 0; worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch); @@ -1616,9 +1590,37 @@ function render() { // Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai. + const drawnAttachedPinIds = new Set(); + const drawAttachedPinsForEntity = (entity) => { + if (!entity) return; + if (TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem?.drawAttachedForEntity) { + TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem.drawAttachedForEntity({ renderStack, entity, visibleRect, worldRef: world, time: world.time, drawnIds: drawnAttachedPinIds, isVisible: isEntityVisibleInRect, ctx, lighting }); + return; + } + const isBall = entity.type === "ball"; + const isTarinaiEntity = typeof Tarinai !== "undefined" && entity instanceof Tarinai; + if (isBall) { + for (const it of renderStack.ballLodgedPins || []) { + if (!it || drawnAttachedPinIds.has(it.id) || it.pinBallId !== entity.id) continue; + if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + drawnAttachedPinIds.add(it.id); + } + } + if (isTarinaiEntity) { + for (const it of renderStack.lodgedPins || []) { + if (!it || drawnAttachedPinIds.has(it.id) || it.pinTargetId !== entity.id) continue; + if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + drawnAttachedPinIds.add(it.id); + } + } + }; + const endLayered = renderPerfBegin("render.draw.layered"); for (const entry of renderStack.layered) { - if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting); + if (isEntityVisibleInRect(entry.entity, visibleRect)) { + entry.entity.draw(ctx, world.time, lighting); + drawAttachedPinsForEntity(entry.entity); + } } renderPerfEnd(endLayered); @@ -1627,8 +1629,15 @@ function render() { syncCarriedPlushieToOwner(it, world); if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } - for (const it of renderStack.lodgedPins) { + for (const it of renderStack.ballLodgedPins || []) { + if (!it || drawnAttachedPinIds.has(it.id)) continue; if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + drawnAttachedPinIds.add(it.id); + } + for (const it of renderStack.lodgedPins) { + if (!it || drawnAttachedPinIds.has(it.id)) continue; + if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + drawnAttachedPinIds.add(it.id); } renderPerfEnd(endAttachments); diff --git a/js/restore_coordinator.js b/js/restore_coordinator.js index 4e3701c..8dc7fff 100644 --- a/js/restore_coordinator.js +++ b/js/restore_coordinator.js @@ -15,14 +15,13 @@ if (typeof syncTopButtons === "function") syncTopButtons(); } - function syncRestoreDependents(worldRef = global.world, context = {}) { - worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" }); + function syncRestoreDependents(worldRef = global.world) { renderWorldAfterRestore(worldRef); } function restoreSnapshot(snapshot, worldRef = global.world, options = {}) { const result = Snapshot.restoreSnapshot(snapshot, worldRef); - if (options.syncUi !== false) syncRestoreDependents(worldRef, { source: options.source || "snapshot", snapshotVersion: result?.snapshotVersion || Snapshot.version }); + if (options.syncUi !== false) syncRestoreDependents(worldRef); return result; } diff --git a/js/save_codec.js b/js/save_codec.js index 36c1c22..d4a658d 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -232,68 +232,6 @@ return readBitFields(reader, Array.from({ length: count }, () => bits)).map(v => v * Math.max(1, step)); } - function makeProfile(snapshot = {}) { - const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : []; - const itemRows = Array.isArray(snapshot.i) ? snapshot.i : []; - return { - format: "TJ1", - schema: BINARY_SCHEMA_VERSION, - alphabetSize: SAVE_TEXT_BASE, - counts: { tarinai: tarinaiRows.length, items: itemRows.length }, - sections: {}, - itemTypes: {}, - compression: {}, - }; - } - - function addProfileBytes(profile, key, bytes = 0) { - if (!profile || !key) return; - const n = Math.max(0, Number(bytes) || 0); - profile.sections[key] = (profile.sections[key] || 0) + n; - } - - function trackBytes(writer, profile, key, fn) { - if (!profile || !writer) return fn(); - const start = writer.bytes.length; - const result = fn(); - addProfileBytes(profile, key, writer.bytes.length - start); - return result; - } - - function addItemTypeProfile(profile, typeId, bytes = 0) { - if (!profile) return; - const type = ITEM_TYPE_IDS[typeId] || `type_${typeId}`; - const entry = profile.itemTypes[type] || { count: 0, bytes: 0 }; - entry.count += 1; - entry.bytes += Math.max(0, Number(bytes) || 0); - profile.itemTypes[type] = entry; - } - - function finalizeProfile(profile, rawBytes, packedBytes, text, codec = "R") { - if (!profile) return null; - const raw = rawBytes?.length || 0; - const packed = packedBytes?.length || 0; - const payloadChars = Math.max(0, String(text || "").length - EXPORT_PREFIX.length - 1); - profile.sections.totalRaw = raw; - profile.compression = { - codec, - rawBytes: raw, - packedBytes: packed, - packedDelta: raw - packed, - ratio: raw > 0 ? packed / raw : 0, - prefixChars: EXPORT_PREFIX.length + 1, - payloadChars, - totalChars: String(text || "").length, - alphabetSize: SAVE_TEXT_BASE, - containsBacktick: false, - japaneseText: true, - bitsPerChar: SAVE_TEXT_BITS, - asciiOnly: false, - }; - return profile; - } - - async function compressBytes(bytes) { if (typeof CompressionStream === "function") { const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw")); @@ -301,7 +239,7 @@ } return bytes; } - async function decompressBytes(bytes, codec = "R") { + async function decompressBytes(bytes, codec) { if (codec === DEFLATE_CODEC) { if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser"); const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw")); @@ -472,7 +410,7 @@ return global.TarinaiSeedFactory?.personalityArray?.(seed) || [100, 100, 100, 100]; } - function writeTarinai(writer, row = [], profile = null, coordState = null) { + function writeTarinai(writer, row = [], coordState = null) { const pinIdx = row[15] ?? -1; const nestIdx = row[16] ?? -1; const stateId = row[17]?.[0] || 0; @@ -480,42 +418,34 @@ const genetics = Array.isArray(row[18]) ? row[18] : []; const fightStats = Array.isArray(row[19]) ? row[19] : []; const birthSeed = row[1] || ""; - trackBytes(writer, profile, "tarinai.flags", () => writeBitFields(writer, [row[0] || 0], [10])); - trackBytes(writer, profile, "tarinai.seed", () => writer.str(birthSeed)); - trackBytes(writer, profile, "tarinai.core", () => { - const x = toSafeInt(row[2] || 0); - const y = toSafeInt(row[3] || 0); - writer.s(x - (coordState?.x || 0)); - writer.s(y - (coordState?.y || 0)); - if (coordState) { coordState.x = x; coordState.y = y; } - writer.u(row[4] || 0); - writer.u(row[5] || 1); - writeBitFields(writer, [row[6] || 0, row[7] || 0, row[8] || 0], [8, 8, 8]); - }); - trackBytes(writer, profile, "tarinai.needs", () => writeFixedPacked(writer, row[9], 6, 6, 2)); - trackBytes(writer, profile, "tarinai.traits", () => { - writeFixedDiffPacked(writer, seedPersonalityArray(birthSeed, row[0] || 0), row[10], 4, 8); - }); - trackBytes(writer, profile, "tarinai.family", () => { - writeIndexList(writer, row[11]); - }); - trackBytes(writer, profile, "tarinai.relationships", () => writeRelationships(writer, row[12])); - trackBytes(writer, profile, "tarinai.timers", () => writeTimers(writer, row[13])); - trackBytes(writer, profile, "tarinai.modes", () => writeModes(writer, row[14])); - trackBytes(writer, profile, "tarinai.tail", () => { - let tailMask = 0; - if (pinIdx >= 0) tailMask |= 1; - if (nestIdx >= 0) tailMask |= 2; - if (stateId || stateTarget >= 0) tailMask |= 4; - if (genetics.length) tailMask |= 8; - if (fightStats.length) tailMask |= 16; - 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 & 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); } - }); + writeBitFields(writer, [row[0] || 0], [10]); + writer.str(birthSeed); + const x = toSafeInt(row[2] || 0); + const y = toSafeInt(row[3] || 0); + writer.s(x - (coordState?.x || 0)); + writer.s(y - (coordState?.y || 0)); + if (coordState) { coordState.x = x; coordState.y = y; } + writer.u(row[4] || 0); + writer.u(row[5] || 1); + writeBitFields(writer, [row[6] || 0, row[7] || 0, row[8] || 0], [8, 8, 8]); + writeFixedPacked(writer, row[9], 6, 6, 2); + writeFixedDiffPacked(writer, seedPersonalityArray(birthSeed, row[0] || 0), row[10], 4, 8); + writeIndexList(writer, row[11]); + writeRelationships(writer, row[12]); + writeTimers(writer, row[13]); + writeModes(writer, row[14]); + let tailMask = 0; + if (pinIdx >= 0) tailMask |= 1; + if (nestIdx >= 0) tailMask |= 2; + if (stateId || stateTarget >= 0) tailMask |= 4; + if (genetics.length) tailMask |= 8; + if (fightStats.length) tailMask |= 16; + 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 & 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); } } function readTarinai(reader, coordState = null) { const flags = readBitFields(reader, [10])[0]; @@ -532,13 +462,12 @@ const relationships = readRelationships(reader); const timers = readTimers(reader); const modes = readModes(reader); - const modernTail = Number(reader.schemaVersion || BINARY_SCHEMA_VERSION) >= 15; - const [tailMask] = readBitFields(reader, [modernTail ? 5 : 3]); + 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 genetics = (modernTail && (tailMask & 8)) ? [reader.s(), reader.s(), reader.s(), reader.s()] : []; - const fightStats = (modernTail && (tailMask & 16)) ? [reader.u(), reader.u(), reader.s()] : []; + 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]; } @@ -604,18 +533,14 @@ for (let i = 0; i < 16; i++) out += (((x >> i) & 1) * (2 ** (2 * i))) + (((y >> i) & 1) * (2 ** (2 * i + 1))); return out; } - function writeItemPayload(writer, row = [], typeId = 0, coordState = null, profile = null) { - const start = writer.bytes.length; - trackBytes(writer, profile, "items.core", () => { - const x = toSafeInt(row[1] || 0); - const y = toSafeInt(row[2] || 0); - writer.s(x - (coordState?.x || 0)); - writer.s(y - (coordState?.y || 0)); - if (coordState) { coordState.x = x; coordState.y = y; } - if (commonItemAmountNeeded(typeId)) writer.s(row[3] || 0); - }); - trackBytes(writer, profile, "items.extra", () => writeItemExtra(writer, typeId, row[4] || [])); - addItemTypeProfile(profile, typeId, writer.bytes.length - start); + function writeItemPayload(writer, row = [], typeId = 0, coordState = null) { + const x = toSafeInt(row[1] || 0); + const y = toSafeInt(row[2] || 0); + writer.s(x - (coordState?.x || 0)); + writer.s(y - (coordState?.y || 0)); + if (coordState) { coordState.x = x; coordState.y = y; } + if (commonItemAmountNeeded(typeId)) writer.s(row[3] || 0); + writeItemExtra(writer, typeId, row[4] || []); } function readItemPayload(reader, typeId = 0, coordState = null) { const x = (coordState?.x || 0) + reader.s(); @@ -642,14 +567,14 @@ } return blocks; } - function writeItemBlocks(writer, rows = [], profile = null) { + function writeItemBlocks(writer, rows = []) { const blocks = itemBlocksFromRows(rows); writer.u(blocks.length); for (const [typeId, list] of blocks) { writer.u(typeId); writer.u(list.length); const coordState = { x: 0, y: 0 }; - for (const row of list) writeItemPayload(writer, row, typeId, coordState, profile); + for (const row of list) writeItemPayload(writer, row, typeId, coordState); } } function readItemBlocks(reader) { @@ -675,35 +600,25 @@ return [day, tarinaiCount, fieldValue(w[0]), time10, itemCount, groundValue(w[1])]; } - function encodeBinarySnapshot(snapshot, options = {}) { + function encodeBinarySnapshot(snapshot) { if (!snapshot || snapshot.v !== Snapshot.version || snapshot.a !== "tj1") throw new Error("snapshot version mismatch"); - const profile = options?.profile ? makeProfile(snapshot) : null; const writer = new BinaryWriter(); - trackBytes(writer, profile, "header", () => { - writer.b(BINARY_SCHEMA_VERSION); - writer.u(Snapshot.version); - }); - trackBytes(writer, profile, "world", () => writeWorld(writer, snapshot.w || [])); + writer.b(BINARY_SCHEMA_VERSION); + writer.u(Snapshot.version); + writeWorld(writer, snapshot.w || []); const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : []; - trackBytes(writer, profile, "tarinai.count", () => writer.u(tarinaiRows.length)); - const tarinaiStart = writer.bytes.length; + writer.u(tarinaiRows.length); const tarCoordState = { x: 0, y: 0 }; - for (const row of tarinaiRows) writeTarinai(writer, row || [], profile, tarCoordState); - addProfileBytes(profile, "tarinai.total", writer.bytes.length - tarinaiStart); + for (const row of tarinaiRows) writeTarinai(writer, row || [], tarCoordState); const itemRows = Array.isArray(snapshot.i) ? snapshot.i : []; - const itemStart = writer.bytes.length; - trackBytes(writer, profile, "items.blocks", () => writeItemBlocks(writer, itemRows, profile)); - addProfileBytes(profile, "items.total", writer.bytes.length - itemStart); - const bytes = writer.out(); - if (profile) return { bytes, profile }; - return bytes; + writeItemBlocks(writer, itemRows); + return writer.out(); } function decodeBinarySnapshot(bytes) { const reader = new BinaryReader(bytes); const schema = reader.b(); - if (schema !== BINARY_SCHEMA_VERSION && schema !== 14) throw new Error("unsupported binary save schema"); - reader.schemaVersion = schema; + if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema"); const snapshotVersion = reader.u(); if (snapshotVersion !== Snapshot.version) throw new Error("unsupported binary save version"); const w = readWorld(reader); @@ -727,18 +642,10 @@ return { codec, packed }; } - async function encodeSnapshotWithProfile(snapshot) { - const encoded = encodeBinarySnapshot(snapshot, { profile: true }); - const bytes = encoded.bytes || encoded; - const profile = encoded.profile || null; - const { codec, packed } = await packBytesForSave(bytes); - const text = `${EXPORT_PREFIX}${codec}${jp2048Encode(packed)}`; - finalizeProfile(profile, bytes, packed, text, codec); - return { text, profile }; - } - async function encodeSnapshot(snapshot) { - return (await encodeSnapshotWithProfile(snapshot)).text; + const bytes = encodeBinarySnapshot(snapshot); + const { codec, packed } = await packBytesForSave(bytes); + return `${EXPORT_PREFIX}${codec}${jp2048Encode(packed)}`; } async function decodeSnapshot(text) { @@ -752,20 +659,7 @@ } global.TarinaiSaveCodec = { - EXPORT_PREFIX, - RAW_CODEC, - DEFLATE_CODEC, - SAVE_TEXT_ALPHABET, - SAVE_TEXT_BASE, - SAVE_TEXT_BITS, - jp2048Encode, - jp2048Decode, encodeSnapshot, - encodeSnapshotWithProfile, decodeSnapshot, - encodeBinarySnapshot, - decodeBinarySnapshot, - BinaryWriter, - BinaryReader, }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/save_storage.js b/js/save_storage.js index 0ebab4f..e82bfdc 100644 --- a/js/save_storage.js +++ b/js/save_storage.js @@ -11,7 +11,6 @@ if (!hash.startsWith("\u305f\u308a")) throw new Error("unsupported Japanese hash slot value"); global.localStorage?.setItem(slotKey(slot), hash); if (meta) global.localStorage?.setItem(metaKey(slot), JSON.stringify({ ...meta, savedAt: Date.now() })); - return { hash, meta: readMeta(slot) }; } function readMeta(slot) { const raw = global.localStorage?.getItem(metaKey(slot)); @@ -32,5 +31,5 @@ global.localStorage?.removeItem(slotKey(slot)); global.localStorage?.removeItem(metaKey(slot)); } - global.TarinaiSaveStorage = { STORAGE_PREFIX, SLOT_COUNT, slotKey, writeSlot, readSlot, requireSlot, deleteSlot }; + global.TarinaiSaveStorage = { STORAGE_PREFIX, SLOT_COUNT, writeSlot, readSlot, requireSlot, deleteSlot }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/save_system.js b/js/save_system.js index f9898bc..7968e0b 100644 --- a/js/save_system.js +++ b/js/save_system.js @@ -14,18 +14,13 @@ } function restoreSnapshot(snapshot, worldRef = global.world, options = {}) { - return Restore.restoreSnapshot(snapshot, worldRef, { source: options.source || "save", syncUi: options.syncUi }); + return Restore.restoreSnapshot(snapshot, worldRef, { syncUi: options.syncUi }); } async function encodeSnapshot(snapshot = createSnapshot(global.world)) { return Codec.encodeSnapshot(snapshot); } - async function encodeSnapshotWithProfile(snapshot = createSnapshot(global.world)) { - if (typeof Codec.encodeSnapshotWithProfile === "function") return Codec.encodeSnapshotWithProfile(snapshot); - return { text: await Codec.encodeSnapshot(snapshot), profile: null }; - } - async function decodeSnapshot(text) { return Codec.decodeSnapshot(text); } @@ -39,7 +34,7 @@ async function loadSlot(slot) { const hash = Storage.requireSlot(slot); const snapshot = await decodeSnapshot(hash); - return restoreSnapshot(snapshot, global.world, { source: "slot" }); + return restoreSnapshot(snapshot, global.world); } function deleteSlot(slot) { Storage.deleteSlot(slot); } @@ -49,7 +44,7 @@ if (!entry) return "\u7a7a\u304d"; const meta = entry.m || entry; const m = meta.m || []; - const date = meta.savedAt ? new Date(meta.savedAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : (meta.c ? new Date(meta.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e"); + const date = meta.savedAt ? new Date(meta.savedAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e"; const day = m[0] || 1; const population = m[1] || 0; const fieldType = m[2] || "\u5ead"; @@ -58,60 +53,6 @@ return `${date} / ${fieldType} / ${day}\u65e5\u76ee ${timeText} / ${population}\u5339${hashLen}`; } - function bytesText(value = 0) { - const n = Math.max(0, Math.round(Number(value) || 0)); - return `${n.toLocaleString("ja-JP")}B`; - } - - function percentText(value = 0, total = 0) { - const t = Number(total) || 0; - if (t <= 0) return "-"; - return `${((Number(value) || 0) / t * 100).toFixed(1)}%`; - } - - function profileRow(label, bytes, total) { - return `
${htmlEscape(label)}${htmlEscape(bytesText(bytes))}${htmlEscape(percentText(bytes, total))}
`; - } - - function renderHashProfile(profile, dialog = ensureDialog()) { - const box = dialog?.querySelector?.("#saveHashProfile"); - if (!box) return; - if (!profile) { box.innerHTML = ""; return; } - const sections = profile.sections || {}; - const comp = profile.compression || {}; - const raw = comp.rawBytes || sections.totalRaw || 0; - const itemEntries = Object.entries(profile.itemTypes || {}) - .sort((a, b) => (b[1]?.bytes || 0) - (a[1]?.bytes || 0)) - .slice(0, 10); - const topItems = itemEntries.length - ? itemEntries.map(([type, entry]) => `${htmlEscape(type)} ${Number(entry.count || 0)}\u500b / ${htmlEscape(bytesText(entry.bytes || 0))}`).join("") - : `\u306a\u3057`; - box.innerHTML = ` -
- \u30cf\u30c3\u30b7\u30e5 ${Number(comp.totalChars || 0).toLocaleString("ja-JP")}\u5b57 - \u5727\u7e2e\u524d ${htmlEscape(bytesText(raw))} - \u5727\u7e2e\u5f8c ${htmlEscape(bytesText(comp.packedBytes || 0))} - \u5727\u7e2e\u7387 ${comp.ratio ? `${(comp.ratio * 100).toFixed(1)}%` : "-"} - \u6587\u5b57\u8868 ${comp.japaneseText ? "\u65e5\u672c\u8a9e2048" : "\u4e0d\u660e"} -
-
- ${profileRow("\u30d8\u30c3\u30c0", sections.header, raw)} - ${profileRow("\u30ef\u30fc\u30eb\u30c9", sections.world, raw)} - ${profileRow("\u305f\u308a\u306a\u3044\u5408\u8a08", sections["tarinai.total"], raw)} - ${profileRow("\u751f\u6210seed", sections["tarinai.seed"], raw)} - ${profileRow("\u57fa\u672c\u5024", sections["tarinai.core"], raw)} - ${profileRow("\u6b32\u6c42", sections["tarinai.needs"], raw)} - ${profileRow("\u6027\u683c\u5dee\u5206", sections["tarinai.traits"], raw)} - ${profileRow("\u5bb6\u65cf", sections["tarinai.family"], raw)} - ${profileRow("\u95a2\u4fc2", sections["tarinai.relationships"], raw)} - ${profileRow("\u30bf\u30a4\u30de\u30fc/\u52b9\u679c", (sections["tarinai.timers"] || 0) + (sections["tarinai.modes"] || 0) + (sections["tarinai.tail"] || 0), raw)} - ${profileRow("\u30a2\u30a4\u30c6\u30e0\u5408\u8a08", sections["items.total"], raw)} - ${profileRow("\u30a2\u30a4\u30c6\u30e0\u57fa\u672c", sections["items.core"], raw)} - ${profileRow("\u30a2\u30a4\u30c6\u30e0\u8ffd\u52a0", sections["items.extra"], raw)} -
-
\u30a2\u30a4\u30c6\u30e0\u4e0a\u4f4d:${topItems}
`; - } - function ensureDialog() { let dialog = document.getElementById("saveDialog"); if (dialog) return dialog; @@ -131,7 +72,6 @@
-
@@ -173,7 +113,7 @@ } function bindSaveSystem() { - const btn = document.getElementById("saveBtn") || global.ui?.saveBtn; + const btn = document.getElementById("saveBtn"); if (!btn || btn.dataset.saveBound === "1") return; btn.dataset.saveBound = "1"; btn.addEventListener("click", () => { @@ -228,9 +168,7 @@ if (e.target.closest("#saveExportBtn")) { const area = dialog.querySelector("#saveHashText"); if (area) { - const result = await encodeSnapshotWithProfile(createSnapshot(global.world)); - area.value = result.text || ""; - renderHashProfile(result.profile, dialog); + area.value = await encodeSnapshot(createSnapshot(global.world)); area.focus(); area.select(); } @@ -242,7 +180,7 @@ if (!text.trim()) { global.showToast?.("\u30ed\u30fc\u30c9\u3059\u308b\u546a\u6587\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; } if (!global.confirm?.("\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return; try { - restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" }); + restoreSnapshot(await decodeSnapshot(text), global.world); closeDialog(); global.showToast?.("\u546a\u6587\u304b\u3089\u30ed\u30fc\u30c9\u3057\u307e\u3057\u305f\u3002"); } catch (error) { @@ -257,7 +195,6 @@ createSnapshot, restoreSnapshot, encodeSnapshot, - encodeSnapshotWithProfile, decodeSnapshot, saveSlot, loadSlot, diff --git a/js/sim_core.js b/js/sim_core.js index 8081fc7..13bfc01 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -65,6 +65,8 @@ function stableUnit(seed, salt = "") { } function stableKeyPart(value) { + const shared = globalThis.TarinaiDeterministicHelpers?.keyPart; + if (typeof shared === "function") return shared(value); if (value == null) return "null"; if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; if (typeof value === "string" || typeof value === "boolean") return String(value); @@ -78,42 +80,16 @@ function stableKeyPart(value) { return String(value); } -function deterministicFrame(worldRef = null, hz = 60) { - const t = Number(worldRef?.time || 0) || 0; - return Math.floor(t * Math.max(1, Number(hz) || 60) + 1e-6); -} - -function deterministicUnit(worldRef = null, salt = "", ...parts) { - const seed = String(worldRef?.worldSeed || "tarinai-world"); - const frame = deterministicFrame(worldRef, 60); - const serial = Number(worldRef?._deterministicEpoch || 0) || 0; - const key = [salt, frame, serial, ...parts.map(stableKeyPart)].join("|"); - return stableUnit(seed, key); -} - -function deterministicRange(worldRef = null, salt = "", min = 0, max = 1, ...parts) { - const a = Number(min) || 0; - const b = Number(max) || 0; - return a + deterministicUnit(worldRef, salt, ...parts) * (b - a); -} - -function deterministicSigned(worldRef = null, salt = "", ...parts) { - return deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1; -} - -function deterministicChance(worldRef = null, salt = "", chance = 0, ...parts) { - const p = clamp(Number(chance) || 0, 0, 1); - if (p <= 0) return false; - if (p >= 1) return true; - return deterministicUnit(worldRef, salt, ...parts) < p; -} - -function deterministicAngle(worldRef = null, salt = "", ...parts) { - return deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts); -} - if (typeof window !== "undefined") { - window.TarinaiDeterminism = { stableKeyPart, deterministicFrame, deterministicUnit, deterministicRange, deterministicSigned, deterministicChance, deterministicAngle }; + window.TarinaiDeterminism = { + stableKeyPart, + deterministicFrame: window.deterministicFrame, + deterministicUnit: window.deterministicUnit, + deterministicRange: window.deterministicRange, + deterministicSigned: window.deterministicSigned, + deterministicChance: window.deterministicChance, + deterministicAngle: window.deterministicAngle, + }; } const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"]; @@ -622,8 +598,38 @@ class Effect { ctx.fill(); } } else if (this.type === "bubble") { - const w = Math.max(30, 14 + this.text.length * 7); - const h = 20; + ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif"; + const maxTextW = 118; + const lineH = 14; + const ellipsize = (text, maxW) => { + let s = String(text || ""); + if (ctx.measureText(s).width <= maxW) return s; + while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) s = s.slice(0, -1); + return `${s}…`; + }; + const wrap = (text, maxW) => { + const src = String(text || "").replace(/\s+/g, " ").trim() || " "; + const units = src.includes(" ") ? src.split(/(\s+)/).filter(Boolean) : Array.from(src); + const out = []; + let line = ""; + for (const unit of units) { + const candidate = line ? `${line}${unit}` : unit; + if (ctx.measureText(candidate).width <= maxW) line = candidate; + else { + if (line) out.push(line.trim()); + line = unit.trim(); + if (ctx.measureText(line).width > maxW) { out.push(ellipsize(line, maxW)); line = ""; } + } + if (out.length >= 3) break; + } + if (line && out.length < 3) out.push(line.trim()); + if (out.length > 2) out[1] = ellipsize([out[1], ...out.slice(2)].join(""), maxW); + return out.slice(0, 2).map(line => ellipsize(line, maxW)); + }; + const lines = wrap(this.text, maxTextW); + const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); + const w = Math.max(30, Math.min(142, textW + 18)); + const h = Math.max(20, 10 + lines.length * lineH); ctx.globalAlpha = Math.min(1, alpha * 1.25); ctx.fillStyle = "rgba(255, 252, 240, 0.92)"; ctx.strokeStyle = "rgba(78, 64, 48, 0.22)"; @@ -640,10 +646,11 @@ class Effect { ctx.fill(); ctx.stroke(); ctx.fillStyle = this.color || "rgba(42,36,29,0.78)"; - ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText(this.text, 0, -10); + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], 0, -h + 8 + lineH * i + lineH / 2); + } } ctx.restore(); } @@ -742,6 +749,8 @@ class SpatialGrid { const minY = Math.floor(Math.min(top, bottom) / this.cellSize); const maxY = Math.floor(Math.max(top, bottom) / this.cellSize); const cellCount = Math.max(1, (maxX - minX + 1) * (maxY - minY + 1)); + // Very large bodies would pollute most buckets; keep them in their origin + // cell and let callers use their own radius/AABB checks after retrieval. if (cellCount > 144) return this.add(map, entity); for (let cy = minY; cy <= maxY; cy += 1) { for (let cx = minX; cx <= maxX; cx += 1) { @@ -919,6 +928,8 @@ class SpatialGrid { nearbyInto(map, x, y, radius, out = [], filterDistance = false) { const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12); + // AABB-indexed entities can appear in several buckets. The per-query stamp + // dedupes without allocating a Set on every hot spatial lookup. const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1); const minX = Math.floor((x - r) / this.cellSize); const maxX = Math.floor((x + r) / this.cellSize); @@ -953,6 +964,7 @@ class SpatialGrid { nearbyRectInto(map, rect, out = []) { if (!rect) return out; + // See nearbyInto(): multi-cell insertions require per-query dedupe. const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1); const minX = Math.floor((rect.left || 0) / this.cellSize); const maxX = Math.floor((rect.right || 0) / this.cellSize); diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 04ae406..ce7b611 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -541,7 +541,6 @@ worldRef.rebuildSpatial?.(true); worldRef.markTerrainDirty?.("load"); worldRef.clampCamera?.(); - worldRef.emit?.("snapshot:restored", { snapshotVersion: SNAPSHOT_VERSION }); return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION }; } diff --git a/js/structure_lifecycle.js b/js/structure_lifecycle.js index 28b4995..836d153 100644 --- a/js/structure_lifecycle.js +++ b/js/structure_lifecycle.js @@ -1,12 +1,32 @@ "use strict"; (function (global) { - function markWorldAfterStructureChange(worldRef, reason = "structure-change") { + function markWorldAfterStructureChange(worldRef, reason = "structure-change", item = null, radius = 40) { if (!worldRef) return; worldRef.drawListDirty = true; worldRef.markItemBucketsDirty?.(reason); worldRef.markSpatialDirty?.(reason); - worldRef.markTerrainDirty?.(reason); + if (item && Number.isFinite(item.x) && Number.isFinite(item.y)) { + worldRef.markTerrainDirtyAt?.(item.x, item.y, Math.max(24, Number(radius || item.r || 40) || 40), reason); + } else { + worldRef.markTerrainDirty?.(reason); + } + } + + function markLooseItemGone(item, reason = "item-removed") { + if (!item || item.dead) return false; + // Normal Item.dead is a getter, so all non-structure removals go through + // amount/hp zeroing instead of assigning to .dead. + item.amount = 0; + item.hp = 0; + item.foodServingsRemaining = 0; + if (item.type === "grass") { + item.growth = 0; + item.grassStage = -1; + item.seedTimer = Math.max(Number(item.seedTimer || 0), 2.5); + } + item.removedReason = String(reason || "item-removed"); + return true; } function markStructureGone(structure, reason = "structure-removed") { @@ -142,20 +162,25 @@ function deleteItem(worldRef, item, opts = {}) { if (!item || item.dead) return false; - if (item.isStructure) return deleteStructure(worldRef, item, { reason: opts.reason || "delete-tool", userReason: opts.userReason, wake: opts.wake, breaker: opts.breaker || null, panicOwner: opts.panicOwner }); - item.amount = 0; - item.hp = 0; - item.removedReason = String(opts.reason || "delete-tool"); - markWorldAfterStructureChange(worldRef, opts.reason || "delete-tool"); + const reason = opts.reason || "delete-tool"; + if (item.isStructure) return deleteStructure(worldRef, item, { reason, userReason: opts.userReason, wake: opts.wake, breaker: opts.breaker || null, panicOwner: opts.panicOwner }); + const changed = markLooseItemGone(item, reason); + if (!changed) return false; + markWorldAfterStructureChange(worldRef, reason, item, Math.max(36, Number(item.r || item.radius || 24) + 18)); + worldRef?.emit?.("item:deleted", { item, type: item.type, reason }); return true; } - global.TarinaiStructureLifecycle = Object.freeze({ + const api = Object.freeze({ markWorldAfterStructureChange, + markLooseItemGone, releaseStructureUsers, deleteStructure, consumeGrassBedOnWake, deleteItem, + destroyItem: deleteItem, triggerOwnedStructureDestroyedPanic, }); -})(window); + global.TarinaiStructureLifecycle = api; + global.TarinaiItemDestruction = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/structures.js b/js/structures.js index dab5885..f7e6b43 100644 --- a/js/structures.js +++ b/js/structures.js @@ -104,7 +104,14 @@ draw(ctx, time = 0, lighting = {}) { if (this.dead) return; + let ownerAlpha = 1; + if (this.type === "plushie") { + const owner = this.owner(this.world); + ownerAlpha = owner ? clamp(1 - (owner.nestFade || (owner.insideNestBoxId ? 1 : 0)), 0, 1) : 1; + if (ownerAlpha <= 0.02) return; + } ctx.save(); + if (this.type === "plushie") ctx.globalAlpha *= ownerAlpha; ctx.translate(this.x, this.y); const night = (lighting?.light || 1) < 0.42; if (this.type === "grass_bed") { @@ -257,7 +264,7 @@ buildNeed: "fulfill", buildTime: 4, maxHp: 1, - materials: { grassMaterial: 1 }, + materials: { grassMaterial: 4 }, roles: { carriedObject: true, ownedStructure: true, fulfillObject: true }, needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 }, create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); }, diff --git a/js/tarinai.js b/js/tarinai.js index d92400b..ad3c6e3 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -1,33 +1,4 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); function tarinaiRoundRectPath(ctx, x, y, w, h, r) { diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index d686bc8..8380f5b 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -91,12 +91,16 @@ function createSleepBuildGrassBedActionSpec() { phrase: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u305f", weight: 18, timerBacked: true, - selectTarget: selectRoleTarget("grassMaterial", 520), + selectTarget(t, world) { return (typeof findNearbyBuildMaterial === "function" ? findNearbyBuildMaterial(world, t, "grass_bed", 520) : null) || findNearbyMaterial(world, t, "grassMaterial", 520); }, canStart(t, world, ctx = {}) { + const higherPriorityPlace = typeof findPrioritySleepPlaceBeforeGrassBedBuild === "function" + ? findPrioritySleepPlaceBeforeGrassBedBuild(world, t, 760) + : findNearestSleepPlace(world, t, 760); return canBuildOwnedStructureByAge(t) - && !findNearestSleepPlace(world, t, 760) + && !higherPriorityPlace && !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) + && !(Number(t?.actionIdCooldowns?.sleep_build_grass_bed || 0) > 0.01) && !["fight", "panic", "intimidate", "birth_ritual"].includes(t.state); }, start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); }, @@ -504,7 +508,7 @@ const TARINAI_ACTION_DEFINITIONS = [ label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u305f", weight: 34, - selectTarget: selectRoleTarget("grassMaterial", 520), + selectTarget(t, world) { return (typeof findNearbyBuildMaterial === "function" ? findNearbyBuildMaterial(world, t, "grass_bed", 520) : null) || findNearbyMaterial(world, t, "grassMaterial", 520); }, canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); }, }, @@ -515,7 +519,7 @@ const TARINAI_ACTION_DEFINITIONS = [ label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 28, - selectTarget: selectRoleTarget("grassMaterial", 520), + selectTarget(t, world) { return (typeof findNearbyBuildMaterial === "function" ? findNearbyBuildMaterial(world, t, "plushie", 520) : null) || findNearbyMaterial(world, t, "grassMaterial", 520); }, canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, start(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); }, }, diff --git a/js/tarinai_behavior_state.js b/js/tarinai_behavior_state.js index f3bec7f..61f34e1 100644 --- a/js/tarinai_behavior_state.js +++ b/js/tarinai_behavior_state.js @@ -88,6 +88,10 @@ return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || ""); } + function isEffectCauseText(text = "") { + return /の効果$/.test(String(text || "").trim().replace(/[。!?]+$/, "")); + } + function normalizeBehaviorValue(input, tarinai = null, previous = null) { if (!isObject(input)) return null; const now = nowFor(tarinai); @@ -138,7 +142,7 @@ forcedId: forcedRequest?.uid || String(input.forcedId || previous?.forcedId || ""), forcedRequest, sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || forcedRequest?.reasonText || previous?.sourceReasonText || ""), - causeText: String(input.causeText || forcedRequest?.causeText || previous?.causeText || ""), + causeText: String(input.causeText || forcedRequest?.causeText || ((forced || !isEffectCauseText(previous?.causeText)) ? previous?.causeText : "") || ""), specId: String(input.specId || input.actionSpecId || previous?.specId || actionId), specKind: String(input.specKind || previous?.specKind || "need_action"), }; @@ -199,7 +203,7 @@ if (!value) { const data = isObject(b.data) ? { ...b.data } : {}; delete data.forcedSource; - const next = normalizeBehaviorValue({ ...b, source: b.source === "forced" ? "need" : (b.source || "need"), forcedId: "", forcedRequest: null, sourceReasonText: "", data }, tarinai, null); + const next = normalizeBehaviorValue({ ...b, source: b.source === "forced" ? "need" : (b.source || "need"), forcedId: "", forcedRequest: null, sourceReasonText: "", causeText: "", data }, tarinai, null); tarinai.behavior = next; return next; } @@ -212,7 +216,7 @@ forcedId: String(request?.uid || value.uid || value.forcedId || b.forcedId || ""), forcedRequest: request, sourceReasonText: request?.reasonText || b.sourceReasonText || "", - causeText: b.causeText || request?.causeText || "", + causeText: request?.causeText || b.causeText || "", data: { ...(b.data || {}), forcedSource: request?.source || value.source || "external" }, }); } diff --git a/js/tarinai_behavior_text.js b/js/tarinai_behavior_text.js index ce0821e..28e0f4e 100644 --- a/js/tarinai_behavior_text.js +++ b/js/tarinai_behavior_text.js @@ -48,6 +48,10 @@ function actionTextForReason(action = null, tarinai = null) { return String(action?.label || action?.phrase || behavior?.label || "\u307c\u30fc\u3063\u3068\u3057\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, ""); } +function isEffectCauseText(text = "") { + return /の効果$/.test(String(text || "").trim().replace(/[。!?]+$/, "")); +} + function forcedCausePhrase(source = "", fallback = "") { const key = String(source || ""); if (key === "love_mochi") return "\u3078\u3053\u9905\u306e\u52b9\u679c"; @@ -72,7 +76,11 @@ function formatCauseActionText(cause = "", actionText = "\u884c\u52d5\u3057\u306 function causePhraseForReason(chosenNeed, action, tarinai = null, options = {}) { if (options?.causeText) return String(options.causeText || "").replace(/[\u3002\uff01\uff1f]+$/, ""); const b = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || {}; - if (b.causeText) return String(b.causeText).replace(/[\u3002\uff01\uff1f]+$/, ""); + if (b.causeText) { + const cause = String(b.causeText).replace(/[\u3002\uff01\uff1f]+$/, ""); + const forced = (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.forcedId || b.forcedRequest)) || (typeof isTarinaiBehaviorForced === "function" ? isTarinaiBehaviorForced(tarinai) : false); + if (forced || !isEffectCauseText(cause)) return cause; + } if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.forcedId || b.forcedRequest)) || b.source) { const forced = forcedCausePhrase(b.source || "", b.sourceReasonText || b.sourceReasonText || ""); if (forced) return forced; @@ -112,8 +120,15 @@ function needsForBehaviorText(tarinai, behavior = {}, fallbackNeed = "fulfill") function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {}) { const request = behavior?.forcedRequest || (typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(tarinai) : null) || null; - const directCause = options?.causeText || behavior?.causeText || request?.causeText || ""; - if (directCause) return String(directCause || "").replace(/[\u3002\uff01\uff1f]+$/, ""); + const optionCause = options?.causeText || ""; + if (optionCause) return String(optionCause || "").replace(/[\u3002\uff01\uff1f]+$/, ""); + const requestCause = request?.causeText || ""; + if (requestCause) return String(requestCause || "").replace(/[\u3002\uff01\uff1f]+$/, ""); + const behaviorCause = behavior?.causeText || ""; + if (behaviorCause) { + const forced = (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.forcedId || behavior?.forcedRequest)) || (typeof isTarinaiBehaviorForced === "function" ? isTarinaiBehaviorForced(tarinai) : false); + if (forced || !isEffectCauseText(behaviorCause)) return String(behaviorCause || "").replace(/[\u3002\uff01\uff1f]+$/, ""); + } if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.forcedId || behavior?.forcedRequest)) || request) { const source = request?.source || behavior?.source || "external"; const sourceReason = request?.reasonText || behavior?.sourceReasonText || behavior?.sourceReasonText || ""; diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js index 807d377..97c38bd 100644 --- a/js/tarinai_building_behavior.js +++ b/js/tarinai_building_behavior.js @@ -79,19 +79,82 @@ function findStructureBuildSpot(t, world, type) { if (world?.placementBlocked?.(probe)) continue; let nearestSame = Infinity; if (type === "grass_bed") { - for (const it of world?.nearbyItems?.(c.x, c.y, 160) || world?.items || []) { + const spacing = Math.max(46, (probe.r || 18) * 3.0); + for (const it of world?.nearbyItems?.(c.x, c.y, spacing + 90) || world?.items || []) { if (!it || it.dead || it.type !== "grass_bed") continue; - nearestSame = Math.min(nearestSame, distXY(c.x, c.y, it.x, it.y)); + const d = distXY(c.x, c.y, it.x, it.y); + const required = spacing + Math.max(0, ((it.r || 18) - (probe.r || 18)) * 0.5); + nearestSame = Math.min(nearestSame, d); + if (d < required) { nearestSame = -Infinity; break; } } + if (nearestSame === -Infinity) continue; } const score = (Number.isFinite(nearestSame) ? nearestSame : 160) - distXY(c.x, c.y, baseX, baseY) * 0.12; if (score > bestScore) { best = c; bestScore = score; } } + if (type === "grass_bed") return best; return best || candidates[0] || { x: baseX, y: baseY }; } + +function structureGrassStageCost(type = "") { + return (type === "grass_bed" || type === "plushie") ? 4 : 1; +} + +function grassMaterialAvailableStages(material) { + if (!material || material.dead || material.type !== "grass" || (material.amount || 0) <= 0) return 0; + const stage = typeof normalizeGrassStage === "function" + ? normalizeGrassStage(material) + : (typeof grassStageFromGrowth === "function" ? grassStageFromGrowth(material.growth, material.amount) : Math.floor((Number(material.amount || 0) || 0) / 24) - 1); + return Math.max(0, Math.floor(Number(stage) || 0) + 1); +} + +function reservedGrassStagesForBuildMaterial(world, material, type, self = null) { + if (!world || !material) return 0; + const id = material.id || ""; + if (!id) return 0; + let reserved = 0; + for (const other of world.tarinai || []) { + if (!other || other.dead || other === self) continue; + const plan = other.buildPlan || null; + const planType = plan?.type || (/build_plushie/.test(other.behavior?.actionId || "") ? "plushie" : (/build_grass_bed/.test(other.behavior?.actionId || "") ? "grass_bed" : "")); + if (type && planType && planType !== type) continue; + const targetId = plan?.materialId || other.target?.id || ""; + if (targetId !== id) continue; + const state = String(other.state || ""); + const actionId = String(other.behavior?.actionId || other.currentActionId || ""); + if (state === "seek_material" || state === "build" || /build_grass_bed|build_plushie|seek_material/.test(actionId)) { + reserved += structureGrassStageCost(planType || type); + } + } + return reserved; +} + +function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) { + const required = structureGrassStageCost(type); + let best = null; + let bestScore = Infinity; + const candidates = world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist + 40, true) || world?.items || []; + for (const item of candidates) { + if (!item || item.dead || !roleMatches(item, "grassMaterial")) continue; + if (tarinai?.shouldAvoidTarget?.(item)) continue; + const available = grassMaterialAvailableStages(item); + if (available < required) continue; + const reserved = reservedGrassStagesForBuildMaterial(world, item, type, tarinai); + if (available - reserved < required) continue; + const d = dist(tarinai, item); + if (d > maxDist) continue; + // Prefer nearby grass, but lightly spread simultaneous builders across + // separate tufts so a crowd does not all reserve one valid material. + const jitter = typeof stableUnit === "function" ? stableUnit(tarinai.id || tarinai.familyKey || "", `build-material:${item.id || ""}`) : Math.random(); + const score = d + Math.max(0, reserved) * 42 + jitter * 12; + if (score < bestScore) { best = item; bestScore = score; } + } + return best; +} + function buildOwnedStructureFromGrass(t, world, type, label) { - const material = findNearbyMaterial(world, t, "grassMaterial", 520); + const material = findNearbyBuildMaterial(world, t, type, 520); if (!material || !globalThis.StructureRegistry) return false; t.buildPlan = { type, @@ -111,8 +174,8 @@ function continueBuildPlan(t, world, dt) { return false; } let material = (world?.itemById?.(plan.materialId) || null); - if (material && (material.dead || !roleMatches(material, "grassMaterial"))) material = null; - if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520); + if (material && (material.dead || !roleMatches(material, "grassMaterial") || grassMaterialAvailableStages(material) < structureGrassStageCost(plan.type))) material = null; + if (!material) material = findNearbyBuildMaterial(world, t, plan.type, 520); if (!material) { return stopFailedBuildPlan(t, world, plan.type, "\u6750\u6599\u304c\u898b\u3064\u304b\u3089\u306a\u3044"); } @@ -125,10 +188,13 @@ function continueBuildPlan(t, world, dt) { plan.timer = Math.max(0, Number(plan.timer || 0) - Math.max(0.05, dt || 0.1)); t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false }); if (plan.timer > 0) return true; - consumeGrassMaterial(material, plan.type === "grass_bed" ? 4 : 1); const spot = plan.type === "plushie" ? { x: t.x, y: t.y - Math.max(28, (t.radius || 24) * 1.28) } : findStructureBuildSpot(t, world, plan.type); + if (!spot) { + return stopFailedBuildPlan(t, world, plan.type, "作る場所が見つからない"); + } + consumeGrassMaterial(material, structureGrassStageCost(plan.type)); const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world); if (!structure) { return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044"); diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index 2bcae3f..78928b4 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -1,33 +1,4 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); (function (global) { @@ -224,8 +195,54 @@ if (!id) return null; return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null; }, + + nestBoxEntryActive() { + const now = this.world?.time || 0; + if (now < (this._nestBoxExitSmoothUntil || -Infinity)) return true; + if (!this.insideNestBoxId) return false; + return now < (this._nestBoxEntrySmoothUntil || -Infinity) || ((this.nestFade || 0) > 0.01 && (this.nestFade || 0) < 0.985); + }, + + abortNestBoxEntry(box = null, reason = "巣箱がいっぱいで入れない") { + const now = this.world?.time || 0; + if (this.insideNestBoxId && (!box || this.insideNestBoxId === box.id)) this.leaveNestBox?.(box); + if (!this.insideNestBoxId) this.nestFade = 0; + if (!box || this.target === box) { + this.target = null; + this.targetKey = ""; + } + this.sleeping = false; + this.sleepSession = null; + this._nestBoxAvoidId = box?.id || this._nestBoxAvoidId || ""; + this._nestBoxAvoidUntil = now + 10.0; + this.actionIdCooldowns = this.actionIdCooldowns || {}; + this.actionCooldowns = this.actionCooldowns || {}; + this.actionIdCooldowns.sleep_in_bed = Math.max(this.actionIdCooldowns.sleep_in_bed || 0, 8.0); + this.actionIdCooldowns.return_owned_structure = Math.max(this.actionIdCooldowns.return_owned_structure || 0, 5.0); + this.actionCooldowns.sleep = Math.max(this.actionCooldowns.sleep || 0, 4.5); + if (box) { + const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(box) : { x: box.x, y: box.y }; + const dx = this.x - (entry.x || box.x || this.x); + const dy = this.y - (entry.y || box.y || this.y); + const d = Math.hypot(dx, dy) || 1; + // Do not teleport/push away from the nest. A failed entry becomes a normal + // short avoidance target so ordinary movement handles the exit path. + this.vx = clamp((this.vx || 0) + dx / d * 10, -80, 80); + this.vy = clamp((this.vy || 0) + dy / d * 8, -80, 80); + } + this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 0.7); + this._nestBoxSeekStartedAt = NaN; + this._nestBoxSeekTargetId = ""; + this.goIdle?.(reason); + this.thought = reason; + this.pinchThoughtTimer = Math.max(this.pinchThoughtTimer || 0, 1.0); + return false; + }, + wantsNestBox(box = null) { if (!box || box.dead || box.type !== "nest_box") return false; + const now = this.world?.time || 0; + if (this._nestBoxAvoidId === box.id && now < (this._nestBoxAvoidUntil || -Infinity)) return false; if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true; if (!(this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return false; return this.target === box || this.insideNestBoxId === box.id; @@ -236,12 +253,22 @@ const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5; const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; const alreadyInside = this.insideNestBoxId === box.id; - if (!alreadyInside && occupants.length >= capacity) return false; + const now = this.world?.time || 0; + if (!alreadyInside && occupants.length >= capacity) return this.abortNestBoxEntry?.(box, "巣箱がいっぱいで入れない") || false; if (!alreadyInside) { - this.nestBoxEnteredAt = this.world?.time || 0; - this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, (this.world?.time || 0) + 14); + this._nestBoxSeekStartedAt = NaN; + this._nestBoxSeekTargetId = ""; + const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(box) : { x: box.x, y: box.y }; + this.nestBoxEnteredAt = now; + this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, now + 14); this.nestBoxSlotIndex = Math.min(occupants.length, Math.max(0, capacity - 1)); - this._nestBoxEntrySmoothUntil = (this.world?.time || 0) + 0.85; + this._nestBoxEntryStartX = Number.isFinite(this.x) ? this.x : entry.x; + this._nestBoxEntryStartY = Number.isFinite(this.y) ? this.y : entry.y; + this._nestBoxEntryDoorX = Number.isFinite(entry.x) ? entry.x : box.x; + this._nestBoxEntryDoorY = Number.isFinite(entry.y) ? entry.y : box.y; + this._nestBoxEntryDuration = 0.72; + this._nestBoxEntrySmoothUntil = now + this._nestBoxEntryDuration; + this.nestFade = Math.min(this.nestFade || 0, 0.14); } this.insideNestBoxId = box.id; this.target = box; @@ -249,15 +276,31 @@ if (this.state === "sleep") this.sleeping = true; const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; const smoothDt = clamp(Number(dt || 0.016) || 0.016, 0.008, 0.045); - const firstPhase = (this.world?.time || 0) < (this._nestBoxEntrySmoothUntil || -Infinity); - const pull = clamp(smoothDt * (alreadyInside ? (firstPhase ? 5.2 : 8.5) : 7.2), 0, 0.55); - this.x = lerp(this.x, inner.x, pull); - this.y = lerp(this.y, inner.y, pull); - if (!firstPhase && (this.nestFade >= 0.97 || distXY(this.x, this.y, inner.x, inner.y) < 2.0)) { - this.x = inner.x; - this.y = inner.y; + const duration = Math.max(0.35, Number(this._nestBoxEntryDuration || 0.95) || 0.95); + const progress = clamp(1 - Math.max(0, (this._nestBoxEntrySmoothUntil || now) - now) / duration, 0, 1); + const eased = progress * progress * (3 - 2 * progress); + if (progress < 1) { + const sx = Number.isFinite(this._nestBoxEntryStartX) ? this._nestBoxEntryStartX : this.x; + const sy = Number.isFinite(this._nestBoxEntryStartY) ? this._nestBoxEntryStartY : this.y; + const doorX = Number.isFinite(this._nestBoxEntryDoorX) ? this._nestBoxEntryDoorX : box.x; + const doorY = Number.isFinite(this._nestBoxEntryDoorY) ? this._nestBoxEntryDoorY : box.y; + const ax = lerp(sx, doorX, eased); + const ay = lerp(sy, doorY, eased); + const bx = lerp(doorX, inner.x, eased); + const by = lerp(doorY, inner.y, eased); + this.x = lerp(ax, bx, eased); + this.y = lerp(ay, by, eased); + this.nestFade = clamp(Math.max(this.nestFade || 0, eased * 0.92), 0, 1); + } else { + const pull = clamp(1 - Math.exp(-smoothDt * 5.2), 0, 0.22); + this.x = lerp(this.x, inner.x, pull); + this.y = lerp(this.y, inner.y, pull); + if (this.nestFade >= 0.97 || distXY(this.x, this.y, inner.x, inner.y) < 1.6) { + this.x = inner.x; + this.y = inner.y; + } + this.nestFade = clamp((this.nestFade || 0) + smoothDt * 1.8, 0, 1); } - this.nestFade = clamp((this.nestFade || 0) + smoothDt * 4.2, 0, 1); this.vx = 0; this.vy = 0; this.impulseVx = 0; @@ -265,25 +308,108 @@ return true; }, + beginNestBoxExitAnimation(box = null) { + const b = box || this.nestBoxById(this.insideNestBoxId); + const now = this.world?.time || 0; + if (!b || !this.world?.nestBoxEntryPoint) { + this._nestBoxExitSmoothUntil = -Infinity; + this._nestBoxExitDuration = 0; + this._nestBoxExitBoxId = ""; + return false; + } + const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(b, this) : { x: b.x, y: b.y }; + const door = this.world.nestBoxEntryPoint(b); + const exit = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : door; + this._nestBoxExitStartX = Number.isFinite(this.x) ? this.x : inner.x; + this._nestBoxExitStartY = Number.isFinite(this.y) ? this.y : inner.y; + if ((this.nestFade || 0) > 0.82 || distXY(this._nestBoxExitStartX, this._nestBoxExitStartY, inner.x, inner.y) > (b.r || 42) * 0.7) { + this._nestBoxExitStartX = inner.x; + this._nestBoxExitStartY = inner.y; + } + this._nestBoxExitDoorX = Number.isFinite(door.x) ? door.x : b.x; + this._nestBoxExitDoorY = Number.isFinite(door.y) ? door.y : b.y; + this._nestBoxExitEndX = Number.isFinite(exit.x) ? exit.x : this._nestBoxExitDoorX; + this._nestBoxExitEndY = Number.isFinite(exit.y) ? exit.y : this._nestBoxExitDoorY; + this._nestBoxExitDuration = 0.58; + this._nestBoxExitSmoothUntil = now + this._nestBoxExitDuration; + this._nestBoxExitBoxId = b.id || ""; + this.nestFade = Math.max(this.nestFade || 0, 0.96); + return true; + }, + + updateNestBoxExitAnimation(dt = 0.016) { + const now = this.world?.time || 0; + const until = this._nestBoxExitSmoothUntil || -Infinity; + if (!(now < until)) return false; + const duration = Math.max(0.20, Number(this._nestBoxExitDuration || 0.58) || 0.58); + const progress = clamp(1 - Math.max(0, until - now) / duration, 0, 1); + const eased = progress * progress * (3 - 2 * progress); + const sx = Number.isFinite(this._nestBoxExitStartX) ? this._nestBoxExitStartX : this.x; + const sy = Number.isFinite(this._nestBoxExitStartY) ? this._nestBoxExitStartY : this.y; + const dx = Number.isFinite(this._nestBoxExitDoorX) ? this._nestBoxExitDoorX : sx; + const dy = Number.isFinite(this._nestBoxExitDoorY) ? this._nestBoxExitDoorY : sy; + const ex = Number.isFinite(this._nestBoxExitEndX) ? this._nestBoxExitEndX : dx; + const ey = Number.isFinite(this._nestBoxExitEndY) ? this._nestBoxExitEndY : dy; + const ax = lerp(sx, dx, eased); + const ay = lerp(sy, dy, eased); + const bx = lerp(dx, ex, eased); + const by = lerp(dy, ey, eased); + const pad = CONFIG.worldPadding || 30; + this.x = clamp(lerp(ax, bx, eased), pad, Math.max(pad, (this.world?.w || this.x) - pad)); + this.y = clamp(lerp(ay, by, eased), pad, Math.max(pad, (this.world?.h || this.y) - pad)); + this.nestFade = clamp(1 - eased, 0, 1); + this.vx = 0; + this.vy = 0; + if (progress >= 0.995 || until - now <= Math.max(0.018, dt || 0.016)) { + this.x = clamp(ex, pad, Math.max(pad, (this.world?.w || ex) - pad)); + this.y = clamp(ey, pad, Math.max(pad, (this.world?.h || ey) - pad)); + const outDx = ex - dx; + const outDy = ey - dy; + const outD = Math.hypot(outDx, outDy) || 1; + this.vx = outDx / outD * deterministicRange(this.world, "nest-exit-vx", 6, 14, this); + this.vy = outDy / outD * deterministicRange(this.world, "nest-exit-vy", 8, 18, this); + this.nestFade = 0; + this._nestBoxExitSmoothUntil = -Infinity; + this._nestBoxExitDuration = 0; + this._nestBoxExitBoxId = ""; + this._nestBoxExitStartX = this._nestBoxExitStartY = this._nestBoxExitDoorX = this._nestBoxExitDoorY = this._nestBoxExitEndX = this._nestBoxExitEndY = NaN; + } + return true; + }, + leaveNestBox(box = null) { const b = box || this.nestBoxById(this.insideNestBoxId); - if (b && this.world?.nestBoxEntryPoint) { + const hadInside = Boolean(this.insideNestBoxId); + if (b && this.world?.nestBoxEntryPoint && hadInside) this.beginNestBoxExitAnimation?.(b); + else if (b && this.world?.nestBoxEntryPoint) { const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b); this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); - this.vx = deterministicRange(this.world, "sleep-disease-shiver-vx", -8, 8, this); - this.vy = deterministicRange(this.world, "sleep-disease-shiver-vy", 6, 18, this); } this.insideNestBoxId = null; - this.nestFade = 0; + if (this.target === b) { + this.target = null; + this.targetKey = ""; + } + this.sleeping = false; + this.sleepSession = null; + if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.("巣箱から出た"); this.nestBoxEnteredAt = -Infinity; this.nestBoxStayUntil = -Infinity; this.nestBoxSlotIndex = -1; this._nestBoxEntrySmoothUntil = -Infinity; + this._nestBoxEntryStartX = NaN; + this._nestBoxEntryStartY = NaN; + this._nestBoxEntryDoorX = NaN; + this._nestBoxEntryDoorY = NaN; + this._nestBoxEntryDuration = 0; + this._nestBoxSeekStartedAt = NaN; + this._nestBoxSeekTargetId = ""; return true; }, updateNestBoxPresence(dt = 0) { + if (this.updateNestBoxExitAnimation?.(dt || 0.016)) return null; const currentBox = this.nestBoxById(this.insideNestBoxId); const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null; const box = currentBox || targetBox; @@ -310,22 +436,37 @@ if (!this.wantsNestBox(targetBox)) { this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + if (targetBox && this.target === targetBox && !this.insideNestBoxId) { + const now = this.world?.time || 0; + this.target = null; + this.targetKey = ""; + this._nestBoxAvoidId = targetBox.id || this._nestBoxAvoidId || ""; + this._nestBoxAvoidUntil = Math.max(this._nestBoxAvoidUntil || -Infinity, now + 3.2); + if (this.state === "idle" || this.state === "wander") this.wanderAngle = (this.wanderAngle || 0) + deterministicRange(this.world, "nest-idle-dispersal-angle", -1.1, 1.1, this, targetBox); + } return null; } + if (!Number.isFinite(this._nestBoxSeekStartedAt) || this._nestBoxSeekTargetId !== targetBox.id) { + this._nestBoxSeekStartedAt = this.world?.time || 0; + this._nestBoxSeekTargetId = targetBox.id; + } + if (!this.insideNestBoxId && global.TarinaiNestSleepSystem?.sleepPlaceAvailableFor && !global.TarinaiNestSleepSystem.sleepPlaceAvailableFor(this.world, this, targetBox)) { + return this.abortNestBoxEntry?.(targetBox, "巣箱がいっぱいで入れない") || null; + } + const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y }; const base = this.world?.nestBoxBaseRect ? this.world.nestBoxBaseRect(targetBox) : null; const r = targetBox.r || 42; const entryD = distXY(this.x, this.y, entry.x, entry.y); const centerD = distXY(this.x, this.y, targetBox.x, targetBox.y); - const inDoorColumn = base && this.x >= base.left + r * 0.18 && this.x <= base.right - r * 0.18 && this.y >= base.top + r * 0.06 && this.y <= base.bottom + r * 0.48; - const captureRange = Math.max(76, r * 1.55, this.radius + 48); - const nearEnough = entryD <= captureRange || centerD <= Math.max(78, r * 1.38) || inDoorColumn; + const inDoorColumn = base && this.x >= base.left + r * 0.30 && this.x <= base.right - r * 0.30 && this.y >= base.top + r * 0.16 && this.y <= base.bottom + r * 0.34; + const captureRange = Math.max(58, r * 1.18, (this.radius || 20) + 30); + const nearEnough = entryD <= captureRange || centerD <= Math.max(58, r * 1.08) || inDoorColumn; if (nearEnough) { - this.enterNestBox(targetBox, dt || 0.016); - return targetBox; + if (this.enterNestBox(targetBox, dt || 0.016)) return targetBox; + return null; } - this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); return null; }, diff --git a/js/tarinai_forced_behavior.js b/js/tarinai_forced_behavior.js index c2c6863..9886fae 100644 --- a/js/tarinai_forced_behavior.js +++ b/js/tarinai_forced_behavior.js @@ -379,6 +379,10 @@ function processForcedBehaviorQueue(tarinai, world, needs) { return false; } +function effectCauseTextLooksStale(text = "") { + return /の効果$/.test(String(text || "").trim().replace(/[。!?]+$/, "")); +} + function setBehaviorText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, reason = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null, forcedId = "", sourceReasonText = "", forcedReasonText = "", causeText = "" } = {}) { if (!tarinai) return; const now = tarinai.world?.time || 0; @@ -400,9 +404,11 @@ function setBehaviorText(tarinai, { need = null, actionId = null, actionLabel = } const nextForced = forced ?? preserveForced; const nextSource = nextForced ? "forced" : (source || previousBehavior.source || "state"); - const nextForcedId = forcedId || previousBehavior.forcedId || ""; - const nextSourceReason = sourceReasonText || forcedReasonText || previousBehavior.sourceReasonText || ""; - const nextCauseText = causeText || previousBehavior.causeText || ""; + const nextForcedId = nextForced ? (forcedId || previousBehavior.forcedId || "") : ""; + const nextSourceReason = nextForced ? (sourceReasonText || forcedReasonText || previousBehavior.sourceReasonText || "") : (sourceReasonText || forcedReasonText || ""); + const explicitCauseText = String(causeText || ""); + const previousCauseText = String(previousBehavior.causeText || ""); + const nextCauseText = explicitCauseText || ((nextForced || !effectCauseTextLooksStale(previousCauseText)) ? previousCauseText : ""); if (nextCauseText) { nextReasonText = buildReasonText(nextNeed, existingTiedNeeds.length ? existingTiedNeeds : [nextNeed], { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null, { causeText: nextCauseText }); } @@ -422,7 +428,7 @@ function setBehaviorText(tarinai, { need = null, actionId = null, actionLabel = causeText: nextCauseText, forced: nextForced, forcedId: nextForcedId, - forcedRequest: previousBehavior.forcedRequest || null, + forcedRequest: nextForced ? (previousBehavior.forcedRequest || null) : null, priority: previousBehavior.priority ?? actionPriority(action), phase, startedAt: previousBehavior.startedAt || now, diff --git a/js/tarinai_item_targeting.js b/js/tarinai_item_targeting.js index babc9b2..dc6510c 100644 --- a/js/tarinai_item_targeting.js +++ b/js/tarinai_item_targeting.js @@ -35,7 +35,7 @@ function roleMatches(item, role) { if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role); if (item.roles?.[role]) return true; if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role); - if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box"; + if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "grass_bed"; if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "splat"].includes(item.type); if (role === "grassMaterial") return item.type === "grass"; return false; @@ -65,21 +65,21 @@ function foodPriorityRank(tarinai, item) { const need = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need; const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0; const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0; - if (type === "sweet") return 4100 + Math.max(0, healthNeed - 40) * 6; + if (type === "sweet") return 4300 + Math.min(120, Math.max(0, healthNeed - 40) * 3); if (type === "water") return 3650 + Math.max(0, healthNeed - 42) * 7; if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6; - if (type === "love_mochi") return 3300 + Math.max(0, socialNeed - 45) * 7; - if (type === "fight_mochi") return 2600 + Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 4; - if (type === "sleep_drug") return need === "sleep" ? 3600 : 2450; - if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return 3000 + Math.max(0, healthNeed - 45) * 5; + if (type === "love_mochi") return 3340 + Math.min(120, Math.max(0, socialNeed - 45) * 3); + if (type === "fight_mochi") return 3280 + Math.min(90, Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 3); + if (type === "sleep_drug") return need === "sleep" ? 3380 : 3220; + if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return 3260 + Math.min(120, Math.max(0, healthNeed - 45) * 3); + if (type === "food" || type === "ant_corpse") return 3300; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 3300; if (type === "grass") { const hunger = Number(tarinai?.hunger || 0) || 0; const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0; - return 2000 + Math.max(0, hunger - 64) * 18 + Math.max(0, foodNeed - 58) * 12; + return 2200 + Math.min(260, Math.max(0, hunger - 64) * 3 + Math.max(0, foodNeed - 58) * 2); } - if (type === "zunchi") return 1000 + (tarinai?.isZunchiSlave ? 250 : 0); - if (type === "food" || type === "ant_corpse") return 3000; - if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 3000; + if (type === "zunchi") return 900 + (tarinai?.isZunchiSlave ? 180 : 0); return -1; } @@ -141,7 +141,9 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { : (world?.nearbyItems?.(tarinai.x, tarinai.y, searchDist, true) || world?.items || []); const candidates = Array.isArray(primary) ? primary.slice() : []; if (foodEmergency && world?.itemsOfType) { - for (const type of ["food", "sweet", "grass", "zunchi", "ant_corpse", "zunda_juice", "duplicator"]) { + 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); } @@ -149,6 +151,12 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { } for (const item of candidates) { if (!roleMatches(item, role)) continue; + if (role === "food" && typeof tarinai?.foodItemHasServingLeft === "function") { + const type = effectiveFoodTypeForItem(item); + if ((item.type === "duplicator" || (typeof isServingFoodType === "function" && isServingFoodType(type))) && !tarinai.foodItemHasServingLeft(item)) continue; + if (item.type === "grass" && (Number(item.amount || 0) || 0) <= 0) continue; + if (item.type === "zunchi" && (Number(item.amount || 0) || 0) <= 0) continue; + } if (tarinai?.shouldAvoidTarget?.(item)) continue; const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item); if (d > searchDist) continue; @@ -164,40 +172,19 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { } function sleepPlaceAvailableFor(world, tarinai, item) { - if (!world || !tarinai || !item || item.dead) return false; - if (!(item.type === "nest_box" || item.type === "bed")) return false; - if (tarinai.shouldAvoidTarget?.(item)) return false; - const capacity = item.type === "nest_box" - ? (world.nestBoxCapacity ? world.nestBoxCapacity(item) : 5) - : 1; - const occupancy = item.type === "nest_box" - ? (world.nestBoxOccupants ? world.nestBoxOccupants(item, Infinity).length : world.bedOccupancy?.(item) || 0) - : (world.bedOccupancy?.(item) || 0); - return occupancy < capacity; + return globalThis.TarinaiNestSleepSystem?.sleepPlaceAvailableFor?.(world, tarinai, item) || false; } function nearestSleepPlaceOfType(world, tarinai, type, maxDist = 520) { - let best = null; - let bestScore = Infinity; - const list = world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || []; - for (const item of list) { - if (!item || item.dead || item.type !== type) continue; - if (!sleepPlaceAvailableFor(world, tarinai, item)) continue; - const d = dist(tarinai, item); - if (d > maxDist) continue; - const comfort = world?.bedComfort ? world.bedComfort(item) : 1; - const score = d - comfort * 18; - if (score < bestScore) { best = item; bestScore = score; } - } - return best; + return globalThis.TarinaiNestSleepSystem?.nearestSleepPlaceOfType?.(world, tarinai, type, maxDist) || null; +} + +function findPrioritySleepPlaceBeforeGrassBedBuild(world, tarinai, maxDist = 520) { + return globalThis.TarinaiNestSleepSystem?.findPrioritySleepPlaceBeforeGrassBedBuild?.(world, tarinai, maxDist) || null; } function findNearestSleepPlace(world, tarinai, maxDist = 520) { - return nearestSleepPlaceOfType(world, tarinai, "nest_box", maxDist) - || nearestSleepPlaceOfType(world, tarinai, "bed", maxDist) - || findOwnedStructure(world, tarinai, "grass_bed", maxDist) - || findUnownedStructure(world, tarinai, "grass_bed", maxDist) - || null; + return globalThis.TarinaiNestSleepSystem?.findNearestSleepPlace?.(world, tarinai, maxDist) || null; } function findNearbyDanger(world, tarinai, maxDist = 150) { diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index c35f4df..e548514 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -1,40 +1,11 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); // Need planning runtime and Tarinai prototype extensions. // 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, 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, 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 }); } function decayNeedSatisfaction(tarinai, dt = 0) { @@ -503,7 +474,7 @@ function continueCurrentBehavior(tarinai, world, needs, dt) { source: behavior?.source || "need", forced: behaviorIsForced(behavior), priority: behavior?.priority || 0, - causeText: behavior?.causeText || "", + causeText: (behaviorIsForced(behavior) || !/の効果$/.test(String(behavior?.causeText || "").trim().replace(/[。!?]+$/, ""))) ? (behavior?.causeText || "") : "", sourceReasonText: behavior?.sourceReasonText || "", forcedReasonText: behavior?.sourceReasonText || "", forcedRequest: behavior?.forcedRequest || null, diff --git a/js/tarinai_nest_sleep_system.js b/js/tarinai_nest_sleep_system.js new file mode 100644 index 0000000..7967abc --- /dev/null +++ b/js/tarinai_nest_sleep_system.js @@ -0,0 +1,151 @@ +"use strict"; + +// Layer: tarinai/nest-sleep +// Centralizes sleep furniture, nest occupancy/reservation, and sleep-target +// selection policy so nest-box entrance logic and bed targeting do not drift. +(function (global) { + const math = global.TarinaiMath || {}; + const distFn = math.dist || ((a, b) => Math.hypot((a?.x || 0) - (b?.x || 0), (a?.y || 0) - (b?.y || 0))); + const distXYFn = math.distXY || ((ax, ay, bx, by) => Math.hypot((ax || 0) - (bx || 0), (ay || 0) - (by || 0))); + const clampFn = math.clamp || ((v, a, b) => Math.max(a, Math.min(b, v))); + + function isSleepFurniture(item) { + 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 nestBoxCapacity(worldRef, box = null) { + if (worldRef?.nestBoxCapacity && worldRef.nestBoxCapacity !== nestBoxCapacityFacade) return worldRef.nestBoxCapacity(box); + return 5; + } + + function nestBoxOccupants(worldRef, box, limit = 5) { + if (!worldRef || !box || box.dead || box.type !== "nest_box") return []; + const occupants = (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id); + return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; + } + + function isTarinaiHiddenInNestBox(worldRef, t) { + if (!t || t.dead || !t.insideNestBoxId) return false; + if (typeof t.nestBoxEntryActive === "function" && t.nestBoxEntryActive()) return false; + const now = Number(worldRef?.time ?? t.world?.time ?? 0) || 0; + if (now < (t._nestBoxEntrySmoothUntil || -Infinity) || ((t.nestFade || 0) > 0.01 && (t.nestFade || 0) < 0.985)) return false; + return true; + } + + function tarinaiIsReservingNest(t, box, now = 0) { + if (!t || t.dead || !box || t.insideNestBoxId === box.id) return false; + if (t.target !== box) return false; + if (t._nestBoxAvoidId === box.id && now < (t._nestBoxAvoidUntil || -Infinity)) return false; + const seekingNest = t.state === "seek_bed" || t.state === "sleep" || t.sleeping || t.behavior?.actionId === "sleep_in_bed"; + if (!seekingNest) return false; + return true; + } + + function nestBoxReservationCount(worldRef, box) { + if (!worldRef || !box || box.dead || box.type !== "nest_box") return 0; + const now = Number(worldRef.time || 0) || 0; + let n = 0; + for (const t of worldRef.tarinai || []) if (tarinaiIsReservingNest(t, box, now)) n += 1; + return n; + } + + function bedOccupancy(worldRef, bed) { + if (!worldRef || !isSleepFurniture(bed)) return 0; + if (bed.type === "nest_box") { + return nestBoxOccupants(worldRef, bed, Infinity).length + nestBoxReservationCount(worldRef, bed); + } + let n = 0; + const radius = Math.max(86, (bed.r || 24) * 2.45); + const near = typeof worldRef.nearbyTarinai === "function" ? worldRef.nearbyTarinai(bed.x, bed.y, radius) : (worldRef.tarinai || []); + for (const t of near || []) { + if (!t || t.dead || isTarinaiHiddenInNestBox(worldRef, t)) continue; + if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; + } + return n; + } + + function bedComfort(worldRef, bed) { + if (!isSleepFurniture(bed)) return 0.7; + const occ = bedOccupancy(worldRef, bed); + return clampFn((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18); + } + + function sleepPlaceAvailableFor(worldRef, tarinai, item) { + if (!worldRef || !tarinai || !item || item.dead || !isSleepFurniture(item)) return false; + if (tarinai.shouldAvoidTarget?.(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); + } + + function findOwnedStructure(worldRef, tarinai, type = "", maxDist = Infinity) { + let best = null, bestD = maxDist; + for (const item of worldRef?.items || []) { + if (!item?.isStructure || item.dead || item.ownerId !== tarinai?.id) continue; + if (type && item.type !== type) continue; + const d = distFn(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; + } + + function findUnownedStructure(worldRef, tarinai, type = "", maxDist = Infinity) { + let best = null, bestD = maxDist; + for (const item of worldRef?.items || []) { + if (!item?.isStructure || item.dead || item.ownerId) continue; + if (type && item.type !== type) continue; + if (tarinai?.shouldAvoidTarget?.(item)) continue; + const d = distFn(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; + } + + function nearestSleepPlaceOfType(worldRef, tarinai, type, maxDist = 520) { + let best = null; + let bestScore = Infinity; + const list = worldRef?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || worldRef?.items || []; + for (const item of list) { + if (!item || item.dead || item.type !== type) continue; + if (!sleepPlaceAvailableFor(worldRef, tarinai, item)) continue; + const d = distFn(tarinai, item); + if (d > maxDist) continue; + const comfort = bedComfort(worldRef, item); + const score = d - comfort * 18; + if (score < bestScore) { best = item; bestScore = score; } + } + return best; + } + + function findPrioritySleepPlaceBeforeGrassBedBuild(worldRef, tarinai, maxDist = 520) { + return findOwnedStructure(worldRef, tarinai, "grass_bed", maxDist) + || nearestSleepPlaceOfType(worldRef, tarinai, "nest_box", maxDist) + || nearestSleepPlaceOfType(worldRef, tarinai, "bed", maxDist) + || null; + } + + function findNearestSleepPlace(worldRef, tarinai, maxDist = 520) { + return findPrioritySleepPlaceBeforeGrassBedBuild(worldRef, tarinai, maxDist) + || findUnownedStructure(worldRef, tarinai, "grass_bed", maxDist) + || 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, + nestBoxOccupants, + nestBoxReservationCount, + bedOccupancy, + bedComfort, + sleepPlaceAvailableFor, + nearestSleepPlaceOfType, + findOwnedStructure, + findUnownedStructure, + findPrioritySleepPlaceBeforeGrassBedBuild, + findNearestSleepPlace, + distXY: distXYFn, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_social_action_runtime.js b/js/tarinai_social_action_runtime.js index 40a1e86..eafcdf2 100644 --- a/js/tarinai_social_action_runtime.js +++ b/js/tarinai_social_action_runtime.js @@ -1,33 +1,4 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); // Social, panic, fight, mate behavior runtime ticks. diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index cd3fdf1..3e871ce 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -1,33 +1,4 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); (function (global) { @@ -70,7 +41,7 @@ avoider.fearTimer = Math.max(avoider.fearTimer || 0, 0.32 * avoider.personalityProfile().fear); if (typeof applyNeedShock === "function") applyNeedShock(avoider, { safety: dt * 26, social: dt * 5 }, champion); if (this.world.relationNotice?.(avoider.id, champion.id, "tarinai-champion-avoid", 18)) { - this.world.log(`${avoider.name}はたりない王者の${champion.name}にぶつかりそうになって避けた。`, "relation", { participants: [avoider, champion], eventType: "avoid_relationship", hiddenFromObservation: true, sound: false }); + this.world.log(`${avoider.name}はたりない王者の${champion.name}にぶつかりそうになって避けた。`, "relation", { participants: [avoider, champion], eventType: "avoid_relationship", sound: false }); } } avoider.lastNeedShockBreaker = champion; diff --git a/js/tarinai_update_legacy_system.js b/js/tarinai_update_legacy_system.js index 2340766..ef01f6b 100644 --- a/js/tarinai_update_legacy_system.js +++ b/js/tarinai_update_legacy_system.js @@ -1,247 +1,16 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); - // Layer: entity-runtime/tarinai/legacy -// Compatibility owner for the former monolithic Tarinai.prototype.update body. -// The normal runtime path uses TarinaiUpdatePipeline concrete steps; this module -// exists only as a stable fallback while update steps continue to be refined. +// Compatibility shim. The former monolithic update body was removed so the +// concrete TarinaiUpdatePipeline remains the single runtime path. (function (global) { function legacyUpdate(dt) { - if (this.dead) return; - this.prevX = this.x; - this.prevY = this.y; - - const minute = dt / 60; - this.age += dt; - this.reproductionTimer -= dt; - this.blink += dt; - this.eatTimer = Math.max(0, this.eatTimer - dt); - if (this.state === "eat" && this.eatTimer <= 0.02) { - this.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true }); - } - this.eatCooldown = Math.max(0, this.eatCooldown - dt); - this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); - this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); - this.fearTimer = Math.max(0, this.fearTimer - dt); - this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); - this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); - this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); - this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); - this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); - this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt); - this.updateZunchiDisease(dt); - this.updateSpecialDiseases(dt); - if (this.updateItemEffects) this.updateItemEffects(dt); - if (this.dead) return; - this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); - this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); - if (this.fightMochiTimer > 0.04) { - applyNeedShock(this, { safety: dt * 4 }); - if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); - } - if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; - if (this.birthRitualTimer > 0) { - const beforeBirthTimer = this.birthRitualTimer; - this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); - if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { - const partner = this.world.liveTarinaiById?.(this.birthPartnerId); - if (partner) this.world.finishBirthRitual(this, partner); - this.birthRitualLeader = false; - } - } - this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); - this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); - this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); - this.pinchThoughtTimer = Math.max(0, (this.pinchThoughtTimer || 0) - dt); - this.sunbathCooldown = Math.max(0, (this.sunbathCooldown || 0) - dt); - if (this.freezeSleepDiseaseMotion(dt)) { - this.checkLife(dt); - return; - } - this.applyCursorContactCare(dt); - const colonyMoodId = this.world?.colonyMood?.id || "relaxed"; - if (colonyMoodId === "weaklings" && !this.isZunchiSlave) applyNeedRelief(this, { safety: -dt * 2, fulfill: -dt * 1 }); - if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(this, { fulfill: -dt * 3 }); - if (colonyMoodId === "ant_overrun" && this.state !== "panic") { - const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead); - if (antSeen) { - if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30a2\u30ea\u3060", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); - else { this.setActionState?.("panic", { target: null, reason: "\u30a2\u30ea\u3060", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); } - } - } - const wasFighting = this.fightTimer > 0.04; - this.fightTimer = Math.max(0, this.fightTimer - dt); - this.fightCooldown = Math.max(0, this.fightCooldown - dt); - this.groundStumbleCooldown = Math.max(0, (this.groundStumbleCooldown || 0) - dt); - this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); - if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { - const winner = this.world.liveTarinaiById?.(this.defeatedById) || null; - this.defeatedTimer = Math.max(this.defeatedTimer, deterministicRange(this.world, "defeated-recover-timer", 3.4, 5.2, this)); - this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); - this.hurtTimer = Math.max(this.hurtTimer, 1.8); - this.fallTimer = Math.max(this.fallTimer, 1.15); - this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer); - this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir; - this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45); - if (winner) this.world.resolveFightOutcome(winner, this); - this.fightTargetIds = []; - this.fightTargetId = null; - } - this.stretchTimer = Math.max(0, this.stretchTimer - dt); - this.grassEatTimer = Math.max(0, this.grassEatTimer - dt); - this.hurtTimer = Math.max(0, this.hurtTimer - dt); - this.fallTimer = Math.max(0, this.fallTimer - dt); - if ((this.world?.groundType || "soil") === "foot_massage" - && (this.groundStumbleCooldown || 0) <= 0.04 - && (this.fallTimer || 0) <= 0.04 - && !this.dead - && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(this.state || "")) - && deterministicChance(this.world, "foot-massage-stumble", dt * 0.055, this)) { - const dir = deterministicSigned(this.world, "foot-massage-stumble-dir", this); - this.groundStumbleCooldown = deterministicRange(this.world, "foot-massage-stumble-cooldown", 8.5, 16.0, this); - this.fallTimer = Math.max(this.fallTimer || 0, deterministicRange(this.world, "foot-massage-stumble-fall", 0.86, 1.18, this)); - this.fallMax = Math.max(this.fallMax || 1.05, this.fallTimer); - this.fallDir = dir; - this.vx += dir * deterministicRange(this.world, "foot-massage-stumble-vx", 26, 52, this); - this.vy += deterministicRange(this.world, "foot-massage-stumble-vy", -18, 18, this); - this.world?.spawnFallEffect?.(this.x, this.y + this.radius * 0.45); - this.setActionState?.("idle", { target: null, reason: "\u8db3\u3064\u307c\u306e\u7a81\u8d77\u306b\u3064\u307e\u3065\u3044\u305f\u3002", sleeping: false, clearTarget: false }); - if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble?.("\u3046\u3063", 1.6, "rgba(86,70,96,0.78)"); - } - if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && deterministicChance(this.world, "stress-breath-bubble", dt * 0.050, this)) { - this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); - } - if (this.state === "idle" && this.stress < 78 && deterministicChance(this.world, "idle-bubble", dt * 0.018, this)) { - this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); - } - if (this.hurtTimer > 0 && deterministicChance(this.world, "hurt-bleed-effect", dt * 5.2, this)) { - const side = this.facingDir(); - this.world.spawnBleedEffect(this.x - side * this.radius * deterministicRange(this.world, "hurt-bleed-x", 0.15, 0.42, this), this.y - this.radius * deterministicRange(this.world, "hurt-bleed-y", 0.08, 0.34, this)); - } - - this.tempTimer += dt; - if (this.tempTimer >= 0.55) { - this.tempComfort = this.world.temperatureAt(this.x, this.y); - this.tempTimer = 0; - } - const tempComfort = this.tempComfort ?? 0.62; - const tooCold = Math.max(0, 0.48 - tempComfort); - const hungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1); - this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * hungerScale * (1 + tooCold * 0.8); - this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); - this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); - // stress is derived from needs; no independent stress decay. - if (tooCold > 0) applyNeedShock(this, { health: tooCold * dt * 4, safety: tooCold * dt * 2 }); - - const phase = this.world.lightLevel(); - if (phase < 0.25) this.energy += dt * 0.07; - if (phase > 0.75) applyNeedShock(this, { health: dt * 1.2 }); - if (this.world.weather === "cloudy") { - applyNeedRelief(this, { safety: -dt * 1 }); - this.vx *= Math.pow(0.996, dt * 60); - this.vy *= Math.pow(0.996, dt * 60); - } else if (this.world.weather === "light_rain") { - this.energy += dt * 0.018; - applyNeedRelief(this, { health: -dt * 1, safety: -dt * 1 }); - } else if (this.world.weather === "sunny") { - this.vx *= Math.pow(1.001, dt * 60); - this.vy *= Math.pow(1.001, dt * 60); - } - - if (this.generation > 1 && this.syncGrowthScale) this.syncGrowthScale(); - - this.hunger = clamp(this.hunger, 0, 115); - this.loneliness = clamp(this.loneliness, 0, 110); - this.energy = clamp(this.energy, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); - this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds())); - - const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; - this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); - - if (this.freezeSleepDiseaseMotion(dt)) { - this.checkLife(dt); - return; - } - - this.behaviorLockTimer = Math.max(0, (this.behaviorLockTimer || 0) - dt); - this.aiTimer += dt; - const urgentFoodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0; - const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02 || this.hunger >= 96 || urgentFoodNeed >= 84; - const aiJitter = stableUnit(this.familyKey || this.id, "ai-interval") * 0.35; - const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter); - if (this.aiTimer >= aiInterval && this.world.spendScheduledWork?.("ai", urgentAi) !== false) { - const elapsedAiDt = Math.max(0.016, Math.min(this.aiTimer, urgentAi ? 0.75 : 1.50)); - const scanDt = Math.min(elapsedAiDt, 0.75); - this.aiTimer = 0; - // Needs intentionally advance by a fixed coarse step per AI update. - // Exact elapsed-time integration is unnecessary for this observation game - // and can over-amplify safety/fear transitions when updates are sparse. - const needStepDt = urgentAi ? 0.50 : 1.00; - this.resolveNeeds(needStepDt); - const sleepingNow = this.state === "sleep"; - if (!sleepingNow) { - this.reactToFreshZunchi(scanDt); - this.updateFacing(); - this.interactWithItems(scanDt); - this.interactWithOthers(scanDt); - } else { - this.updateFacing(); - } - } else { - this.updateFacing(); - } - this.envTimer += dt; - const envInterval = 3.0 + stableUnit(this.familyKey || this.id, "env-phase") * 1.4; - if (this.envTimer >= envInterval && this.world.spendScheduledWork?.("env", urgentAi || this.world.selected === this) !== false) { - const envDt = Math.min(this.envTimer, 1.2); - this.envTimer = 0; - this.applyEnvironment(envDt); - } - this.updateSunbath(dt); - this.maintainTargetProgress(dt); - this.move(dt); - if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt); - this.updateNestBoxPresence(dt); - if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); - const sleepingAtFrameEnd = this.state === "sleep" || this.sleeping; - if (wasSleepingAtFrameStart && !sleepingAtFrameEnd && !this.dead) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); - this.checkLife(dt); - + return global.TarinaiUpdatePipeline?.updateOne?.(this, dt) || false; } function updateOne(tarinai, dt) { if (!tarinai || tarinai.dead) return false; - legacyUpdate.call(tarinai, dt); - return true; + return global.TarinaiUpdatePipeline?.updateOne?.(tarinai, dt) !== false; } function updateWithThis(dt) { diff --git a/js/tarinai_update_policy.js b/js/tarinai_update_policy.js index 802abb1..7230463 100644 --- a/js/tarinai_update_policy.js +++ b/js/tarinai_update_policy.js @@ -29,6 +29,23 @@ return /idle|wander|approach|follow|seek|panic|fight|birth_ritual|sunbath|zunchi_sick|fight_sick|cursor_/i.test(id); } + function activeMechanicalObstacleNear(tarinai, worldRef = null) { + if (!tarinai || !worldRef || worldRef.isTarinaiHiddenInNestBox?.(tarinai)) return false; + const radius = Math.max(150, (Number(tarinai.radius) || 22) + 190); + const source = worldRef.nearbyItems?.(tarinai.x || 0, tarinai.y || 0, radius, true) || worldRef.items || []; + for (const it of source) { + if (!it || it.dead || !global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type)) continue; + const reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(64, it.r || 64); + if (Math.hypot((tarinai.x || 0) - (it.x || 0), (tarinai.y || 0) - (it.y || 0)) > reach + radius) continue; + if (it.type === "rotator" || it.type === "reciprocator") return true; + if (global.TarinaiMechanicalSystem?.passiveItemAwake?.(it)) return true; + const body = global.TarinaiPhysicsBodySystem?.ensureBody?.(it, worldRef, { syncFromLegacy: false }); + const vel = body?.velocity || {}; + if (Math.hypot(Number(vel.x || it.vx || 0) || 0, Number(vel.y || it.vy || 0) || 0) + Math.abs(Number(vel.angular || it.spin || 0) || 0) * 64 > 0.8) return true; + } + return false; + } + function hasPassivePhysicsNeed(tarinai, worldRef = null, isVisible = false) { if (!tarinai || tarinai.dead) return false; const vx = Number.isFinite(tarinai.vx) ? tarinai.vx : 0; @@ -36,8 +53,12 @@ const ivx = Number.isFinite(tarinai.impulseVx) ? tarinai.impulseVx : 0; const ivy = Number.isFinite(tarinai.impulseVy) ? tarinai.impulseVy : 0; if (Math.hypot(vx + ivx, vy + ivy) > 0.35) return true; - const sleeping = tarinai.state === "sleep" || tarinai.sleeping; + const id = behaviorId(tarinai); + const state = String(tarinai.state || id || ""); + const sleeping = state === "sleep" || tarinai.sleeping; + const lockedPassive = sleeping || state === "eat" || /eat|sunbath|birth_ritual|breed|repro/i.test(id) || /sunbath|birth_ritual|breed|repro/.test(state) || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.sunbathTimer || 0) > 0.04; if (sleeping && isVisible && !worldRef?.isTarinaiHiddenInNestBox?.(tarinai)) return true; + if (lockedPassive && isVisible && activeMechanicalObstacleNear(tarinai, worldRef)) return true; if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && tarinai.target.type !== "nest_box") return true; return false; } @@ -57,6 +78,9 @@ const isNear = isVisible || Boolean(helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, farRect)); const motionNeed = hasMotionNeed(tarinai); + const nestEntryActive = Boolean(tarinai.insideNestBoxId && (typeof tarinai.nestBoxEntryActive === "function" ? tarinai.nestBoxEntryActive() : ((worldRef.time || 0) < (tarinai._nestBoxEntrySmoothUntil || -Infinity) || ((tarinai.nestFade || 0) > 0.01 && (tarinai.nestFade || 0) < 0.985)))); + if (nestEntryActive) return { interval: 0, lane: "nest-entry", smoothMotion: false, visible: true, urgent: true }; + const hardRealtimeUrgent = (tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || @@ -113,7 +137,7 @@ && interval <= 0.75 && !worldRef.isTarinaiHiddenInNestBox?.(tarinai) && ((smoothAllowed && motionNeed) || sleepPhysical || eatPhysical)); - return { interval, lane, smoothMotion, visible: isVisible, urgent: false, sleepPhysical, passivePhysicsNeed }; + return { interval, lane, smoothMotion, visible: isVisible, urgent: false, sleepPhysical, eatPhysical, passivePhysicsNeed }; } function updateInterval(worldRef, tarinai, visibleRect) { diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js index d31d800..d206406 100644 --- a/js/tarinai_update_step_frame.js +++ b/js/tarinai_update_step_frame.js @@ -1,33 +1,4 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); // Layer: entity-runtime/tarinai/update-step @@ -35,6 +6,24 @@ // pressure, and early freeze exits. This is extracted from the former // Tarinai.prototype.update body without changing behavior. (function (global) { + function detRange(worldRef, salt, min, max, ...parts) { + const fn = global.deterministicRange; + if (typeof fn === "function") return fn(worldRef, salt, min, max, ...parts); + const a = Number(min) || 0; + const b = Number(max) || 0; + return (a + b) * 0.5; + } + + function detSigned(worldRef, salt, ...parts) { + const fn = global.deterministicSigned; + return typeof fn === "function" ? fn(worldRef, salt, ...parts) : 1; + } + + function detChance(worldRef, salt, chance, ...parts) { + const fn = global.deterministicChance; + return typeof fn === "function" ? fn(worldRef, salt, chance, ...parts) : (Number(chance) || 0) >= 1; + } + function update(t, dt, context = {}) { if (!t || t.dead) return { done: true }; t.prevX = t.x; @@ -106,7 +95,7 @@ t.defeatedTimer = Math.max(0, t.defeatedTimer - dt); if (wasFighting && t.fightTimer <= 0.04 && t.defeatedById) { const winner = t.world.liveTarinaiById?.(t.defeatedById) || null; - t.defeatedTimer = Math.max(t.defeatedTimer, deterministicRange(t.world, "defeated-recover-timer", 3.4, 5.2, t)); + t.defeatedTimer = Math.max(t.defeatedTimer, detRange(t.world, "defeated-recover-timer", 3.4, 5.2, t)); t.fearTimer = Math.max(t.fearTimer, 1.0 * t.personalityProfile().fear); t.hurtTimer = Math.max(t.hurtTimer, 1.8); t.fallTimer = Math.max(t.fallTimer, 1.15); @@ -126,27 +115,27 @@ && (t.fallTimer || 0) <= 0.04 && !t.dead && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(t.state || "")) - && deterministicChance(t.world, "foot-massage-stumble", dt * 0.055, t)) { - const dir = deterministicSigned(t.world, "foot-massage-stumble-dir", t); - t.groundStumbleCooldown = deterministicRange(t.world, "foot-massage-stumble-cooldown", 8.5, 16.0, t); - t.fallTimer = Math.max(t.fallTimer || 0, deterministicRange(t.world, "foot-massage-stumble-fall", 0.86, 1.18, t)); + && detChance(t.world, "foot-massage-stumble", dt * 0.055, t)) { + const dir = detSigned(t.world, "foot-massage-stumble-dir", t); + t.groundStumbleCooldown = detRange(t.world, "foot-massage-stumble-cooldown", 8.5, 16.0, t); + t.fallTimer = Math.max(t.fallTimer || 0, detRange(t.world, "foot-massage-stumble-fall", 0.86, 1.18, t)); t.fallMax = Math.max(t.fallMax || 1.05, t.fallTimer); t.fallDir = dir; - t.vx += dir * deterministicRange(t.world, "foot-massage-stumble-vx", 26, 52, t); - t.vy += deterministicRange(t.world, "foot-massage-stumble-vy", -18, 18, t); + t.vx += dir * detRange(t.world, "foot-massage-stumble-vx", 26, 52, t); + t.vy += detRange(t.world, "foot-massage-stumble-vy", -18, 18, t); t.world?.spawnFallEffect?.(t.x, t.y + t.radius * 0.45); t.setActionState?.("idle", { target: null, reason: "\u8db3\u3064\u307c\u306e\u7a81\u8d77\u306b\u3064\u307e\u3065\u3044\u305f\u3002", sleeping: false, clearTarget: false }); if ((t.world?.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("\u3046\u3063", 1.6, "rgba(86,70,96,0.78)"); } - if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && deterministicChance(t.world, "stress-breath-bubble", dt * 0.050, t)) { + if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && detChance(t.world, "stress-breath-bubble", dt * 0.050, t)) { t.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); } - if (t.state === "idle" && t.stress < 78 && deterministicChance(t.world, "idle-bubble", dt * 0.018, t)) { + if (t.state === "idle" && t.stress < 78 && detChance(t.world, "idle-bubble", dt * 0.018, t)) { t.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); } - if (t.hurtTimer > 0 && deterministicChance(t.world, "hurt-bleed-effect", dt * 5.2, t)) { + if (t.hurtTimer > 0 && detChance(t.world, "hurt-bleed-effect", dt * 5.2, t)) { const side = t.facingDir(); - t.world.spawnBleedEffect(t.x - side * t.radius * deterministicRange(t.world, "hurt-bleed-x", 0.15, 0.42, t), t.y - t.radius * deterministicRange(t.world, "hurt-bleed-y", 0.08, 0.34, t)); + t.world.spawnBleedEffect(t.x - side * t.radius * detRange(t.world, "hurt-bleed-x", 0.15, 0.42, t), t.y - t.radius * detRange(t.world, "hurt-bleed-y", 0.08, 0.34, t)); } t.tempTimer += dt; diff --git a/js/tarinai_update_step_legacy.js b/js/tarinai_update_step_legacy.js index fbcdb86..5859a24 100644 --- a/js/tarinai_update_step_legacy.js +++ b/js/tarinai_update_step_legacy.js @@ -1,13 +1,11 @@ "use strict"; // Layer: entity-runtime/tarinai/update-step -// Fallback step for environments where the split update pipeline cannot run. +// Compatibility shim for old step lists. Delegates to the concrete pipeline. (function (global) { - function update(t, dt, context = {}) { - const legacyUpdate = context.legacyUpdate; - if (typeof legacyUpdate !== "function") return { done: true, ok: false }; - legacyUpdate.call(t, dt); - return { done: true, ok: true }; + function update(t, dt) { + const ok = global.TarinaiUpdatePipeline?.updateOne?.(t, dt); + return { done: true, ok: ok !== false }; } global.TarinaiLegacyUpdateStep = Object.freeze({ update }); diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index 6f0f6ae..5b53fab 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -27,8 +27,9 @@ if (now - (t.lastVelocityShockDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false; if (now - (t.lastPhysicalCollisionDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false; const amount = Math.min(42, Math.max(1.0, (delta - SHOCK_DAMAGE_DELTA_THRESHOLD) / 14)); + const source = (now - Number(t.lastSolidObstacleCollisionAt || -999) < 0.75) ? (t.lastSolidObstacleCollisionSource || null) : null; t.lastVelocityShockDamageAt = now; - t.world?.applyImpactDamage?.(t, amount, "\u6025\u6fc0\u306a\u901f\u5ea6\u5909\u5316", { cause: "\u885d\u7a81" }) || t.damage?.(amount, "\u885d\u7a81"); + t.world?.applyImpactDamage?.(t, amount, "\u6025\u6fc0\u306a\u901f\u5ea6\u5909\u5316", { cause: "\u885d\u7a81", source }) || t.damage?.(amount, "\u885d\u7a81"); t.hurtTimer = Math.max(t.hurtTimer || 0, amount > 15 ? 1.9 : 0.9); t.fallTimer = Math.max(t.fallTimer || 0, amount > 12 ? 0.72 : 0.28); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer || 0); @@ -178,7 +179,8 @@ const dx = numeric(t.x) - beforeX; const dy = numeric(t.y) - beforeY; - if (world?.resolveSolidObstacleCollision && !world.isTarinaiHiddenInNestBox?.(t) && (state === "sleep" || sleepExternalMotionActive(t))) { + const lockedPassiveContact = /^(sleep|eat|sunbath|birth_ritual|breed|reproduction)$/.test(state); + if (world?.resolveSolidObstacleCollision && !world.isTarinaiHiddenInNestBox?.(t) && (lockedPassiveContact || sleepExternalMotionActive(t))) { // A moving mechanical item can hit a sleeping Tarinai while the Tarinai's // own velocity is zero. In that case swept movement will never run, so // do a small contact pass every passive-physics tick. @@ -189,7 +191,7 @@ searchRadius: Math.max(72, (numeric(t.radius, 22) || 22) + 120), maxChecks: context.cadence?.lane === "sleep" ? 18 : 22, slop: 0.28, - reason: "tarinai-sleep-passive-contact", + reason: state === "eat" ? "tarinai-eat-passive-contact" : (state === "sleep" ? "tarinai-sleep-passive-contact" : "tarinai-locked-passive-contact"), }); if (pushed) { t.lastSolidObstacleCollisionAt = world.time || 0; @@ -197,7 +199,7 @@ const cdx = numeric(t.x) - beforeContactX; const cdy = numeric(t.y) - beforeContactY; if (Math.hypot(cdx, cdy) > 0.05) { - const pushCarry = state === "sleep" ? 0.085 : 0.040; + const pushCarry = state === "sleep" ? 0.085 : (state === "eat" ? 0.070 : 0.040); t.vx = clamp(numeric(t.vx) + cdx / Math.max(0.016, dt) * pushCarry, -170, 170); t.vy = clamp(numeric(t.vy) + cdy / Math.max(0.016, dt) * pushCarry, -170, 170); } @@ -224,7 +226,7 @@ function update(t, dt, context = {}) { if (!t || t.dead) return { done: true }; const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016)); - if (context.motionOnly && /^(sleep|eat|intimidate|ant_intimidate)$/.test(String(t.state || ""))) { + if (context.motionOnly && /^(sleep|eat|sunbath|birth_ritual|breed|reproduction|intimidate|ant_intimidate)$/.test(String(t.state || ""))) { return passivePhysicalMotion(t, motionDt, context); } const beforeX = Number.isFinite(t.x) ? t.x : 0; diff --git a/js/ui.js b/js/ui.js index eb1841e..b30951d 100644 --- a/js/ui.js +++ b/js/ui.js @@ -28,6 +28,7 @@ const ui = { logPushEnabled: document.getElementById("logPushEnabled"), logPushOverlay: document.getElementById("logPushOverlay"), mobileModeControls: document.getElementById("mobileModeControls"), + mobileRotateControls: document.getElementById("mobileRotateControls"), backToGameBtn: document.getElementById("backToGameBtn"), soundCategoryControls: document.getElementById("soundPanel"), toast: document.getElementById("toast"), @@ -109,8 +110,6 @@ const uiCache = { touchStartY: 0, touchLastX: 0, touchLastY: 0, - touchLastWorldX: 0, - touchLastWorldY: 0, pinchStartDistance: 0, pinchStartZoom: 1, pinchFocusWorld: null, diff --git a/js/ui_bind.js b/js/ui_bind.js index 085426e..7e8fb1c 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -14,7 +14,7 @@ function bindUI() { return Boolean(world?.tool && world.tool !== "observe"); } function clickedInsideGameOrToolUi(target) { - return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target) || ui.panelQuickTabs?.contains?.(target) || ui.mobileModeControls?.contains?.(target) || ui.backToGameBtn?.contains?.(target)); + return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target) || ui.panelQuickTabs?.contains?.(target) || ui.mobileModeControls?.contains?.(target) || ui.mobileRotateControls?.contains?.(target) || ui.backToGameBtn?.contains?.(target)); } function panelSectionForTab(tab = "") { @@ -37,6 +37,10 @@ function bindUI() { return Boolean(window.TarinaiInputMode?.classification?.touchFirst || document.body.classList.contains("touch-mobile-ui") || window.innerWidth <= 980); } + function isDesktopLayout() { + return window.matchMedia?.("(min-width: 981px)")?.matches ?? window.innerWidth >= 981; + } + function markCollapsibleCardState(card, collapsed, { user = false } = {}) { if (!card) return; card.classList.toggle("collapsed", Boolean(collapsed)); @@ -160,6 +164,15 @@ function bindUI() { markCollapsibleCardState(card, collapsed); } + function applyDesktopInitialPanelState() { + const touchFirst = Boolean(window.TarinaiInputMode?.classify?.().touchFirst || document.body.classList.contains("touch-mobile-ui")); + const desktop = !touchFirst && isDesktopLayout(); + if (!desktop || uiCache.desktopInitialPanelStateApplied) return; + uiCache.desktopInitialPanelStateApplied = true; + for (const card of document.querySelectorAll(".collapsible-card")) markCollapsibleCardState(card, false); + for (const category of ui.toolPalette?.querySelectorAll(".tool-category") || []) markToolCategoryState(category, false); + } + function applyMobileInitialPanelState() { const touchFirst = Boolean(window.TarinaiInputMode?.classify?.().touchFirst || document.body.classList.contains("touch-mobile-ui")); if (!touchFirst || uiCache.mobileInitialPanelStateApplied) return; @@ -171,8 +184,9 @@ function bindUI() { markToolCategoryState(category, !keepOpen); } } + applyDesktopInitialPanelState(); applyMobileInitialPanelState(); - window.addEventListener("resize", applyMobileInitialPanelState, { passive: true }); + window.addEventListener("resize", () => { applyDesktopInitialPanelState(); applyMobileInitialPanelState(); }, { passive: true }); function updateBackToGameButton() { const btn = ui.backToGameBtn; @@ -194,6 +208,15 @@ function bindUI() { updateBackToGameButton(); window.TarinaiEvents?.on?.("selection:changed", () => { + if (world?._scrollSelectedDataTopOnNextSelection && isDesktopLayout() && world.selected && !world.selected.dead) { + world._scrollSelectedDataTopOnNextSelection = false; + window.setTimeout(() => { + renderSelected?.(); + scrollPanelToTab("selected"); + }, 40); + return; + } + world && (world._scrollSelectedDataTopOnNextSelection = false); const touchFirst = Boolean(window.TarinaiInputMode?.classification?.touchFirst || document.body.classList.contains("touch-mobile-ui") || window.innerWidth <= 980); const mode = window.TarinaiInputMode?.currentMode || uiCache.mobileInputMode || "auto"; if (!touchFirst || mode !== "auto" || world.tool !== "observe" || !world.selected || world.selected.dead) return; @@ -207,6 +230,39 @@ function bindUI() { const type = typeof toolItemType === "function" ? toolItemType(world?.tool || "") : ""; return type && typeof isRotatableItemType === "function" && isRotatableItemType(type) ? type : ""; } + + function syncMobileRotateControls() { + const controls = ui.mobileRotateControls; + if (!controls) return; + const show = isTouchMobileLayout() + && (window.TarinaiInputMode?.currentMode || uiCache.mobileInputMode || "auto") !== "camera" + && Boolean(activeRotatableToolType()); + controls.hidden = !show; + } + + window.syncMobileRotateControls = syncMobileRotateControls; + + function rotateMobileTool(direction = "right") { + const type = activeRotatableToolType(); + if (!type || !world?.rotateToolAngle) return false; + const step = 45 * Math.PI / 180; + world.rotateToolAngle(type, direction === "left" ? -step : step); + audio.uiClick?.(); + render(); + syncMobileRotateControls(); + return true; + } + + ui.mobileRotateControls?.addEventListener("click", (e) => { + const btn = e.target.closest?.("[data-mobile-rotate]"); + if (!btn) return; + e.preventDefault(); + rotateMobileTool(btn.dataset.mobileRotate || "right"); + }); + window.TarinaiEvents?.on?.("tool:selected", syncMobileRotateControls); + window.addEventListener("resize", syncMobileRotateControls, { passive: true }); + syncMobileRotateControls(); + window.addEventListener("keydown", (e) => { const tag = String(e.target?.tagName || "").toLowerCase(); if (tag === "input" || tag === "textarea" || e.target?.isContentEditable) return; @@ -218,14 +274,17 @@ function bindUI() { e.preventDefault(); world.rotateToolAngle(type, -step); render(); + syncMobileRotateControls(); } else if (key === "e" || key === "]") { e.preventDefault(); world.rotateToolAngle(type, step); render(); + syncMobileRotateControls(); } else if (key === "r") { e.preventDefault(); world.setToolAngle?.(type, typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0); render(); + syncMobileRotateControls(); } }); @@ -456,37 +515,61 @@ function bindUI() { renderStats(); return; } + if (tool === "clear_tool") { + world.pendingLinkEndpoint = null; + world.copyBuffer = null; + selectTool("observe"); + for (const b of uiCache.toolButtons || []) b.classList.remove("selected"); + showToast("道具選択を解除しました。"); + return; + } if (world.tool === tool && cycleToolSize(tool)) return; selectTool(tool); }); + const normalizedWheelDeltaY = (e) => { + const line = 18; + const page = Math.max(160, window.innerHeight * 0.82); + return e.deltaY * (e.deltaMode === 1 ? line : (e.deltaMode === 2 ? page : 1)); + }; + const applyScrollDelta = (el, dy) => { + if (!el || !Number.isFinite(dy) || Math.abs(dy) < 0.001) return dy; + const max = Math.max(0, el.scrollHeight - el.clientHeight); + const before = clamp(el.scrollTop || 0, 0, max); + const next = clamp(before + dy, 0, max); + el.scrollTop = next; + return dy - (next - before); + }; const chainScroll = (el) => { if (!el) return; el.addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - const atTop = el.scrollTop <= 0; - const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; - if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { - const page = document.scrollingElement || document.documentElement; - page.scrollTop += e.deltaY; - } - }, { passive: true }); + const dy = normalizedWheelDeltaY(e); + const max = Math.max(0, el.scrollHeight - el.clientHeight); + const atTop = el.scrollTop <= 0.5; + const atBottom = el.scrollTop >= max - 0.5; + if (!((dy < 0 && atTop) || (dy > 0 && atBottom))) return; + e.preventDefault(); + e.stopPropagation(); + applyScrollDelta(document.scrollingElement || document.documentElement, dy); + }, { passive: false }); }; const transferEdgeScroll = (child, parent) => { if (!child || !parent) return; child.addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + const dy = normalizedWheelDeltaY(e); const childCanScroll = child.scrollHeight > child.clientHeight + 1; - const atTop = child.scrollTop <= 0; - const atBottom = child.scrollTop + child.clientHeight >= child.scrollHeight - 1; - const movingInside = childCanScroll && ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)); + const childMax = Math.max(0, child.scrollHeight - child.clientHeight); + const movingInside = childCanScroll && ((dy < 0 && child.scrollTop > 0.5) || (dy > 0 && child.scrollTop < childMax - 0.5)); e.preventDefault(); e.stopPropagation(); - if (movingInside) { - child.scrollTop += e.deltaY; - return; + let rest = dy; + if (movingInside) rest = applyScrollDelta(child, dy); + if (Math.abs(rest) > 0.001) { + const parentRest = applyScrollDelta(parent, rest); + if (Math.abs(parentRest) > 0.001) applyScrollDelta(document.scrollingElement || document.documentElement, parentRest); } - parent.scrollTop += e.deltaY; }, { passive: false }); }; const rightPanel = document.querySelector(".panel"); diff --git a/js/ui_charts.js b/js/ui_charts.js index 45efb0c..2393170 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -170,9 +170,9 @@ function chartSeries(mode = "population") { ]; if (mode === "objects") return [ { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, - { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, + { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f5a2f" }, { key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" }, - { key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#4f9bc4" }, + { key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" }, { key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" }, ]; return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }]; diff --git a/js/ui_family_layout.js b/js/ui_family_layout.js index f622e90..a850b40 100644 --- a/js/ui_family_layout.js +++ b/js/ui_family_layout.js @@ -35,17 +35,17 @@ function lineageTreeNodeHtml(n, x, y) { const live = entity && !entity.dead ? entity : null; const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; const name = escapeHtml(n?.name || "\u4e0d\u660e"); - const status = familyStatusText(n); const gen = n?.generation ? `G${n.generation}` : "G?"; const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; const briefReason = familyDeathReasonLabel(rawReason, entity); + const status = n?.alive ? familyStatusText(n) : (briefReason || "死亡"); const reason = briefReason ? ` / ${briefReason}` : ""; const rawTags = live?.personalityTags ? live.personalityTags() : (Array.isArray(n?.personalityTags) ? n.personalityTags : []); const tags = rawTags.slice(); const displayTags = tags.slice(0, 4); const tagText = tags.length ? ` / ${tags.join(" ")}` : ""; const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}${tagText}`); - const deathLine = briefReason ? `${escapeHtml(briefReason)}` : ""; + const deathLine = ""; const tagLine = displayTags.length ? `${displayTags.map(tag => `${escapeHtml(tag)}`).join("")}` : ""; const champion = !!(live?.isTarinaiChampion || n?.isTarinaiChampion); const statLine = n ? ` @@ -385,10 +385,10 @@ function lineageBuildRowGroups(row, idSet) { function lineageLayoutRows(rows, idSet) { const NODE_W = 150; const NODE_H = 104; - const SIBLING_GAP = 32; - const GROUP_GAP = 52; - const PARTNER_GROUP_GAP = 24; - const ROW_GAP = 108; + const SIBLING_GAP = 24; + const GROUP_GAP = 42; + const PARTNER_GROUP_GAP = 18; + const ROW_GAP = 96; const LEFT_PAD = 82; const TOP_PAD = 44; const RIGHT_PAD = 48; @@ -447,7 +447,7 @@ function lineageLayoutRows(rows, idSet) { } const lefts = []; - const SOFT_EXTRA_GAP = 68; + const SOFT_EXTRA_GAP = 54; for (let gIndex = 0; gIndex < groups.length; gIndex++) { const prevGroup = groups[gIndex - 1]; const pairGap = prevGroup && prevGroup.items.length === 1 && groups[gIndex].items.length === 1 diff --git a/js/ui_family_render.js b/js/ui_family_render.js index 7e9ac59..97d8c60 100644 --- a/js/ui_family_render.js +++ b/js/ui_family_render.js @@ -54,8 +54,8 @@ function lineageFastLayoutNeeded(nodes, idSet, family) { function lineageLayoutRowsFast(rows, idSet) { const NODE_W = 150; const NODE_H = 104; - const SIBLING_GAP = 22; - const ROW_GAP = 92; + const SIBLING_GAP = 18; + const ROW_GAP = 84; const LEFT_PAD = 82; const TOP_PAD = 44; const RIGHT_PAD = 48; diff --git a/js/ui_input_mouse.js b/js/ui_input_mouse.js index 6d212a8..9cf6709 100644 --- a/js/ui_input_mouse.js +++ b/js/ui_input_mouse.js @@ -4,45 +4,17 @@ function bindMouseInput(ctx) { if (!ctx?.canvas) return false; const { canvas, world, uiCache } = ctx; + const actions = global.TarinaiPointerActionSystem?.create?.(ctx); canvas.addEventListener("contextmenu", (e) => e.preventDefault()); canvas.addEventListener("mousedown", (e) => { - if (e.button === 0 && world.tool === "area_delete") { + if (e.button === 0) { const p = ctx.screenToWorldEvent(e); - if (!p.inside) return; - e.preventDefault(); - ctx.beginAreaDelete(p, e.clientX, e.clientY); - return; - } - if (e.button === 0 && world.tool === "water_hose") { - const p = ctx.screenToWorldEvent(e); - if (!p.inside) return; - e.preventDefault(); - ctx.beginHose(p, e.clientX, e.clientY); - if (uiCache.hoseHoldTimer) clearInterval(uiCache.hoseHoldTimer); - uiCache.hoseHoldTimer = setInterval(() => { - if (!uiCache.hosing) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; return; } - const cx = uiCache.hoseLastClientX || e.clientX; - const cy = uiCache.hoseLastClientY || e.clientY; - const hp = ctx.screenToWorldClient(cx, cy); - ctx.moveHose(hp, cx, cy); - }, 90); - return; - } - if (e.button === 0 && world.tool === "shoot") { - const p = ctx.screenToWorldEvent(e); - if (!p.inside) return; - e.preventDefault(); - ctx.beginShoot(p, e.clientX, e.clientY); - return; - } - if (e.button === 0 && world.tool === "pinch") { - const p = ctx.screenToWorldEvent(e); - if (!p.inside) return; - if (!ctx.beginGrab(p, e.clientX, e.clientY, { notifyNoTarget: true })) return; - e.preventDefault(); - canvas.style.cursor = "grabbing"; - return; + const started = actions?.beginToolAt?.(p, e.clientX, e.clientY, { notifyNoTarget: world.tool === "pinch" }); + if (started) { + e.preventDefault(); + return; + } } if (e.button !== 2) return; e.preventDefault(); @@ -50,64 +22,20 @@ }); window.addEventListener("mouseup", (e) => { - if (ctx.endAreaDelete()) return; - if (ctx.endHose()) { if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; } return; } - if (ctx.endShoot()) return; - if (ctx.endGrab(e.clientX, e.clientY)) return; + if (actions?.endActive?.(e.clientX, e.clientY)) return; ctx.endPan(e.button); }); window.addEventListener("mousemove", (e) => { - if (uiCache.areaDeleting) { - const p = ctx.screenToWorldEvent(e); - ctx.moveAreaDelete(p, e.clientX, e.clientY); - return; - } - if (uiCache.hosing) { - const p = ctx.screenToWorldEvent(e); - ctx.moveHose(p, e.clientX, e.clientY); - return; - } - if (uiCache.shooting) { - const p = ctx.screenToWorldEvent(e); - ctx.moveShoot(p, e.clientX, e.clientY); - return; - } - if (uiCache.grabbing) { - const p = ctx.screenToWorldEvent(e); - const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); - if (totalMove > 3) uiCache.grabMoved = true; - ctx.moveGrab(p, e.clientX, e.clientY); - return; - } + const p = ctx.screenToWorldEvent(e); + if (actions?.moveActive?.(p, e.clientX, e.clientY)) return; ctx.movePan(e.clientX, e.clientY); }); canvas.addEventListener("click", (e) => { - if (uiCache.areaDeleteMoved) { - uiCache.areaDeleteMoved = false; - return; - } - if (uiCache.hoseMoved) { - uiCache.hoseMoved = false; - return; - } - if (uiCache.grabMoved) { - uiCache.grabMoved = false; - return; - } - if (uiCache.shootSuppressClick) { - uiCache.shootSuppressClick = false; - return; - } - if (uiCache.panMoved) { - uiCache.panMoved = false; - return; - } + if (actions?.shouldSuppressClick?.()) return; const p = ctx.screenToWorldEvent(e); - if (!p.inside) return; - window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); - renderStats(); + actions?.dispatchTapAt?.(p); }); canvas.addEventListener("mousemove", (e) => ctx.updatePointerFromEvent(e)); diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index c62ca64..7ea5049 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -21,6 +21,7 @@ document.body.classList.toggle("mobile-mode-auto", mode === "auto"); document.body.classList.toggle("mobile-mode-camera", mode === "camera"); document.body.classList.toggle("mobile-mode-tool", mode === "tool"); + global.syncMobileRotateControls?.(); return mode; } @@ -28,6 +29,7 @@ const enabled = Boolean(inputModeManager?.refresh?.().touchFirst); document.body.classList.toggle("touch-mobile-ui", enabled); if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled; + global.syncMobileRotateControls?.(); return enabled; } @@ -187,6 +189,9 @@ rememberGrabOrigin(target); uiCache.grabLastWorldX = p.x; uiCache.grabLastWorldY = p.y; + uiCache.grabVelocityX = 0; + uiCache.grabVelocityY = 0; + uiCache.grabLastMoveAt = performance?.now?.() ?? Date.now(); uiCache.grabStartX = clientX || 0; uiCache.grabStartY = clientY || 0; target.playerHeld = true; @@ -215,6 +220,11 @@ } const dx = p.x - (uiCache.grabLastWorldX ?? p.x); const dy = p.y - (uiCache.grabLastWorldY ?? p.y); + const moveNow = performance?.now?.() ?? Date.now(); + const moveDt = Math.max(0.012, Math.min(0.18, (moveNow - (uiCache.grabLastMoveAt || moveNow)) / 1000)); + uiCache.grabLastMoveAt = moveNow; + uiCache.grabVelocityX = clamp(dx / moveDt, -820, 820); + uiCache.grabVelocityY = clamp(dy / moveDt, -820, 820); uiCache.grabLastWorldX = p.x; uiCache.grabLastWorldY = p.y; const beforeX = Number(target.x || 0) || 0; @@ -238,8 +248,8 @@ target.prevY = target.y; } if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-move"); - target.vx = clamp(dx * 16, -160, 160); - target.vy = clamp(dy * 16, -160, 160); + target.vx = clamp(uiCache.grabVelocityX || dx * 16, -820, 820); + target.vy = clamp(uiCache.grabVelocityY || dy * 16, -820, 820); if (uiCache.grabKind === "tarinai") { target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); @@ -268,9 +278,21 @@ target.targetKey = ""; target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); finishTarinaiGrab(target, clientX, clientY); - } else if (target.type === "ball") { - target.prevX = target.x; - target.prevY = target.y; + } else if (uiCache.grabKind === "item") { + 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) { + target.vx = releaseVx; + target.vy = releaseVy; + const frameDt = Math.max(1 / 90, Math.min(1 / 30, Number(world.dt || 1 / 60) || 1 / 60)); + target.prevX = target.x - releaseVx * frameDt; + target.prevY = target.y - releaseVy * frameDt; + if (target.type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + releaseVx * 0.018, -42, 42); + } else if (target.type === "ball") { + target.prevX = target.x; + target.prevY = target.y; + } } if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-release"); target.playerHeld = false; @@ -286,6 +308,9 @@ uiCache.grabbing = false; uiCache.grabTarget = null; uiCache.grabKind = ""; + uiCache.grabVelocityX = 0; + uiCache.grabVelocityY = 0; + uiCache.grabLastMoveAt = 0; world.rebuildSpatial?.(); uiCache.grabLastSpatialAt = 0; canvas.style.cursor = cursorForTool(world.tool); @@ -571,6 +596,9 @@ uiCache.grabbing = false; uiCache.grabTarget = null; uiCache.grabKind = ""; + uiCache.grabVelocityX = 0; + uiCache.grabVelocityY = 0; + uiCache.grabLastMoveAt = 0; if (world) { world.hoverGrabTarget = null; world.hoverDeleteTarget = null; diff --git a/js/ui_input_touch.js b/js/ui_input_touch.js index ed40896..5980275 100644 --- a/js/ui_input_touch.js +++ b/js/ui_input_touch.js @@ -4,6 +4,7 @@ function bindTouchInput(ctx) { if (!ctx?.canvas) return false; const { canvas, world, uiCache } = ctx; + const actions = global.TarinaiPointerActionSystem?.create?.(ctx); ctx.bindMobileModeControls(); const isMobileAutoScrollMode = () => { const mode = ctx.mobileInputMode?.() || "auto"; @@ -41,33 +42,13 @@ uiCache.touchStartY = t.clientY; uiCache.touchLastX = t.clientX; uiCache.touchLastY = t.clientY; - uiCache.touchLastWorldX = p.x; - uiCache.touchLastWorldY = p.y; if (mode === "tool" && world.tool === "observe") { uiCache.touchMode = "toolTapOnly"; return; } - if (world.tool === "shoot" && p.inside) { - ctx.beginShoot(p, t.clientX, t.clientY, { touchMode: "shoot" }); - return; - } - if (world.tool === "area_delete" && p.inside) { - ctx.beginAreaDelete(p, t.clientX, t.clientY, { touchMode: "areaDelete" }); - return; - } - if (world.tool === "water_hose" && p.inside) { - ctx.beginHose(p, t.clientX, t.clientY, { touchMode: "hose" }); - if (uiCache.hoseHoldTimer) clearInterval(uiCache.hoseHoldTimer); - uiCache.hoseHoldTimer = setInterval(() => { - if (!uiCache.hosing) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; return; } - const cx = uiCache.hoseLastClientX || uiCache.touchLastX || t.clientX; - const cy = uiCache.hoseLastClientY || uiCache.touchLastY || t.clientY; - const hp = ctx.screenToWorldClient(cx, cy); - ctx.moveHose(hp, cx, cy, { touch: true }); - }, 90); - return; - } - if (world.tool === "pinch" && p.inside && ctx.beginGrab(p, t.clientX, t.clientY, { touchMode: "grab" })) return; + const touchModeByTool = { shoot: "shoot", area_delete: "areaDelete", water_hose: "hose", pinch: "grab" }; + const touchMode = touchModeByTool[world.tool]; + if (touchMode && actions?.beginToolAt?.(p, t.clientX, t.clientY, { touch: true, touchMode })) return; uiCache.touchMode = autoPageScroll ? "autoTapOrScroll" : (mode === "tool" ? "toolTapOnly" : "panOrTap"); }, { passive: false }); @@ -105,23 +86,7 @@ if (totalMove > 8) uiCache.touchMoved = true; return; } - if (uiCache.touchMode === "shoot" && uiCache.shooting) { - ctx.moveShoot(p, t.clientX, t.clientY, { touch: true }); - return; - } - if (uiCache.touchMode === "areaDelete" && uiCache.areaDeleting) { - ctx.moveAreaDelete(p, t.clientX, t.clientY, { touch: true }); - return; - } - if (uiCache.touchMode === "hose" && uiCache.hosing) { - ctx.moveHose(p, t.clientX, t.clientY, { touch: true }); - return; - } - if (uiCache.touchMode === "grab" && uiCache.grabbing) { - if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; } - ctx.moveGrab(p, t.clientX, t.clientY, { touch: true, renderFrame: true }); - return; - } + if (actions?.moveActive?.(p, t.clientX, t.clientY, { touch: true, renderFrame: uiCache.touchMode === "grab" })) return; if (uiCache.touchMode === "autoTapOrScroll") { if (totalMove > 7) uiCache.touchMoved = true; return; @@ -137,30 +102,15 @@ canvas.addEventListener("touchend", (e) => { if (uiCache.touchMode !== "autoTapOrScroll") e.preventDefault(); - if (uiCache.areaDeleting) ctx.endAreaDelete(); - if (uiCache.hosing) { ctx.endHose(); if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; } } - if (uiCache.shooting) ctx.endShoot(); - if (uiCache.grabbing) { - ctx.endGrab(e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0); + if (actions?.endActive?.(e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0)) { + // Active tool ended; click suppression is handled through movement flags. } else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) { - const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); - if (p.inside) { - window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); - renderStats(); - } + actions?.dispatchTapClient?.(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); } else if (uiCache.touchMode === "autoTapOrScroll" && !uiCache.touchMoved) { e.preventDefault(); - const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); - if (p.inside) { - window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); - renderStats(); - } + actions?.dispatchTapClient?.(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); } else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) { - const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); - if (p.inside) { - window.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); - renderStats(); - } + actions?.dispatchTapClient?.(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); } if (!e.touches?.length) { uiCache.touchMode = ""; @@ -168,7 +118,7 @@ } }, { passive: false }); - canvas.addEventListener("touchcancel", () => ctx.cancelPointerActions(), { passive: true }); + canvas.addEventListener("touchcancel", () => actions?.cancel?.(), { passive: true }); return true; } diff --git a/js/ui_log.js b/js/ui_log.js index cae4a5c..d69a775 100644 --- a/js/ui_log.js +++ b/js/ui_log.js @@ -35,7 +35,7 @@ function syncAudioControls() { } if (ui.soundMasterToggle) ui.soundMasterToggle.checked = Boolean(audio.enabled); if (ui.soundBtn) { - ui.soundBtn.textContent = audio.enabled ? "\u97f3\u8a2d\u5b9a ON" : "\u97f3\u8a2d\u5b9a OFF"; + ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF"; ui.soundBtn.classList.toggle("active", Boolean(audio.enabled)); ui.soundBtn.setAttribute("aria-expanded", ui.soundPanel && !ui.soundPanel.classList.contains("hidden") ? "true" : "false"); } @@ -57,8 +57,8 @@ function pushLogNotification(entry = {}) { const kind = entry.kind || "note"; if (!uiCache.logPushKinds?.has(kind)) return; const overlay = ui.logPushOverlay; - audio.notify?.(); if (!overlay) return; + audio.notify?.(); const card = document.createElement("div"); card.className = `log-push-toast log-push-${kind}`; const tag = document.createElement("span"); @@ -86,11 +86,6 @@ window.pushLogNotification = pushLogNotification; function shouldShowInEventLog(entry = {}) { if (!entry) return false; if (entry.eventType === "player_item_placement") return false; - const text = String(entry.text || ""); - const kind = entry.kind || ""; - // Older saves may contain direct placement records without eventType. - // Hide only participant-less player-like placement lines, preserving births, accidents, fights, and natural events. - if ((kind === "observe" || kind === "note") && !(entry.participants || []).length && /(?:を配置した。|を落とした。)$/.test(text)) return false; return true; } @@ -99,13 +94,11 @@ function renderLog(logs) { const frag = document.createDocumentFragment(); const touchFirst = Boolean(window.TarinaiInputMode?.classification?.touchFirst || document.body.classList.contains("touch-mobile-ui") || window.innerWidth <= 980); const limit = touchFirst ? 34 : 80; - const visibleLogs = Array.isArray(logs) ? logs.filter(raw => { - const entry = typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw; - return shouldShowInEventLog(entry); - }) : []; + const visibleLogs = Array.isArray(logs) + ? logs.map(raw => typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw).filter(shouldShowInEventLog) + : []; const rows = visibleLogs.slice(0, limit); - for (const raw of rows) { - const entry = typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw; + for (const entry of rows) { const kind = entry.kind || "note"; const div = document.createElement("div"); div.className = `log-entry log-${kind}`; @@ -136,7 +129,7 @@ function formatLogTime(timeValue) { function logKindLabel(kind) { return { birth: "\u8a95\u751f", death: "\u6b7b\u4ea1", fight: "\u55a7\u5629", food: "\u98df\u4e8b", grass: "\u8349", - weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", note: "\u8a18\u9332" + weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", event: "\u51fa\u6765\u4e8b", note: "\u8a18\u9332" }[kind] || "\u8a18\u9332"; } diff --git a/js/ui_pointer_action_system.js b/js/ui_pointer_action_system.js new file mode 100644 index 0000000..d1e97ec --- /dev/null +++ b/js/ui_pointer_action_system.js @@ -0,0 +1,122 @@ +"use strict"; + +// Shared pointer action router for mouse and touch. Keeps long-hold timers, +// drag movement thresholds, and tap suppression consistent across input types. +(function (global) { + const DRAG_THRESHOLD = 3; + + function createPointerActionManager(ctx) { + if (!ctx?.canvas || !ctx?.world || !ctx?.uiCache) return null; + const { canvas, world, uiCache } = ctx; + + function clearHoseHoldTimer() { + if (uiCache.hoseHoldTimer) { + clearInterval(uiCache.hoseHoldTimer); + uiCache.hoseHoldTimer = null; + } + } + + function ensureHoseHoldTimer(clientX, clientY, options = {}) { + clearHoseHoldTimer(); + uiCache.hoseHoldTimer = setInterval(() => { + if (!uiCache.hosing) { + clearHoseHoldTimer(); + return; + } + const cx = uiCache.hoseLastClientX || uiCache.touchLastX || clientX; + const cy = uiCache.hoseLastClientY || uiCache.touchLastY || clientY; + const hp = ctx.screenToWorldClient(cx, cy); + ctx.moveHose(hp, cx, cy, { touch: Boolean(options.touch) }); + }, 90); + } + + function beginToolAt(p, clientX, clientY, options = {}) { + if (!p?.inside) return false; + const tool = String(world.tool || ""); + if (tool === "area_delete") { + return Boolean(ctx.beginAreaDelete(p, clientX, clientY, options)); + } + if (tool === "water_hose") { + const started = Boolean(ctx.beginHose(p, clientX, clientY, options)); + if (started) ensureHoseHoldTimer(clientX, clientY, options); + return started; + } + if (tool === "shoot") { + return Boolean(ctx.beginShoot(p, clientX, clientY, options)); + } + if (tool === "pinch") { + const started = Boolean(ctx.beginGrab(p, clientX, clientY, options)); + if (started) canvas.style.cursor = "grabbing"; + return started; + } + return false; + } + + function moveActive(p, clientX, clientY, options = {}) { + if (uiCache.areaDeleting) return Boolean(ctx.moveAreaDelete(p, clientX, clientY, options) || true); + if (uiCache.hosing) return Boolean(ctx.moveHose(p, clientX, clientY, options) || true); + if (uiCache.shooting) return Boolean(ctx.moveShoot(p, clientX, clientY, options)); + if (uiCache.grabbing) { + const sx = uiCache.grabStartX || clientX; + const sy = uiCache.grabStartY || clientY; + if (Math.hypot(clientX - sx, clientY - sy) > DRAG_THRESHOLD) { + uiCache.grabMoved = true; + if (options.touch) uiCache.touchMoved = true; + } + return Boolean(ctx.moveGrab(p, clientX, clientY, options)); + } + return false; + } + + function endActive(clientX = 0, clientY = 0) { + if (ctx.endAreaDelete()) return true; + if (ctx.endHose()) { + clearHoseHoldTimer(); + return true; + } + if (ctx.endShoot()) return true; + if (ctx.endGrab(clientX, clientY)) return true; + return false; + } + + function shouldSuppressClick() { + const flags = ["areaDeleteMoved", "hoseMoved", "grabMoved", "shootSuppressClick", "panMoved"]; + for (const key of flags) { + if (uiCache[key]) { + uiCache[key] = false; + return true; + } + } + return false; + } + + function dispatchTapAt(p) { + if (!p?.inside) return false; + global.TarinaiCommands?.dispatch?.(world, { type: "pointer.primary", x: p.x, y: p.y }); + global.renderStats?.(); + return true; + } + + function dispatchTapClient(clientX, clientY) { + return dispatchTapAt(ctx.screenToWorldClient(clientX, clientY)); + } + + function cancel() { + clearHoseHoldTimer(); + ctx.cancelPointerActions?.(); + } + + return Object.freeze({ + beginToolAt, + moveActive, + endActive, + shouldSuppressClick, + dispatchTapAt, + dispatchTapClient, + cancel, + clearHoseHoldTimer, + }); + } + + global.TarinaiPointerActionSystem = Object.freeze({ create: createPointerActionManager }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/ui_tools.js b/js/ui_tools.js index b432b47..f74d7e4 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -1,7 +1,7 @@ "use strict"; const SCALABLE_TOOLS = new Set(scalableToolIds()); -const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose", "rope", "rod", "reciprocator", "copy", "undo", "redo", "area_delete"]); +const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose", "rope", "rod", "reciprocator", "copy", "undo", "redo", "area_delete", "clear_tool"]); const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"]; @@ -15,7 +15,7 @@ function renderToolPalette() { const section = document.createElement("section"); section.className = `tool-category ${category.themeClass || ""}`.trim(); section.dataset.toolCategory = category.id; - const mobileCompact = Boolean(window.TarinaiInputMode?.classification?.touchFirst || document.body.classList.contains("touch-mobile-ui") || window.innerWidth <= 980); + const mobileCompact = Boolean(window.TarinaiInputMode?.classification?.touchFirst || document.body.classList.contains("touch-mobile-ui")); const initiallyCollapsed = mobileCompact && category.id !== "operate"; if (initiallyCollapsed) section.classList.add("collapsed"); const toggle = document.createElement("button"); diff --git a/js/version.js b/js/version.js index 3e9f025..db89cf7 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.15.47"; - const APP_BUILD = "tarinai-event-family-oshibyo-fix-v39-15-45"; + const APP_VERSION = "39.15.63"; + const APP_BUILD = "tarinai-medium-unification-v39-15-60"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index c4a7430..e70463b 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -1,46 +1,9 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); (function (global) { const World = global.World; if (!World) throw new Error("World is not available for mixin: world_combat_effects.js"); - const PHYSICAL_DAMAGE_SPEED_THRESHOLD = 165; - function physicalDamageFromImpactSpeed(speed = 0, { min = 0, max = 58, scale = 8.6 } = {}) { - const v = Math.max(0, Number(speed) || 0); - if (v < PHYSICAL_DAMAGE_SPEED_THRESHOLD) return 0; - const raw = (v - PHYSICAL_DAMAGE_SPEED_THRESHOLD) / Math.max(1, scale); - return clamp(raw, min, max); - } - Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) { let hit = 0; @@ -77,6 +40,39 @@ return hit; }, + + blastLoosePinsFrom(x, y, blastRadius, source = null, scale = 1, salt = "blast") { + let blastedPins = 0; + const pinSystem = globalThis.TarinaiItemDynamicPinSystem; + const pins = (this.items || []).filter(it => it && !it.dead && typeof isPinType === "function" && isPinType(it.type)); + for (const pin of pins) { + if (pin.pinState === "lodged") continue; + let dx = pin.x - x; + let dy = pin.y - y; + let d = Math.hypot(dx, dy) || 1; + if (d > blastRadius * 1.18) continue; + const p = clamp(1 - d / (blastRadius * 1.18), 0, 1); + if (d < 0.001) { + const a = deterministicAngle(this, `${salt}-pin-overlap`, source || pin, pin); + dx = Math.cos(a); + dy = Math.sin(a); + d = 1; + } + if (pin.pinState === "ball_lodged") pinSystem?.detach?.(pin, this, "blast"); + const blast = (360 + p * 820) * (Number(scale) || 1); + this.applyImpulse(pin, + dx / d * blast + deterministicRange(this, `${salt}-pin-jitter-x`, -70, 70, source || pin, pin), + dy / d * blast + deterministicRange(this, `${salt}-pin-jitter-y`, -70, 70, source || pin, pin) + ); + pin.spinVelocity = clamp((pin.spinVelocity || 0) + deterministicRange(this, `${salt}-pin-spin`, -18, 18, source || pin, pin), -38, 38); + pin.noStickUntil = (this.time || 0) + 0.22; + pin.lastPokedAt = this.time || 0; + this.effects.push(new Effect("ring", pin.x, pin.y, { size: Math.max(12, (pin.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.46)" })); + blastedPins += 1; + } + return blastedPins; + }, + explodeFirecracker(it) { if (!it || it.dead || it.type !== "firecracker") return; it.amount = 0; @@ -164,14 +160,15 @@ const blast = 430 + p * 960; this.applyImpulse(ball, dx / d * blast + deterministicRange(this, "firecracker-ball-jitter-x", -80, 80, it, ball), dy / d * blast + deterministicRange(this, "firecracker-ball-jitter-y", -80, 80, it, ball)); const speed = Math.hypot(ball.vx || 0, ball.vy || 0); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "firecracker-ball-spin", -24, 24, it, ball), -38, 38); ball.lastPokedAt = this.time || 0; ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" })); blastedBalls += 1; } + const blastedPins = this.blastLoosePinsFrom(x, y, blastRadius, it, it.blastScale || 1, "firecracker"); audio.explode(); - this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u8fba\u308a\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); + this.log((blastedBalls || blastedPins) ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u8efd\u3044\u7269\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u8fba\u308a\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); }, explodeDiseaseTarinai(t) { @@ -257,13 +254,18 @@ ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" })); } + this.blastLoosePinsFrom(x, y, blastRadius, t, blastScale, "disease-explosion"); t.die("\u7206\u767a\u75c5"); audio.explode(); this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident", { participants: [t] }); }, + waterHoseRadius() { + return 138; + }, + applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) { - const radius = 276; + const radius = this.waterHoseRadius?.() || 138; const speed = Math.hypot(dx, dy) || 1; const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1); const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7); @@ -279,7 +281,13 @@ } else if (it.type === "grass") { it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 135 * (0.30 + p)); it.growth = Math.max(0, (Number(it.growth) || 0) - dt * 0.50 * (0.20 + p)); - if (it.amount <= 0.5) { it.amount = 0; } + if (it.amount <= 0.5) { + it.amount = 0; + it.hp = 0; + it.growth = 0; + it.grassStage = -1; + it.removedReason = "water-hose-grass"; + } cleaned += p * 0.72; } else if (it.type === "grass_bed") { it.amount = 0; @@ -315,6 +323,7 @@ this.markItemBucketsDirty?.("water-hose-clean"); this.markTerrainDirtyAt?.(x, y, radius + 84, "water-hose-clean"); this.markSpatialDirty?.("water-hose-clean"); + this.compactItems?.(); this.updateItemCounts(); } }, @@ -412,31 +421,19 @@ }, isSleepFurniture(it) { - return Boolean(it && (it.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (it.type === "bed" || it.type === "nest_box")))); + return Boolean(globalThis.TarinaiNestSleepSystem?.isSleepFurniture?.(it)); }, isTarinaiHiddenInNestBox(t) { - return Boolean(t && !t.dead && t.insideNestBoxId); + return Boolean(globalThis.TarinaiNestSleepSystem?.isTarinaiHiddenInNestBox?.(this, t)); }, bedOccupancy(bed) { - if (!this.isSleepFurniture(bed)) return 0; - if (bed.type === "nest_box") { - return this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity).length : 0; - } - let n = 0; - const radius = Math.max(86, bed.r * 2.45); - for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) { - if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; - if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; - } - return n; + return globalThis.TarinaiNestSleepSystem?.bedOccupancy?.(this, bed) || 0; }, bedComfort(bed) { - if (!this.isSleepFurniture(bed)) return 0.7; - const occ = this.bedOccupancy(bed); - return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18); + return globalThis.TarinaiNestSleepSystem?.bedComfort?.(this, bed) ?? 0.7; }, relationNotice(aId, bId, kind, cooldown = 30) { const key = [aId, bId].sort().join(":") + `:${kind}`; @@ -478,9 +475,10 @@ if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; const sourceLabel = (src) => { if (!src) return ""; - if (src.name) return String(src.name); + if (src instanceof Tarinai || (src.name && src.radius && !src.type)) return `「${String(src.name || "たりない")}」`; if (src.type && typeof toolLabel === "function") return toolLabel(src.type); if (src.type) return String(src.type); + if (src.name) return String(src.name); return ""; }; let normalized = options.cause || cause || "\u885d\u7a81"; @@ -606,74 +604,7 @@ }, itemDropImpact(it) { - if (!it || it.dead || it.type === "water") return; - const isStone = it.type === "stone"; - const isGenkotsu = it.type === "genkotsu"; - const dropFallHeight = isGenkotsu ? Math.max(360, (it.r || 88) * 5.8) : 170; - const dropDuration = Math.max(0.08, Number(it.dropMax || 0.42) || 0.42); - const impactSpeed = dropFallHeight / dropDuration; - const radius = Math.max(32, Math.min(220, (it.r || 14) * 1.35 + impactSpeed * 0.055)); - const damage = physicalDamageFromImpactSpeed(impactSpeed, { min: 1.2, max: isGenkotsu ? 58 : 34, scale: 8.6 }); - const push = clamp((impactSpeed - 120) * 0.62, 42, isGenkotsu ? 1040 : 260); - const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3\u306b\u6f70\u3055\u308c\u305f" : (impactSpeed >= PHYSICAL_DAMAGE_SPEED_THRESHOLD ? "\u9ad8\u901f\u3067\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u885d\u7a81" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f")); - if (isGenkotsu) { this.lastGenkotsuAt = this.time || 0; it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); this.triggerGroundShake?.(2.0, 10); } - let hit = 0; - for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) { - if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; - const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); - const p = clamp(1 - d / (radius + t.radius), 0, 1); - if (p <= 0) continue; - let nx = (t.x - it.x) / d; - let ny = (t.y - it.y) / d; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = deterministicAngle(this, "drop-impact-overlap-normal", it, t); - nx = Math.cos(a); - ny = Math.sin(a); - } - t.vx += nx * push * (0.35 + p); - t.vy += ny * push * (0.35 + p) - Math.min(210, push * 0.22) * p; - const dealtDamage = damage * (0.35 + p * 0.65); - if (dealtDamage > 0) t.damage(dealtDamage, reason); - if (isGenkotsu) { - t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); - if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 }); - t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); - t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044"; - } else { - t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b"; - } - t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); - t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); - t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); - t.fallDir = nx >= 0 ? 1 : -1; - hit += 1; - } - if (damage > 0 && typeof damageNearbyStructures === "function") damageNearbyStructures(this, it.x, it.y, radius, damage, it); - const antHit = damage > 0 ? this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { - push: p => push * (0.62 + p * 1.25), - }) : 0; - if (isGenkotsu) { - this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75, { limit: 96, effectLimit: 6 }); - const existingEffects = (this.effects || []).length; - const ringCount = existingEffects > 180 ? 2 : 3; - const burstCount = existingEffects > 180 ? 4 : 7; - for (let r = 0; r < ringCount; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 42, life: 0.32 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.68)" : "rgba(74,57,42,0.38)" })); - for (let i = 0; i < burstCount; i++) this.effects.push(new Effect("fight", it.x + deterministicRange(this, "genkotsu-impact-effect-x", -38, 38, it, i), it.y + deterministicRange(this, "genkotsu-impact-effect-y", -22, 28, it, i), { size: deterministicRange(this, "genkotsu-impact-effect-size", 12, 22, it, i), life: deterministicRange(this, "genkotsu-impact-effect-life", 0.22, 0.42, it, i), color: "rgba(255,205,46,0.78)" })); - } else { - this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); - } - if (hit > 0 || antHit > 0 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45)); - if (isGenkotsu) audio.genkotsuImpact?.(); - else if (isStone) audio.stoneImpact?.(); - else if (hit > 0 || antHit > 0) audio.poke?.(); - else audio.drop?.(); - if (hit > 0 || antHit > 0) { - const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177"); - const victims = []; - if (hit > 0) victims.push(`${hit}\u5339\u306e\u305f\u308a\u306a\u3044`); - if (antHit > 0) victims.push(`${antHit}\u5339\u306e\u30a2\u30ea`); - this.log(`${label}\u304c\u843d\u3061\u3001${victims.join("\u3068")}\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); - } + return global.TarinaiImpactResponseSystem?.itemDropImpact?.(this, it); }, limitBallChasers() { if (!this.itemCounts?.ball) return; @@ -725,327 +656,20 @@ ball.amount = Math.max(ball.amount || 999, 999); audio.ballHit?.(); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" })); - this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke", hiddenFromObservation: true }); + this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke" }); return true; }, resolveBallBallCollisions(dt) { - if ((this.itemCounts?.ball || 0) < 2) return; - const now = this.time || 0; - if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map(); - const balls = this.itemsOfType?.("ball") || (this.items || []).filter(it => it && !it.dead && it.type === "ball"); - for (const a of balls) { - if (!a || a.dead || a.type !== "ball") continue; - const ar = a.r || 18; - const avx = a.vx || 0; - const avy = a.vy || 0; - const aSpeed = Math.hypot(avx, avy); - const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; - for (const b of this.nearbyItems(a.x, a.y, range) || []) { - if (!b || b === a || b.dead || b.type !== "ball") continue; - const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; - if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue; - const br = b.r || 18; - const hitRadius = ar + br; - let dx = b.x - a.x; - let dy = b.y - a.y; - let d = Math.hypot(dx, dy); - let hitX = a.x; - let hitY = a.y; - if (d > hitRadius) { - const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; - const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; - const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; - const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; - const rvx = (a.x - ax0) - (b.x - bx0); - const rvy = (a.y - ay0) - (b.y - by0); - const rx = ax0 - bx0; - const ry = ay0 - by0; - const aa = rvx * rvx + rvy * rvy; - if (aa <= 0.0001) continue; - const bb = 2 * (rx * rvx + ry * rvy); - const cc = rx * rx + ry * ry - hitRadius * hitRadius; - const disc = bb * bb - 4 * aa * cc; - if (disc < 0) continue; - const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1); - const axHit = ax0 + (a.x - ax0) * u; - const ayHit = ay0 + (a.y - ay0) * u; - const bxHit = bx0 + (b.x - bx0) * u; - const byHit = by0 + (b.y - by0) * u; - dx = bxHit - axHit; - dy = byHit - ayHit; - d = Math.hypot(dx, dy); - hitX = (axHit + bxHit) * 0.5; - hitY = (ayHit + byHit) * 0.5; - if (d > hitRadius + 0.5) continue; - } else { - hitX = (a.x + b.x) * 0.5; - hitY = (a.y + b.y) * 0.5; - } - if (d < 0.001) { - const rvx = (b.vx || 0) - (a.vx || 0); - const rvy = (b.vy || 0) - (a.vy || 0); - const rs = Math.hypot(rvx, rvy); - if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } - else { const ang = deterministicAngle(this, "ball-ball-overlap-normal", a, b); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } - } - const nx = dx / d; - const ny = dy / d; - const rvx = (b.vx || 0) - (a.vx || 0); - const rvy = (b.vy || 0) - (a.vy || 0); - const relNormal = rvx * nx + rvy * ny; - if (relNormal > 0 && d >= hitRadius - 0.5) continue; - this.ballCollisionMemo.set(key, now); - const restitution = 0.86; - const impulse = -(1 + restitution) * relNormal / 2; - if (Number.isFinite(impulse)) { - a.vx = (a.vx || 0) - impulse * nx; - a.vy = (a.vy || 0) - impulse * ny; - b.vx = (b.vx || 0) + impulse * nx; - b.vy = (b.vy || 0) + impulse * ny; - } - const overlap = Math.max(0, hitRadius - d + 0.6); - if (overlap > 0) { - a.x -= nx * overlap * 0.5; - a.y -= ny * overlap * 0.5; - b.x += nx * overlap * 0.5; - b.y += ny * overlap * 0.5; - } - const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0)); - a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38); - b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38); - this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" })); - } - } + return global.TarinaiCollisionResponseSystem?.resolveBallBallCollisions?.(this, dt); }, resolveBallInteractions(dt) { - if (!this.itemCounts?.ball) return; - for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) { - if (!ball || ball.dead || ball.type !== "ball") continue; - const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84; - const range = (ball.r || 18) + 72 + sweep; - const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x; - const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y; - const sx = ball.x - px; - const sy = ball.y - py; - const segLenSq = sx * sx + sy * sy; - for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { - if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue; - let hitX = ball.x; - let hitY = ball.y; - if (segLenSq > 1) { - const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1); - hitX = px + sx * u; - hitY = py + sy * u; - } - let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y)); - const hitDistance = (ball.r || 18) + t.radius * 0.74; - if (d > hitDistance) continue; - const now = this.time || 0; - const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18; - if (repeat && ballSpeed < 300) continue; - let nx = (hitX - t.x) / d; - let ny = (hitY - t.y) / d; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - if (ballSpeed > 0.01) { - nx = (ball.vx || 0) / ballSpeed; - ny = (ball.vy || 0) / ballSpeed; - } else { - nx = t.facingDir ? t.facingDir() : 1; - ny = deterministicRange(this, "ball-interaction-fallback-ny", -0.22, 0.22, ball, t); - } - } - - const isDangerous = ballSpeed >= 285; - if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) { - const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx; - const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; - const damage = physicalDamageFromImpactSpeed(ballSpeed, { min: 3.5, max: 38, scale: 18 }); - t.lastBallDamageAt = now; - this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, { - panic: true, - target: { x: ball.x, y: ball.y }, - fearTimer: 0.95, - thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", - }); - this.applyImpactDamage(t, damage, "\u885d\u7a81", { source: ball }); - const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); - const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); - if (t.addStress) t.addStress(stressGain, { threshold: 8 }); - if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); - else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); - t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); - t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46); - t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); - t.fallDir = bvx >= 0 ? 1 : -1; - t.fearTimer = Math.max(t.fearTimer, 0.95); - t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44); - t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); - this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8)); - this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" })); - if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] }); - ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); - ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); - continue; - } - - const relVx = (t.vx || 0) - (ball.vx || 0); - const relVy = (t.vy || 0) - (ball.vy || 0); - const relativeSpeed = Math.hypot(relVx, relVy); - const playIntent = t.state === "play_ball" || t.target === ball; - const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); - const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260); - ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52; - ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52; - const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38); - ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding); - ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding); - ball.lastKickedAt = now; - ball.lastKickerId = t.id; - t.vx -= nx * Math.min(22, impulse * 0.10); - t.vy -= ny * Math.min(22, impulse * 0.10); - t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); - const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; - const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; - if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 }); - if (playful && playIntent) { - t.loneliness = clamp(t.loneliness - 0.9, 0, 100); - t.affection = clamp(t.affection + 0.18, 0, 100); - } - t.goodMode = playIntent || playful ? "smile" : t.goodMode; - if (playIntent && now > (t.nextPlayBubbleAt || 0)) { - t.nextPlayBubbleAt = now + deterministicRange(this, "ball-play-bubble-delay", 2.0, 3.8, ball, t); - this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); - } - if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); - this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); - } - } + return global.TarinaiCollisionResponseSystem?.resolveBallInteractions?.(this, dt); }, resolveTarinaiHighSpeedCollisions(dt) { - const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD; - const now = this.time || 0; - if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); - const live = Array.from(this.tarinai || []) - .filter(t => t && !t.dead && !this.isTarinaiHiddenInNestBox(t)) - .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))); - const candidates = []; - const maxCandidates = Math.max(24, Math.min(320, live.length * 6)); - for (let i = 0; i < live.length; i += 1) { - const a = live[i]; - const avx = a.vx || 0; - const avy = a.vy || 0; - const aSpeed = Math.hypot(avx, avy); - if (aSpeed < threshold) continue; - const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; - const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; - const ax1 = Number.isFinite(a.x) ? a.x : ax0; - const ay1 = Number.isFinite(a.y) ? a.y : ay0; - const asx = ax1 - ax0; - const asy = ay1 - ay0; - for (let j = 0; j < live.length; j += 1) { - const b = live[j]; - if (!b || b === a) continue; - const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; - if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; - const bvx = b.vx || 0; - const bvy = b.vy || 0; - const relVx = avx - bvx; - const relVy = avy - bvy; - const relSpeed = Math.hypot(relVx, relVy); - if (relSpeed < threshold) continue; - const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; - const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; - const bx1 = Number.isFinite(b.x) ? b.x : bx0; - const by1 = Number.isFinite(b.y) ? b.y : by0; - const bsx = bx1 - bx0; - const bsy = by1 - by0; - const rx0 = ax0 - bx0; - const ry0 = ay0 - by0; - const rvx = asx - bsx; - const rvy = asy - bsy; - const relMoveSq = rvx * rvx + rvy * rvy; - const u = relMoveSq > 0.001 ? clamp(-(rx0 * rvx + ry0 * rvy) / relMoveSq, 0, 1) : 1; - const cx = rx0 + rvx * u; - const cy = ry0 + rvy * u; - const d = Math.max(0.001, Math.hypot(cx, cy)); - const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; - if (d > hitDistance) continue; - const score = (1 - u) * 4 + Math.max(0, hitDistance - d) * 0.12 + Math.min(5, relSpeed / 120); - candidates.push({ a, b, key, relSpeed, u, cx, cy, d, hitDistance, score }); - if (candidates.length >= maxCandidates) break; - } - if (candidates.length >= maxCandidates) break; - } - candidates.sort((p, q) => (p.u - q.u) || (q.score - p.score) || String(p.key).localeCompare(String(q.key))); - const maxImpacts = Math.max(8, Math.min(64, Number(this.maxTarinaiCollisionImpacts || 32) || 32)); - let solved = 0; - for (const c of candidates) { - if (solved >= maxImpacts) break; - const { a, b, key, relSpeed, hitDistance } = c; - if (!a || !b || a.dead || b.dead) continue; - if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; - this.tarinaiCollisionMemo.set(key, now); - solved += 1; - const as = Math.hypot(a.vx || 0, a.vy || 0); - const bs = Math.hypot(b.vx || 0, b.vy || 0); - const impactor = as >= bs ? a : b; - const target = impactor === a ? b : a; - const ivx = impactor.vx || 0; - const ivy = impactor.vy || 0; - const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; - let nx = impactSpeed > 0.001 ? ivx / impactSpeed : 0; - let ny = impactSpeed > 0.001 ? ivy / impactSpeed : 0; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const dx = target.x - impactor.x; - const dy = target.y - impactor.y; - const dd = Math.hypot(dx, dy); - if (dd > 0.001) { nx = dx / dd; ny = dy / dd; } - else { - const angle = (typeof stableUnit === "function" ? stableUnit(key, "tarinai-collision-normal") : 0.5) * Math.PI * 2; - nx = Math.cos(angle); - ny = Math.sin(angle); - } - } - const overlap = Math.max(0, hitDistance - Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0))); - if (overlap > 0.5) { - const sep = Math.min(18, overlap * 0.45 + 0.8); - impactor.x = clamp((impactor.x || 0) - nx * sep * 0.45, CONFIG.worldPadding, this.w - CONFIG.worldPadding); - impactor.y = clamp((impactor.y || 0) - ny * sep * 0.45, CONFIG.worldPadding, this.h - CONFIG.worldPadding); - target.x = clamp((target.x || 0) + nx * sep * 0.55, CONFIG.worldPadding, this.w - CONFIG.worldPadding); - target.y = clamp((target.y || 0) + ny * sep * 0.55, CONFIG.worldPadding, this.h - CONFIG.worldPadding); - this.markSpatialDirty?.("tarinai-highspeed-separation"); - } - const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 }); - this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { - panic: true, - target: { x: impactor.x, y: impactor.y }, - fearTimer: 0.55, - thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", - }); - impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); - impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); - this.applyImpactDamage(target, damage, "\u885d\u7a81", { source: impactor }); - if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81", { source: target }); - for (const t of [target, impactor]) { - if (!t || t.dead) continue; - if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); - else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true }); - t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); - t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); - t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); - t.fallDir = nx >= 0 ? 1 : -1; - t.fearTimer = Math.max(t.fearTimer || 0, 0.55); - } - this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" })); - if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] }); - } - if (solved && this.spatialDirty) this.rebuildSpatial?.(true, "post-tarinai-highspeed-collision"); + return global.TarinaiCollisionResponseSystem?.resolveTarinaiHighSpeedCollisions?.(this, dt); } })); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_environment.js b/js/world_environment.js index 0800017..7b57f7c 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -34,7 +34,7 @@ let reach = Math.max(32, Number(it.r || it.radius || 32) || 32); if (type === "fence" || type === "fence_v" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") reach = Math.max(120, reach * 3.9); else if (type === "nest_box") reach = Math.max(92, reach * 1.9); - else reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, reach * 1.6); + else reach = global.TarinaiMechanicalShapeSystem?.reach?.(it) || Math.max(60, reach * 1.6); return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach); } @@ -189,6 +189,8 @@ } function rectLocalPoint(r, x, y) { + const shared = global.TarinaiCollisionFootprints?.rectLocalPoint; + if (typeof shared === "function") return shared(r, x, y); const dx = x - (r.cx ?? 0); const dy = y - (r.cy ?? 0); const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0); @@ -427,25 +429,25 @@ }, rotatorWorldSegments(it) { - return global.TarinaiMechanicalSystem?.worldSegments?.(it) || rotatorWorldSegmentsLocal(it); + return global.TarinaiMechanicalShapeSystem?.worldSegments?.(it) || rotatorWorldSegmentsLocal(it); }, rotatorExtent(it) { - return global.TarinaiMechanicalSystem?.extent?.(it) || rotatorExtentLocal(it); + return global.TarinaiMechanicalShapeSystem?.extent?.(it) || rotatorExtentLocal(it); }, rotatorObstacleRects(it) { - if (it?.type === "rotator") return global.TarinaiMechanicalSystem?.obstacleRects?.(it) || rotatorObstacleRectsLocal(it); + if (it?.type === "rotator") return global.TarinaiMechanicalShapeSystem?.obstacleRects?.(it) || rotatorObstacleRectsLocal(it); return []; }, reciprocatorObstacleRects(it) { - if (it?.type === "reciprocator") return global.TarinaiMechanicalSystem?.obstacleRects?.(it) || reciprocatorObstacleRectsLocal(it); + if (it?.type === "reciprocator") return global.TarinaiMechanicalShapeSystem?.obstacleRects?.(it) || reciprocatorObstacleRectsLocal(it); return []; }, rotatorBoundsAabb(it) { - const bounds = global.TarinaiMechanicalSystem?.boundsAabb?.(it); + const bounds = global.TarinaiMechanicalShapeSystem?.boundsAabb?.(it); if (bounds) return bounds; const rects = rotatorObstacleRectsLocal(it); if (!rects.length) return null; @@ -465,7 +467,7 @@ const rect = this.fenceRect(it); return rect ? [{ ...rect, type: it.type, item: it }] : []; } - if (global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type)) return global.TarinaiMechanicalSystem?.obstacleRects?.(it) || []; + if (global.TarinaiMechanicalShapeSystem?.isMechanicalType?.(it.type)) return global.TarinaiMechanicalShapeSystem?.obstacleRects?.(it) || []; if (it.type === "nest_box") return this.nestBoxSolidRects(it); return []; }, @@ -479,8 +481,8 @@ const r = box.r || 42; const entryD = distXY(t.x, t.y, entry.x, entry.y); const centerD = distXY(t.x, t.y, box.x, box.y); - const aroundDoor = entryD <= Math.max(96, r * 1.75) || centerD <= Math.max(86, r * 1.48); - const inDoorColumn = base && t.x >= base.left + r * 0.18 && t.x <= base.right - r * 0.18 && t.y >= base.top + r * 0.02 && t.y <= base.bottom + r * 0.58; + const aroundDoor = entryD <= Math.max(68, r * 1.32) || centerD <= Math.max(66, r * 1.18); + const inDoorColumn = base && t.x >= base.left + r * 0.30 && t.x <= base.right - r * 0.30 && t.y >= base.top + r * 0.12 && t.y <= base.bottom + r * 0.42; return Boolean(aroundDoor || inDoorColumn); }, @@ -489,6 +491,8 @@ const maxRects = Math.max(12, Math.min(72, Number(requestedMaxRects || 0) || Math.ceil(limit * 2.25))); const cacheable = !include && !exclude; const stats = this.nearbyQueryStatsThisFrame || null; + // Quantize cacheable probes so smooth movement reuses obstacle rect lists + // across nearby frames while the inflated radius keeps collisions conservative. const q = 48; const qx = Math.floor((Number(x) || 0) / q); const qy = Math.floor((Number(y) || 0) / q); @@ -582,7 +586,7 @@ let checked = 0; const addRectsFor = (it) => { if (!it || it.dead || it.type !== "poison_block" || it._poisonHazardQueryStamp === stamp) return false; - const reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, it.r || 64); + const reach = global.TarinaiMechanicalShapeSystem?.reach?.(it) || Math.max(60, it.r || 64); if (distXY(x, y, it.x, it.y) > radius + reach + 36) return false; const partRects = global.TarinaiMechanicalSystem?.poisonHazardRects?.(it) || []; if (!partRects.length) return false; @@ -647,10 +651,7 @@ t.lastPoisonBlockBubbleAt = now; t.bubble("!", 0.48, "rgba(94,166,76,0.78)"); } - if (now >= (t.lastPoisonBlockEffectAt || -999) + 0.10) { - t.lastPoisonBlockEffectAt = now; - this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(16, rr * 1.05), life: 0.18, color: "rgba(92,188,68,0.45)" })); - } + // 物理ブロック衝突の視覚エフェクトは出さない。接触時の状態変化はHPバーと吹き出しだけに限定する。 return true; }, @@ -668,6 +669,61 @@ return hit; }, + + resolvePoisonBlockItemContacts(opts = {}) { + if ((this.itemCounts?.poison_block || 0) <= 0) return 0; + const meltTypes = new Set(["grass_bed", "zunchi", "water", "grass"]); + let possible = 0; + for (const type of meltTypes) possible += this.itemCounts?.[type] || 0; + if (possible <= 0) return 0; + const maxPoison = Math.max(1, Math.min(48, Number(opts.maxPoison || 24) || 24)); + const maxTargets = Math.max(1, Math.min(160, Number(opts.maxTargets || 80) || 80)); + const poisonBlocks = this.itemsOfType?.("poison_block") || []; + let removed = 0; + let checkedPoison = 0; + const lifecycle = global.TarinaiStructureLifecycle; + for (const poison of poisonBlocks) { + if (!poison || poison.dead || poison.type !== "poison_block") continue; + checkedPoison += 1; + if (checkedPoison > maxPoison) break; + const rects = global.TarinaiMechanicalSystem?.poisonHazardRects?.(poison) || []; + if (!rects.length) continue; + const reach = global.TarinaiMechanicalSystem?.reach?.(poison) || Math.max(72, poison.r || 64); + const source = this.nearbyItems?.(poison.x, poison.y, reach + 96, true) || this.items || []; + let checkedTargets = 0; + for (const target of source) { + if (!target || target.dead || target === poison || !meltTypes.has(target.type)) continue; + checkedTargets += 1; + if (checkedTargets > maxTargets) break; + const rr = Math.max(5, Number(target.r || target.radius || itemRadiusFor?.(target.type, 10) || 10) || 10) * (target.type === "grass_bed" ? 1.15 : 0.92); + let touching = false; + for (const rect of rects) { + if (this.circleOverlapsObstacleRect(target.x, target.y, rr, rect, 1.5)) { touching = true; break; } + } + if (!touching) continue; + const wasLive = !target.dead; + const destroyed = (globalThis.TarinaiItemDestruction || lifecycle)?.deleteItem?.(this, target, { + reason: "poison-block-contact", + userReason: "毒ブロックに触れて消えた", + wake: true, + breaker: poison, + panicOwner: false, + }); + if (wasLive && destroyed) { + removed += 1; + this.markTerrainDirtyAt?.(target.x, target.y, Math.max(36, rr + 16), "poison-block-contact"); + } + } + } + if (removed > 0) { + this.compactItems?.(); + this.updateItemCounts?.("poison-block-contact"); + this.markSpatialDirty?.("poison-block-contact"); + this.drawListDirty = true; + } + return removed; + }, + pushTarinaiOutOfRect(t, r, rr, opts = {}) { if (!t || !r) return false; const preVx = Number(t.vx || 0) || 0; @@ -726,6 +782,8 @@ : Math.min(rr - d + slop, maxPush); t.x += nx * push; t.y += ny * push; + t.lastSolidObstacleCollisionSource = r.item || null; + t.lastSolidObstacleCollisionAt = this.time || 0; if (r.bounce) { const restitution = Number.isFinite(r.restitution) ? r.restitution : 1.08; const vx = t.vx || 0; @@ -773,11 +831,6 @@ spinKick: 0, }); t.surpriseTimer = Math.max(t.surpriseTimer || 0, r.rotator ? 0.12 : 0.10); - const field = r.rotator ? "lastRotatorHitEffectAt" : (r.poisonBlock ? "lastPoisonBlockPushEffectAt" : "lastReciprocatorHitEffectAt"); - if ((this.time || 0) >= (t[field] || -999) + 0.10) { - t[field] = this.time || 0; - this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: r.rotator ? "rgba(175,116,230,0.42)" : (r.poisonBlock ? "rgba(92,188,68,0.42)" : "rgba(90,150,214,0.40)") })); - } } else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; else t.vy *= -0.18; return true; @@ -911,9 +964,11 @@ return false; }, - grassBlockedAt(x, y, self = null) { + grassBlockedAt(x, y, self = null, opts = {}) { + const ignoreSoft = Boolean(opts?.ignoreSoftPlacement); for (const it of this.nearbyItems(x, y, 96)) { if (it === self || it.dead) continue; + if (ignoreSoft && global.TarinaiPhysicsSoftClear?.isSoftPlacementType?.(it.type)) continue; for (const r of this.solidObstacleRects(it)) { if (this.pointInRect(x, y, r, 10)) return true; } diff --git a/js/world_family_social.js b/js/world_family_social.js index 4537282..a168a3a 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -1,33 +1,4 @@ "use strict"; -(function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; - const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); - const keyPart = value => { - if (value == null) return "null"; - if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan"; - if (typeof value === "string" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const id = value.familyKey || value.id || value.seed || value.liveToken || value.name; - if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`; - const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x"; - const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y"; - return `${value.type || value.constructor?.name || "entity"}@${x},${y}`; - } - return String(value); - }; - const hashUnit = (seed, salt = "") => { - const str = `${seed}:${salt}`; - let h = 2166136261; - for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } - return ((h >>> 0) % 100000) / 100000; - }; - global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6)); - global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"))); - global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); - global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); - global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); }); - global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); -})(typeof window !== "undefined" ? window : globalThis); (function (global) { @@ -492,9 +463,7 @@ }, nestBoxOccupants(box, limit = 5) { - if (!box || box.dead || box.type !== "nest_box") return []; - const occupants = (this.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id); - return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; + return globalThis.TarinaiNestSleepSystem?.nestBoxOccupants?.(this, box, limit) || []; }, pointerNestBoxInfo() { diff --git a/js/world_placement_log.js b/js/world_placement_log.js index dec706e..7c76827 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -167,14 +167,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

@@ -184,6 +184,7 @@ `; document.body.appendChild(dialog); + global.TarinaiPhysicsShapeEditorSystem?.decorateToolbar?.(dialog, "rotator"); return dialog; } @@ -203,6 +204,7 @@ if (!item || (item.type !== "rotator" && item.type !== "poison_block")) return false; const isPoisonBlock = item.type === "poison_block"; const dialog = ensureRotatorEditor(); + global.TarinaiPhysicsShapeEditorSystem?.decorateToolbar?.(dialog, "rotator"); const canvas = dialog.querySelector("#rotatorCanvas"); const ctx = canvas?.getContext?.("2d"); const speed = dialog.querySelector("#mechMotorSpeed"); @@ -302,7 +304,7 @@ for (const btn of dialog.querySelectorAll("[data-rotator-mode]")) btn.classList.toggle("active", btn.dataset.rotatorMode === mode); canvas.style.cursor = mode === "erase" ? "cell" : "crosshair"; }; - const pointSegmentDistance = (p, seg) => { + const pointSegmentDistance = (p, seg) => global.TarinaiPhysicsShapeEditorSystem?.pointSegmentDistance?.(p, seg) ?? (() => { if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity; const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0; const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0; @@ -311,7 +313,7 @@ if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1); const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq)); return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u)); - }; + })(); const eraseAt = (p) => { const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); const radius = Math.max(24, lw * 1.35); @@ -330,17 +332,8 @@ return false; }; const setTemplate = (name) => { - if (name === "bar") segments = [[-130, 0, 130, 0]]; - else if (name === "cross") segments = [[-118, 0, 118, 0], [0, -90, 0, 90]]; - else if (name === "circle") { - segments = []; - const n = 18, r = 108; - for (let i = 0; i < n; i += 1) { - const a = i / n * Math.PI * 2; - const b = (i + 1) / n * Math.PI * 2; - segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]); - } - } + const shared = global.TarinaiPhysicsShapeEditorSystem?.templateSegments?.(name); + if (shared) segments = shared.map(seg => seg.slice(0, 4)); cleanSegments(); draw(); }; const syncPair = (a, b) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; draw(); }; b.oninput = () => { a.value = b.value; draw(); }; }; @@ -455,14 +448,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

@@ -472,12 +465,14 @@ `; document.body.appendChild(dialog); + global.TarinaiPhysicsShapeEditorSystem?.decorateToolbar?.(dialog, "reciprocator"); return dialog; } function openReciprocatorEditor(item, worldRef) { if (!item || item.type !== "reciprocator") return false; const dialog = ensureReciprocatorEditor(); + global.TarinaiPhysicsShapeEditorSystem?.decorateToolbar?.(dialog, "reciprocator"); const canvas = dialog.querySelector("#reciprocatorCanvas"); const ctx = canvas?.getContext?.("2d"); const angle = dialog.querySelector("#reciprocatorAngle"); @@ -588,7 +583,7 @@ for (const btn of dialog.querySelectorAll("[data-reciprocator-mode]")) btn.classList.toggle("active", btn.dataset.reciprocatorMode === mode); canvas.style.cursor = mode === "erase" ? "cell" : "crosshair"; }; - const pointSegmentDistance = (p, seg) => { + const pointSegmentDistance = (p, seg) => global.TarinaiPhysicsShapeEditorSystem?.pointSegmentDistance?.(p, seg) ?? (() => { if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity; const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0; const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0; @@ -597,7 +592,7 @@ if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1); const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq)); return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u)); - }; + })(); const eraseAt = (p) => { const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); const radius = Math.max(28, lw * 1.55); @@ -616,17 +611,8 @@ return false; }; const setTemplate = (name) => { - if (name === "bar") segments = [[-130, 0, 130, 0]]; - else if (name === "cross") segments = [[-118, 0, 118, 0], [0, -90, 0, 90]]; - else if (name === "circle") { - segments = []; - const n = 18, r = 108; - for (let i = 0; i < n; i += 1) { - const a = i / n * Math.PI * 2; - const b = (i + 1) / n * Math.PI * 2; - segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]); - } - } + const shared = global.TarinaiPhysicsShapeEditorSystem?.templateSegments?.(name); + if (shared) segments = shared.map(seg => seg.slice(0, 4)); cleanSegments(); draw(); }; const syncPair = (a, b, redraw = false) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; if (redraw) draw(); }; b.oninput = () => { a.value = b.value; if (redraw) draw(); }; }; @@ -723,7 +709,7 @@ function copyTargetAt(worldRef, x, y) { let blockedReason = ""; - const collision = global.TarinaiCollisionFootprints; + const collision = global.TarinaiPlacementCollisionSystem || global.TarinaiCollisionFootprints; const items = worldRef?.items || []; for (let i = items.length - 1; i >= 0; i--) { const it = items[i]; @@ -911,7 +897,7 @@ let d = distXY(x, y, it.x, it.y); let hit = d <= Math.max(30, (it.r || 24) * 1.35); if (global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type) || worldRef?.isFenceType?.(it.type)) { - const result = global.TarinaiCollisionFootprints?.hitTestItem?.(worldRef, it, x, y, { padding: 14, radiusMultiplier: 1.25 }) || { hit: false, distance: d }; + const result = (global.TarinaiPlacementCollisionSystem || global.TarinaiCollisionFootprints)?.hitTestItem?.(worldRef, it, x, y, { padding: 14, radiusMultiplier: 1.25 }) || { hit: false, distance: d }; hit = Boolean(result.hit); d = Math.min(d, result.distance ?? d); } @@ -989,9 +975,13 @@ ); } - function isSoftPhysicsIgnoredItemType(type = "") { + function isSoftPlacementIgnoredItemType(type = "") { const key = String(type || ""); - return key === "water" || key === "grass_bed"; + return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water"; + } + + function isSoftPhysicsIgnoredItemType(type = "") { + return isSoftPlacementIgnoredItemType(type); } function softItemRadius(item) { @@ -999,42 +989,57 @@ return Math.max(8, Number(item.r || item.radius || itemRadiusFor?.(item.type, 12) || 12) || 12); } - function physicsItemOverlapsSoftItem(worldRef, physicsItem, softItem) { - if (!worldRef || !physicsItem || !softItem || softItem.dead) return false; - const r = softItemRadius(softItem); - if (physicsItem.type === "rope" || physicsItem.type === "rod") { - const runtime = global.TarinaiLinkRuntime; - const pb = physicsApi(); - const a = runtime?.endpointWorld?.(pb?.endpoint?.(physicsItem, 0), worldRef); - const b = runtime?.endpointWorld?.(pb?.endpoint?.(physicsItem, 1), worldRef); - if (a && b) { - const d = runtime?.pointSegmentDistance?.(softItem.x, softItem.y, a.x, a.y, b.x, b.y); - return Number.isFinite(d) && d <= r + (physicsItem.type === "rod" ? 9 : 7); - } - } - const rects = worldRef.solidObstacleRects?.(physicsItem) || []; - if (rects.length) { - return rects.some(rect => global.TarinaiCollisionFootprints?.rectCircleOverlap?.(rect, softItem.x, softItem.y, r, 0.5)); - } - const pr = Math.max(10, Number(physicsItem.r || physicsItem.radius || itemRadiusFor?.(physicsItem.type, 12) || 12) || 12); - return distXY(physicsItem.x, physicsItem.y, softItem.x, softItem.y) <= pr + r; + function placementRectsForItem(worldRef, item) { + const solid = worldRef?.solidObstacleRects?.(item) || []; + if (solid.length) return solid; + if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item?.type)) return global.TarinaiMechanicalSystem?.placementRects?.(item) || []; + return solid; } - function clearSoftItemsUnderPhysicsItem(worldRef, item, reason = "physics-overlap-clear") { - if (!worldRef || !item || !isPhysicsObstacleItemType(item.type)) return 0; - let search = Math.max(80, Number(item.r || item.radius || 36) + 90); - if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) { - search = Math.max(search, (global.TarinaiMechanicalSystem?.reach?.(item) || item.r || 80) + 96); - } else if (item.type === "rope" || item.type === "rod") { - search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96); + function placementItemOverlapsSoftItem(worldRef, placedItem, softItem) { + if (!worldRef || !placedItem || !softItem || softItem.dead) return false; + const r = softItemRadius(softItem); + if (placedItem.type === "rope" || placedItem.type === "rod") { + const runtime = global.TarinaiLinkRuntime; + const pb = physicsApi(); + const a = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 0), worldRef); + const b = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 1), worldRef); + if (a && b) { + const d = runtime?.pointSegmentDistance?.(softItem.x, softItem.y, a.x, a.y, b.x, b.y); + return Number.isFinite(d) && d <= r + (placedItem.type === "rod" ? 9 : 7); + } } - let removed = 0; + const rects = placementRectsForItem(worldRef, placedItem); + if (rects.length) return rects.some(rect => (global.TarinaiPlacementCollisionSystem || global.TarinaiCollisionFootprints)?.rectCircleOverlap?.(rect, softItem.x, softItem.y, r, 0.5)); + const pr = Math.max(10, Number(placedItem.r || placedItem.radius || itemRadiusFor?.(placedItem.type, 12) || 12) || 12); + return distXY(placedItem.x, placedItem.y, softItem.x, softItem.y) <= pr + r; + } + + function placementSoftSearchRadius(item) { + let search = Math.max(80, Number(item?.r || item?.radius || 36) + 90); + if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item?.type)) search = Math.max(search, (global.TarinaiMechanicalSystem?.reach?.(item) || item.r || 80) + 96); + else if (item?.type === "rope" || item?.type === "rod") search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96); + return search; + } + + function collectSoftItemsUnderPlacementItem(worldRef, item) { + if (!worldRef || !item || item.type === "trace" || item.type === "splat") return []; + const out = []; + const search = placementSoftSearchRadius(item); for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) { - if (!soft || soft === item || soft.dead || !isSoftPhysicsIgnoredItemType(soft.type)) continue; - if (!physicsItemOverlapsSoftItem(worldRef, item, soft)) continue; - const ok = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, soft, { + if (!soft || soft === item || soft.dead || !isSoftPlacementIgnoredItemType(soft.type)) continue; + if (placementItemOverlapsSoftItem(worldRef, item, soft)) out.push(soft); + } + return out; + } + + function clearSoftItemsUnderPlacementItem(worldRef, item, reason = "placement-soft-overlap-clear") { + const items = collectSoftItemsUnderPlacementItem(worldRef, item); + let removed = 0; + for (const soft of items) { + const ok = (global.TarinaiItemDestruction || global.TarinaiStructureLifecycle)?.deleteItem?.(worldRef, soft, { reason, - userReason: "\u7269\u7406\u30a2\u30a4\u30c6\u30e0\u306b\u91cd\u306a\u3063\u305f", + userReason: "置きものが重なった", wake: true, }); if (ok) removed += 1; @@ -1047,34 +1052,35 @@ return removed; } + function physicsItemOverlapsSoftItem(worldRef, physicsItem, softItem) { + return placementItemOverlapsSoftItem(worldRef, physicsItem, softItem); + } + + function clearSoftItemsUnderPhysicsItem(worldRef, item, reason = "physics-overlap-clear") { + if (!worldRef || !item || !isPhysicsObstacleItemType(item.type)) return 0; + return clearSoftItemsUnderPlacementItem(worldRef, item, reason); + } + global.TarinaiPhysicsSoftClear = Object.freeze({ isPhysicsType: isPhysicsObstacleItemType, isSoftType: isSoftPhysicsIgnoredItemType, + isSoftPlacementType: isSoftPlacementIgnoredItemType, overlaps: physicsItemOverlapsSoftItem, + overlapsPlacement: placementItemOverlapsSoftItem, + collectForPlacement: collectSoftItemsUnderPlacementItem, + clearForPlacement: clearSoftItemsUnderPlacementItem, clearForItem: clearSoftItemsUnderPhysicsItem, }); function clearOverlappingGrassBedsForPlacement(worldRef, item) { - if (!worldRef || !item || item.type === "grass_bed" || item.type === "trace" || item.type === "splat") return 0; - if (isPhysicsObstacleItemType(item.type)) return clearSoftItemsUnderPhysicsItem(worldRef, item, "physics-placement-soft-clear"); - const radius = Math.max(16, item.r || item.radius || 12); - let removed = 0; - for (const bed of worldRef.nearbyItems?.(item.x, item.y, radius + 72) || worldRef.items || []) { - if (!bed || bed.dead || bed.type !== "grass_bed") continue; - const hit = radius * 0.95 + Math.max(14, (bed.r || 18) * 0.95); - if (distXY(item.x, item.y, bed.x, bed.y) > hit) continue; - const ok = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, bed, { - reason: "grass-bed-ignored-by-placement", - userReason: "\u7f6e\u304d\u3082\u306e\u304c\u7f6e\u304b\u308c\u305f", - wake: true, - }); - if (ok) removed += 1; - } - return removed; + return clearSoftItemsUnderPlacementItem(worldRef, item, "placement-soft-overlap-clear"); } function forceImmediateToolVisualRefresh(worldRef, reason = "tool-change") { if (!worldRef) return; + // Render and spatial rebuild are already performed by the main frame loop. + // Tool operations only invalidate cached views; batch placement must not + // force synchronous render/spatial work per item. worldRef.drawListDirty = true; if (worldRef._renderStack) worldRef._renderStack.signature = ""; if (worldRef._visibleRenderStack) { @@ -1085,11 +1091,6 @@ } worldRef.markItemBucketsDirty?.(reason); worldRef.markSpatialDirty?.(reason); - worldRef.ensureSpatial?.(reason); - if (typeof global.render === "function") { - global.render(); - global.requestAnimationFrame?.(() => global.render?.()); - } } Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ @@ -1156,30 +1157,34 @@ const otherIsGate = it.type === "gate_fence"; const margin = (item.type === "gate_fence" || otherIsGate) ? 1.5 : baseMargin; for (const r of this.solidObstacleRects(it)) { - if (global.TarinaiCollisionFootprints?.rectsOverlap?.(rect, r, margin)) return true; + if ((global.TarinaiPlacementCollisionSystem || global.TarinaiCollisionFootprints)?.rectsOverlap?.(rect, r, margin)) return true; } } return false; }, importantPlacementOverlapBlocked(item) { - return global.TarinaiCollisionFootprints?.itemPlacementOverlapBlocked?.(this, item) ?? false; + return (global.TarinaiPlacementCollisionSystem || global.TarinaiCollisionFootprints)?.itemPlacementOverlapBlocked?.(this, item) ?? false; }, placementBlocked(item) { if (!item || item.dead) return true; if (item.type === "genkotsu") return false; - const pad = CONFIG.worldPadding || 30; const rects = this.solidObstacleRects(item); - if (rects.length) { - for (const rect of rects) { - if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true; + const sharedBoundsBlocked = global.TarinaiPlacementPreviewSystem?.boundsBlocked?.(this, item, { rects }); + if (sharedBoundsBlocked === true) return true; + if (sharedBoundsBlocked !== false) { + const pad = CONFIG.worldPadding || 30; + if (rects.length) { + for (const rect of rects) { + if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true; + } + } else { + const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); + if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true; } - } else { - const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); - if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true; } - if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true; + if (item.type === "grass" && (this.grassBlockedAt?.(item.x, item.y, item, { ignoreSoftPlacement: true }) || this.grassOnTarinaiAt?.(item.x, item.y))) return true; if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true; if (this.importantPlacementOverlapBlocked(item)) return true; if (item.type === "nest_box") { @@ -1194,18 +1199,12 @@ } } } - if (item.type === "grass_bed") { - const spacing = Math.max(46, (item.r || 18) * 3.0); - for (const it of this.nearbyItems(item.x, item.y, spacing + 70) || []) { - if (!it || it === item || it.dead || it.type !== "grass_bed") continue; - const required = spacing + Math.max(0, ((it.r || 18) - (item.r || 18)) * 0.5); - if (distXY(item.x, item.y, it.x, it.y) < required) return true; - } - } return false; }, placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) { + const shared = global.TarinaiPlacementPreviewSystem?.clampPointFor?.(this, item, x, y); + if (shared) return shared; const pad = CONFIG.worldPadding || 30; const rects = this.solidObstacleRects(item); if (rects.length) { @@ -1287,12 +1286,16 @@ hit = Boolean(result.hit); d = Math.min(d, result.distance ?? d); } else { - const result = global.TarinaiCollisionFootprints?.hitTestItem?.(this, it, x, y, { radiusMultiplier: it.type === "ball" ? 4.0 : 2.2 }) || { hit: false, distance: d }; + const result = (global.TarinaiPlacementCollisionSystem || global.TarinaiCollisionFootprints)?.hitTestItem?.(this, it, x, y, { radiusMultiplier: it.type === "ball" ? 4.0 : 2.2 }) || { hit: false, distance: d }; hit = Boolean(result.hit); d = Math.min(d, result.distance ?? d); } if (hit && d < foundItemD) { foundItem = it; foundItemD = d; } } + if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "ball_lodged") { + const carrier = (this.items || []).find(it => it && !it.dead && it.type === "ball" && it.id === foundItem.pinBallId); + if (carrier) return carrier; + } if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem; if (foundT) { const lodged = foundT.currentLodgedPin?.() || (this.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === foundT.id); @@ -1324,13 +1327,13 @@ item.dropTimer = 0; item.dropImpactDone = true; } - clearOverlappingGrassBedsForPlacement(this, item); + const removedSoft = clearOverlappingGrassBedsForPlacement(this, item); + if (removedSoft) this.updateItemCounts?.("placement-soft-overlap-clear"); const placed = this.addItem?.(item, `place:${item.type}`); if (!placed) { if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`); return null; } - clearSoftItemsUnderPhysicsItem(this, item, `place:${item.type}:soft-overlap-clear`); this.emit("tool:place", { item, type: item.type, dropped }); this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type }); this.drawListDirty = true; @@ -1360,6 +1363,9 @@ return; } } + const wasSelectionEmpty = !this.selected; + if (found && wasSelectionEmpty) this._scrollSelectedDataTopOnNextSelection = true; + else this._scrollSelectedDataTopOnNextSelection = false; global.TarinaiCommands?.setSelection?.(this, found) || (this.selected = found); return; } @@ -1382,7 +1388,7 @@ this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color })); this.drawListDirty = true; audio.poke?.(); - this.log(`${label}\u3092\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f\u3002`, "observe", { eventType: "mechanism_poke", hiddenFromObservation: true }); + this.log(`${label}\u3092\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f\u3002`, "observe", { eventType: "mechanism_poke" }); return; } } @@ -1432,8 +1438,8 @@ const placed = this.placeItem(rawItem, true); if (!placed) return; forceImmediateToolVisualRefresh(this, "tool-place"); - const label = toolLabel(itemType); - this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`, "observe", { eventType: "player_item_placement" }); + // Item placement intentionally no longer writes observation-log entries. + // Placement audio is still emitted through tool:placed. } }, @@ -1454,7 +1460,7 @@ shouldHideFromObservationRecords(entry = {}) { const type = typeof entry === "string" ? "" : (entry.eventType || ""); - return type === "ball_poke" || type === "avoid_relationship"; + return type === "ball_poke" || type === "avoid_relationship" || type === "mechanism_poke"; }, log(text, kind = null, options = {}) { diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js index eeb2689..4472753 100644 --- a/js/world_tool_actions.js +++ b/js/world_tool_actions.js @@ -6,6 +6,9 @@ function forceImmediateToolVisualRefresh(worldRef, reason = "tool-action") { if (!worldRef) return; + // Render and spatial rebuild are already performed by the main frame loop. + // Tool operations only invalidate cached views; batch placement must not + // force synchronous render/spatial work per item. worldRef.drawListDirty = true; if (worldRef._renderStack) worldRef._renderStack.signature = ""; if (worldRef._visibleRenderStack) { @@ -16,11 +19,6 @@ } worldRef.markItemBucketsDirty?.(reason); worldRef.markSpatialDirty?.(reason); - worldRef.ensureSpatial?.(reason); - if (typeof global.render === "function") { - global.render(); - global.requestAnimationFrame?.(() => global.render?.()); - } } Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ @@ -149,7 +147,7 @@ for (let i = itemCandidates.length - 1; i >= 0; i -= 1) { const it = itemCandidates[i]; if (!it || it.dead) continue; - if ((this.tool === "rope" || this.tool === "rod") && (it.type === "water" || it.type === "grass_bed")) continue; + if ((this.tool === "rope" || this.tool === "rod") && global.TarinaiPhysicsSoftClear?.isSoftPlacementType?.(it.type)) continue; const isMechanism = Boolean(global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type)); const isFence = this.isFenceType?.(it.type) || false; const isConstraint = it.type === "rope" || it.type === "rod"; @@ -400,20 +398,45 @@ } else if (hit?.kind === "item") { handled = true; const target = hit.target; - const shouldRemove = Math.random() < 0.30; - if (shouldRemove) { - const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, target, { - reason: "shoot-tool", - userReason: "\u5c04\u6483\u3067\u6d88\u3048\u305f", - wake: true, - }); - if (removed) { - this.markItemBucketsDirty?.("shoot-delete"); - this.markSpatialDirty?.("shoot-delete"); - this.drawListDirty = true; + if (target && typeof isPinType === "function" && isPinType(target.type)) { + const blasted = this.blastLoosePinsFrom?.(aim.x, aim.y, 92, target, 1.25, "shoot-tool") || 0; + if (!blasted && target.pinState !== "lodged") { + let dx = (target.x || 0) - aim.x; + let dy = (target.y || 0) - aim.y; + let d = Math.hypot(dx, dy) || 1; + if (d < 0.001) { + const a = deterministicAngle(this, "shoot-pin-overlap", target, aim.x, aim.y); + dx = Math.cos(a); dy = Math.sin(a); d = 1; + } + const pinSystem = globalThis.TarinaiItemDynamicPinSystem; + if (target.pinState === "ball_lodged") pinSystem?.detach?.(target, this, "shoot"); + this.applyImpulse?.(target, + dx / d * 610 + deterministicRange(this, "shoot-pin-jitter-x", -70, 70, target, aim.x, aim.y), + dy / d * 610 + deterministicRange(this, "shoot-pin-jitter-y", -70, 70, target, aim.x, aim.y) + ); + target.spinVelocity = clamp((target.spinVelocity || 0) + deterministicRange(this, "shoot-pin-spin", -20, 20, target, aim.x, aim.y), -40, 40); + target.noStickUntil = (this.time || 0) + 0.22; + target.lastPokedAt = this.time || 0; + } + this.effects?.push(new Effect("ring", target.x || aim.x, target.y || aim.y, { size: Math.max(12, (target.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.48)" })); + this.markSpatialDirty?.("shoot-pin-blast"); + this.drawListDirty = true; + } else { + const shouldRemove = Math.random() < 0.30; + if (shouldRemove) { + const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, target, { + reason: "shoot-tool", + userReason: "\u5c04\u6483\u3067\u6d88\u3048\u305f", + wake: true, + }); + if (removed) { + this.markItemBucketsDirty?.("shoot-delete"); + this.markSpatialDirty?.("shoot-delete"); + this.drawListDirty = true; + } else { + } } else { } - } else { } } else { } diff --git a/js/world_update.js b/js/world_update.js index b89a283..dcca199 100644 --- a/js/world_update.js +++ b/js/world_update.js @@ -72,76 +72,10 @@ }, evaluateColonyMood(force = false) { - const now = this.time || 0; - if (!force && this.colonyMood && now < (this.nextColonyMoodEvalAt || 0)) return this.colonyMood; - const alive = (this.tarinai || []).filter(t => t && !t.dead); - const n = alive.length; - const avg = (fn) => n ? alive.reduce((sum, t) => sum + (Number(fn(t)) || 0), 0) / n : 0; - const count = (fn) => alive.reduce((sum, t) => sum + (fn(t) ? 1 : 0), 0); - const ratio = (value) => n ? value / n : 0; - const avgEnergy = avg(t => t.energy || 0); - const avgStress = avg(t => t.stress || 0); - const avgHunger = avg(t => t.hunger || 0); - const avgSafety = avg(t => t.needs?.safety || 0); - const panicCount = count(t => t.state === "panic" || (t.fearTimer || 0) > 0.2 || (t.panicTargetUntil || 0) > now); - const safetyHighCount = count(t => (t.needs?.safety || 0) >= 52 || (t.fearTimer || 0) > 0.35); - const weakCount = count(t => (t.energy || 0) < 45 || (t.hunger || 0) > 88 || (t.needs?.health || 0) > 58); - const diseasedCount = count(t => t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease); - const breedingCount = count(t => (t.loveMochiTimer || 0) > 0.1 || t.state === "birth_ritual" || (t.birthRitualTimer || 0) > 0.1); - const recentDeaths = (Array.isArray(this.recentDeathTimes) ? this.recentDeathTimes : []).filter(t => now - (Number(t) || -999) <= 30).length; - let nearestAvg = Infinity; - if (n >= 2) { - let totalNearest = 0; - for (const a of alive) { - let best = Infinity; - for (const b of alive) { - if (a === b) continue; - const dx = (a.x || 0) - (b.x || 0); - const dy = (a.y || 0) - (b.y || 0); - const d2 = dx * dx + dy * dy; - if (d2 < best) best = d2; - } - if (Number.isFinite(best)) totalNearest += Math.sqrt(best); - } - nearestAvg = totalNearest / n; - } - const recentShock = (now - (this.lastShootAt || -999) <= 8) || (now - (this.lastGenkotsuAt || -999) <= 8); - const panicRatio = ratio(panicCount); - const safetyRatio = ratio(safetyHighCount); - const weakRatio = ratio(weakCount); - const diseaseRatio = ratio(diseasedCount); - const breedingRatio = ratio(breedingCount); - const fieldId = String(this.fieldType || "garden"); - const overcrowdLimit = fieldId === "cage" ? 20 : (fieldId === "park" ? 100 : 50); - const denseCrowd = Number.isFinite(nearestAvg) && nearestAvg < 84 && n >= Math.max(8, Math.floor(overcrowdLimit * 0.55)); - const candidates = []; - const add = (id, priority, ok) => { if (ok) candidates.push({ id, priority }); }; - - // Severe or multi-signal situations are evaluated first. Simple population - // and comfort labels are deliberately fallback states so they do not mask - // richer current events. - add("devastation", 120, n <= 0 || recentDeaths >= 3 || (n <= 2 && this.deadCount > 0 && (recentDeaths > 0 || weakRatio >= 0.35 || panicRatio >= 0.20))); - add("crisis", 110, recentDeaths >= 2 || (avgEnergy < 38 && weakRatio >= 0.34) || (avgHunger > 88 && weakRatio >= 0.40)); - add("confusion", 100, (recentShock && (panicRatio >= 0.10 || safetyRatio >= 0.18)) || panicRatio >= 0.25); - add("fearful", 90, (avgSafety >= 50 && safetyRatio >= 0.28) || (recentShock && safetyRatio >= 0.18)); - add("disease_spread", 82, diseaseRatio >= 0.25 || (diseasedCount >= 3 && diseaseRatio >= 0.16)); - add("weakened", 74, (avgEnergy < 54 && weakRatio >= 0.18) || avgHunger > 82 || weakRatio >= 0.34); - add("breeding", 66, breedingRatio >= 0.10 || breedingCount >= 2 || now - (this.lastBirthAt || -999) <= 45); - add("happy", 46, avgStress < 28 && panicRatio < 0.08 && diseaseRatio < 0.12 && weakRatio < 0.18); - add("overcrowded", 34, n >= overcrowdLimit || denseCrowd); - add("isolated", 28, n <= 1 || (n <= 3 && nearestAvg > Math.max(180, Math.min(this.w || 900, this.h || 600) * 0.32))); - - let id = "relaxed"; - if (candidates.length) { - candidates.sort((a, b) => b.priority - a.priority); - id = candidates[0].id; - } - const next = this.colonyMoodDefinition(id); - this.colonyMood = next; - this.nextColonyMoodEvalAt = now + 1.25; - return next; + return global.TarinaiColonySituationSystem?.evaluate?.(this, force) || this.colonyMood || this.colonyMoodDefinition?.("relaxed"); }, + observeHighlightKind(t) { const s = this.selected; if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; diff --git a/scripts/ai_inventory.ps1 b/scripts/ai_inventory.ps1 index beca69f..5d7e1db 100644 --- a/scripts/ai_inventory.ps1 +++ b/scripts/ai_inventory.ps1 @@ -16,6 +16,7 @@ $Exact = @{ "index.html" = "app DOM shell, canvas, panels, dialogs, and ordered script tags" "README.md" = "small AI entrypoint and read strategy" "AI_MAP.md" = "subsystem map and task-oriented read sets" + "GAME_FEATURES.md" = "player-facing game features, UI, items, and behavior guide" "FILES.json" = "machine-readable file routing and glob semantics" "service-worker.js" = "generated offline/cache service worker" } diff --git a/scripts/ai_inventory.py b/scripts/ai_inventory.py index 63b4701..98f5882 100644 --- a/scripts/ai_inventory.py +++ b/scripts/ai_inventory.py @@ -21,6 +21,7 @@ EXACT_DESCRIPTIONS = { "index.html": "app DOM shell, canvas, panels, dialogs, and ordered script tags", "README.md": "small AI entrypoint and read strategy", "AI_MAP.md": "subsystem map and task-oriented read sets", + "GAME_FEATURES.md": "player-facing game features, UI, items, and behavior guide", "FILES.json": "machine-readable file routing and glob semantics", "service-worker.js": "generated offline/cache service worker", } diff --git a/scripts/regression_check.py b/scripts/regression_check.py index 798b9a2..7e59bdd 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -473,46 +473,56 @@ def check_current_save_format() -> None: placement = read_text(ROOT / "js/world_placement_log.js") if "playLogSoundForEntry" in placement: fail("log sound mapping still lives in world_placement_log.js") + if "player_item_placement" in placement: + fail("player item placement should not create placement log entries") ok("binary hash save snapshot, codec, storage, and restore coordinator present") def check_unified_item_registry() -> None: data = manifest() js_files = data["js"] - if "js/item_registry.js" not in js_files: - fail("unified item registry missing from manifest") - removed = ["js/food_registry.js", "js/effect_registry.js", "js/item_visual_registry.js"] - stale_manifest = [rel for rel in removed if rel in js_files] - if stale_manifest: - fail("old split item registry files still listed in manifest: " + ", ".join(stale_manifest)) - stale_files = [rel for rel in removed if (ROOT / rel).exists()] - if stale_files: - fail("old split item registry files still exist: " + ", ".join(stale_files)) - source = read_text(ROOT / "js/item_registry.js") - required = [ - "global.TarinaiItemRegistry", - "const TOOL_DEFINITIONS", - "function itemDefinition", - "function itemVisualDefinition", - "class FoodRegistry", - "class EffectRegistry", - "food: FOOD_REGISTRY", - "effect: EFFECT_REGISTRY", - "visual: itemVisualDefinition", + required_files = [ + "js/item_tool_definitions.js", + "js/item_visual_definitions.js", + "js/item_food_definitions.js", + "js/item_effect_definitions.js", + "js/item_registry.js", ] - for token in required: + missing = [rel for rel in required_files if rel not in js_files or not (ROOT / rel).exists()] + if missing: + fail("split item registry files missing: " + ", ".join(missing)) + order = [js_files.index(rel) for rel in required_files] + if order != sorted(order): + fail("split item registry files are not loaded before the facade in manifest order") + stale_files = [rel for rel in ["js/food_registry.js", "js/effect_registry.js", "js/item_visual_registry.js"] if (ROOT / rel).exists() or rel in js_files] + if stale_files: + fail("obsolete item registry split files remain: " + ", ".join(stale_files)) + + tool_source = read_text(ROOT / "js/item_tool_definitions.js") + visual_source = read_text(ROOT / "js/item_visual_definitions.js") + food_source = read_text(ROOT / "js/item_food_definitions.js") + effect_source = read_text(ROOT / "js/item_effect_definitions.js") + facade_source = read_text(ROOT / "js/item_registry.js") + required_pairs = [ + (tool_source, "const TOOL_DEFINITIONS"), + (tool_source, "global.TarinaiToolRegistry"), + (visual_source, "function itemVisualDefinition"), + (visual_source, "global.TarinaiItemVisualDefinitions"), + (food_source, "class FoodRegistry"), + (food_source, "global.TarinaiFoodRegistry"), + (effect_source, "class EffectRegistry"), + (effect_source, "global.TarinaiEffectRegistry"), + (facade_source, "global.TarinaiItemRegistry"), + (facade_source, "definition: itemTypeDefinition"), + ] + for source, token in required_pairs: if token not in source: - fail(f"unified item registry token missing: {token}") - forbidden_globals = ["global.TarinaiFoodRegistry", "global.TarinaiEffectRegistry", "global.TARINAI_ITEM_VISUALS", "global.TARINAI_FOOD_DEFINITIONS", "global.TARINAI_EFFECT_DEFINITIONS"] - offenders = [token for token in forbidden_globals if token in source] - if offenders: - fail("old split item registry globals still exported: " + ", ".join(offenders)) + fail(f"split item registry token missing: {token}") data_source = read_text(ROOT / "js/data.js") forbidden_data = ["const TOOL_DEFINITIONS", "const ITEM_TRAITS", "class FoodRegistry", "class EffectRegistry", "TARINAI_ITEM_VISUALS"] offenders = [token for token in forbidden_data if token in data_source] if offenders: fail("item definitions still mixed into data.js: " + ", ".join(offenders)) - ok("item definitions centralized in item_registry.js") - + ok("item registry responsibilities split behind facade") def check_docs_removed() -> None: docs = ROOT / "docs" diff --git a/service-worker.js b/service-worker.js index 215e1ac..7bcd738 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,11 +1,11 @@ "use strict"; -const APP_VERSION = "39.15.47"; +const APP_VERSION = "39.15.63"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. const coreScriptNames = [ - "version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "collision_footprint_system", "mechanical_system", "constraint_system", "physics_world_system", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_lifecycle_runtime", "item_update_policy", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_forced_action_planner_system", "tarinai_action_planner_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "world_update_phases", "system_order", "simulation", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "patch_helpers", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" + "version", "event_bus", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "collision_footprint_system", "physics_helpers", "placement_collision_system", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_lifecycle_runtime", "item_update_policy", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_forced_action_planner_system", "tarinai_action_planner_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "world_update_phases", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "patch_helpers", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" ]; const CORE_ASSETS = [ "./",