Compare commits

..

No commits in common. "ee22e1a9298845c4f5bd57c57098de9e87a2d036" and "ad2cc76429276394e57909c8af9eb8d4a37f4554" have entirely different histories.

123 changed files with 4037 additions and 6556 deletions

View file

@ -6,10 +6,9 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.103`.
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.47`.
- Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render.
- Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches.
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.
- Update order: `js/system_order.js` calls phase facades from `js/world_update_phases.js` / `js/simulation_systems.js`.
- Command path: UI/input emits command objects; `js/command_dispatcher.js` routes tool, pointer, selection, save, reset, and UI intents.
- Persistence: save files are schema/codec/storage/coordinator modules; snapshot/history modules keep world restoration and stats consistent.
@ -46,8 +45,6 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Tool behavior: `js/command_dispatcher.js`, `js/world_tool_actions.js`, `js/item_registry.js`, relevant `js/item_*` or `js/tarinai_item_*`.
- Creature behavior: `js/tarinai.js`, `js/tarinai_update_pipeline.js`, relevant `js/tarinai_*`, `js/system_order.js`.
- World/environment/combat: matching `js/world_*`, `js/simulation_*`, `js/system_order.js`.
- Fire/burning behavior: `js/fire_runtime_system.js`, `js/item_dynamic_tool_system.js`, relevant item definitions, `js/world_combat_effects.js`, and nearby environment helpers.
- Performance/crowding: `js/perf_profiler.js`, `js/item_update_scheduler.js`, `js/tarinai_update_policy.js`, `js/constraint_system.js`, `js/world_spatial_budget.js`, and render/detail code.
- Save/load: `js/save_schema.js`, `js/save_codec.js`, `js/save_system.js`, `js/snapshot_system.js`, `js/restore_coordinator.js`.
- Family tree: `js/world_family_social.js`, `js/family_graph.js`, `js/ui_family_*.js`.
- Static app/cache/version: `app_manifest.json`, `scripts/generate_app_files.py`, `service-worker.js`, `js/version.js`, `index.html`.
@ -56,7 +53,6 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Preserve `index.html` script order unless all dependent manifests/generated files are updated together.
- Keep `app_manifest.json`, `js/version.js`, `service-worker.js`, and cache query versions synchronized.
- Keep intentional global exports for compatibility with regression checks and browser runtime; remove public facades only after repo-wide caller checks.
- Keep global exports for compatibility with regression checks and browser runtime.
- Prefer focused edits in the owning subsystem; split modules are intentional compatibility boundaries.
- Avoid reintroducing `simulationQualityTier` branches unless a real simulation-quality setting is restored; current quality controls are visual/detail controls.
- Run `node --check js/*.js` and `python scripts/regression_check.py` for behavior changes.

View file

@ -1,13 +0,0 @@
tarinai_water_balloon_fan_ui_v1
- Fan drawing now shows the actual forward wind cone/range.
- Mobile rotator/reciprocator editor dialogs are scrollable; reciprocator canvas shares editor sizing.
- Balloon tool is replaced by water balloon visuals/text.
- Water balloons splash water when popped; low-openness tarinai panic and flee with behavior text: 水風船が割れておどろいている。
- Version: 39.15.116
tarinai_king_fix_v10
- Fire tool button hidden; flames spawn only from flame firecracker.
- Burning tarinai and ants draw lightweight flame-shaped overlays.
- Flame firecracker is redder and shows a countdown timer; normal firecracker is darker.
- Standalone flame and fire particles use a simpler flame silhouette.
- Version: 39.15.102

View file

@ -1,6 +1,6 @@
{
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
"version": "39.15.103",
"version": "39.15.77",
"entrypoints": {
"app": "index.html",
"runtime": "js/main.js",
@ -71,9 +71,7 @@
"js/main.js",
"js/math.js",
"js/perf_profiler.js",
"js/debug_tools.js",
"js/update_step_pipeline_runner.js",
"js/burn_motion_util.js"
"js/debug_tools.js"
],
"meaning": "Bootstrap, shared constants/helpers, registries, config, profiling/debug."
},

View file

@ -16,8 +16,7 @@ The core loop is:
## 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, display settings, and reset.
- **Display settings**: manually adjust effects, shadows, and drawing detail. `High` keeps quality-preserving optimizations active, while lower tiers cut heavier visuals for crowded scenes without lowering object limits; simulation cadence remains quality-preserving.
- **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.
@ -32,68 +31,68 @@ The core loop is:
### 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.
- **解除 / 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, be used for simple beds, and sometimes dampen nearby fire instead of immediately catching.
- **水滴 / 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.
- **ずんだ餅 / 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.
- **ねむり薬 / 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.
- **ロボ掃除機 / robot_cleaner**: moves through the field at fixed normal speed 60, or 120 in high-speed mode. It checks the nearest matching cleanup target every 2 seconds, keeps pursuing it between scans, and cleans every matching object it touches. The 死骸 target also covers blood traces; clicking the item opens its target settings.
- **干草寝床 / bed**: placeable sleeping spot; helps HP recovery.
- **巣箱 / nest box**: reduces stress nearby; improves sleep quality when used.
- **ボール / ball**: toy that tarinai can play with and the player can poke.
- **看板 / signboard**: editable text object; inspect with observe after placing.
- **ずんち / zunchi**: tarinai waste; affects disease and can fertilize grass.
- **ロボ掃除機 / robot_cleaner**: moves through the field at fixed normal speed 60, or 120 in high-speed mode. It checks the nearest matching cleanup target every 2 seconds, keeps pursuing it between scans, and cleans every matching object it touches. The 死骸 target also covers blood traces; clicking the item opens its target settings.
- **干草寝床 / bed**: placeable sleeping spot; helps HP recovery.
- **巣箱 / nest box**: reduces stress nearby; improves sleep quality when used.
- **ボール / ball**: toy that tarinai can play with and the player can poke.
- **看板 / 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.
- **ヒモ / 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; flame variants scatter fire while respecting active-fire limits.
- **画鋲 / 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.
- **石 / 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
@ -140,7 +139,7 @@ Tarinai can be pushed by physics objects, carried or affected by ants, dragged b
- **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, can become bed material, and has a small chance to reduce fire before burning.
- **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.
@ -170,23 +169,3 @@ Recovery depends on the disease or effect. Water, rest, food, medicine, zunda ju
- 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.
## 39.15.116 土管外観・内部温度
- 土管の外観を、丸い口と側面が見える斜め上からの視点に変更。
- 土管内の体感温度を20℃へ近づける内部温度補正を追加。補正幅は最大7℃。
- 土管ツールチップの内部気温表示にも、土管内補正後の温度を反映。
## 39.15.115 水風船の飛散強化・扇風機首振り設定
- 水風船破裂時に水滴が放射状に飛び、短時間移動してから水たまり状に落ち着く。
- 配置済み扇風機をクリックすると首振り設定UIを開ける。個別に首振りON/OFF、向き、振り幅、速度を設定可能。
- 首振り中の扇風機は風の範囲表示と実際の風判定が現在角度に追従する。
## 39.15.115 土管
- 生活カテゴリに「土管」を追加。丸い口が見える配置アイテムとして表示。
- たりないが巣箱のように土管の中へ入り、内部の個体は巣箱系ツールチップで確認可能。
- 土管から出る時は、複数ある場合は別のランダムな土管から出る。
- 土管内でも眠れるが、寝心地と睡眠優先順位は干草寝床の一段下。

View file

@ -3,9 +3,9 @@
AI-first entrypoint for a static browser simulation/game. Keep this file small; read deeper docs only when needed.
## v39.15.70 robot cleaner tuning
- Tuned ロボ掃除機 targeting: normal speed is 60, high-speed mode is 120, and speed no longer changes based on the selected cleanup targets.
- Tuned ロボ掃除機 targeting: normal speed is 60, high-speed mode is 120, and speed no longer changes based on the selected cleanup targets.
- The cleaner checks the nearest matching target every 2 seconds, then keeps trying to clean that target between scans. Contact cleanup still removes every matching object it touches.
- The 死骸 target also covers blood traces. When たりない is enabled, nearby Tarinai panic while chased and are cleaned on contact.
- The 死骸 target also covers blood traces. When たりない is enabled, nearby Tarinai panic while chased and are cleaned on contact.
## Read Strategy
@ -18,7 +18,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
## Runtime Shape
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.103`.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.47`.
- Mutable runtime lives on `window`; primary entities are `World`, `Tarinai`, items, structures, ants, family graph, weather, and save snapshots.
- Update loop: `main.js` creates the world/UI loop; `World.update` delegates to `TarinaiSystemOrder.update`; `system_order.js` runs named phases.
- UI loop: pointer/tool/UI intents go through `command_dispatcher.js`; canvas drawing is in render modules; side panels are `ui_*.js`.
@ -57,7 +57,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All`
## UI media isolation / scroll repair
## v39.15.47 UI media isolation / scroll repair
- PC / smartphone UI override rules are separated into `@media (min-width: 981px)` and `@media (max-width: 980px)`.
- PC family tree card is restored below the main game layout, not inside the right menu.
@ -65,7 +65,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- Auto-scroll does not open collapsed cards.
## Event/family/oshibyo UI fixes
## v39.15.47 event/family/oshibyo UI fixes
- Event UI hides direct player item placement records while keeping those records in the internal log.
- Collapsed Event UI no longer leaves a large blank area from the log-card min-height.
@ -73,14 +73,14 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- Oshibyo lodged position is anchored to the left side of the tarinai sprite.
## Family polish
## v39.15.47 family polish
- Family tree generation row spacing is tightened.
- Family tree personality tags also use a 2x2 grid (up to 4 visible tags).
- Sprinkler effective cleaning radius is increased to 3x the previous value and still filters targets by actual distance.
## Owned object panic / shoot effect
## v39.15.47 owned object panic / shoot effect
- Tarinai panic when their own plushie or simple grass bed is destroyed.
- The selected Tarinai data behavior text explicitly shows the owned-object panic reason.

View file

@ -1,5 +1,5 @@
{
"version": "39.15.116",
"version": "39.15.90",
"css": [
"css/base.css",
"css/layout.css",
@ -14,7 +14,6 @@
"js/disease_registry.js",
"js/sound_pack.js",
"js/deterministic_helpers.js",
"js/item_tool_metadata.js",
"js/item_tool_definitions.js",
"js/item_visual_definitions.js",
"js/item_food_definitions.js",
@ -24,7 +23,6 @@
"js/ground_types.js",
"js/input_mode_manager.js",
"js/math.js",
"js/geometry_helpers.js",
"js/collision_footprint_system.js",
"js/physics_helpers.js",
"js/placement_preview_system.js",
@ -46,8 +44,6 @@
"js/item_type_initializers.js",
"js/item_lifecycle_support.js",
"js/item_update_policy.js",
"js/fire_runtime_system.js",
"js/robot_cleaner_system.js",
"js/item_dynamic_tool_system.js",
"js/item_dynamic_ball_system.js",
"js/item_dynamic_duplicator_system.js",
@ -60,13 +56,10 @@
"js/item_lifecycle_step_decay.js",
"js/item_lifecycle_step_dynamic.js",
"js/item_lifecycle_step_growth.js",
"js/update_step_pipeline_runner.js",
"js/item_lifecycle_pipeline.js",
"js/item_runtime.js",
"js/structure_lifecycle.js",
"js/item_render_helpers.js",
"js/item_render_runtime.js",
"js/burn_motion_util.js",
"js/ants.js",
"js/health.js",
"js/tarinai.js",
@ -86,7 +79,6 @@
"js/tarinai_building_behavior.js",
"js/tarinai_action_definitions.js",
"js/tarinai_needs_items.js",
"js/tarinai_food_prototype_mixin.js",
"js/tarinai_need_planner_system.js",
"js/tarinai_item_interaction_context.js",
"js/tarinai_food_interaction_system.js",
@ -107,16 +99,12 @@
"js/world.js",
"js/world_view.js",
"js/family_graph.js",
"js/world_reset_presets.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_temperature_system.js",
"js/world_pathfinding_system.js",
"js/world_grass_placement_system.js",
"js/world_spatial_budget.js",
"js/world_ants_system.js",
"js/weather_system.js",
@ -167,5 +155,14 @@
"js/ui_family_render.js",
"js/main.js",
"js/debug_tools.js"
]
],
"assetGlobs": [
"assets/ui/*.webp",
"assets/objects/*.webp",
"assets/sprites/*.webp",
"assets/sounds/*.wav",
"assets/sounds/*.mp3",
"assets/objects/*.png"
],
"notes": "Boundary cleanup v38: freezer removed, washing expanded and continuous, laboratory ground added, and save schema synchronized."
}

View file

@ -36,6 +36,23 @@ button {
color: inherit;
}
.loading-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 50% 30%, rgba(164, 225, 104, 0.22), transparent 32%),
linear-gradient(180deg, rgba(239, 248, 255, 0.98), rgba(247, 245, 236, 0.98));
transition: opacity 180ms ease, visibility 180ms ease;
}
.loading-screen.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.loading-panel {
width: min(420px, 92vw);
padding: 24px 22px;
@ -45,6 +62,18 @@ button {
box-shadow: 0 18px 48px rgba(64, 48, 32, 0.16);
text-align: center;
}
.loading-title {
font-weight: 700;
font-size: 18px;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.loading-text {
color: var(--muted);
font-size: 13px;
line-height: 1.55;
min-height: 1.55em;
}
#app {
width: min(1480px, 100vw);
@ -56,17 +85,15 @@ button {
.topbar {
display: flex;
justify-content: space-between;
gap: 14px;
gap: 18px;
align-items: center;
flex-wrap: nowrap;
margin-bottom: 16px;
}
.top-left {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
gap: 12px;
min-width: 0;
}
@ -91,21 +118,19 @@ h1 {
white-space: nowrap;
}
.top-actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
white-space: nowrap;
}
.btn, .tool {
border: 1px solid var(--line);
background: rgba(255,255,255,0.76);
border-radius: 999px;
padding: 8px 11px;
padding: 9px 13px;
cursor: pointer;
box-shadow: 0 4px 14px rgba(80,60,40,0.06);
transition: transform 130ms ease, background 130ms ease, border-color 130ms ease;
@ -230,6 +255,7 @@ h1 {
min-width: 132px;
}
.ecology-dialog-panel {
width: min(760px, 100%);
max-height: calc(100vh - 36px);
@ -422,15 +448,16 @@ h1 {
.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp"); }
/* Generated parameter item tool icons. */
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp"); }
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); }
.signboard-editor {
position: fixed;
@ -450,10 +477,6 @@ h1 {
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.96));
border: 1px solid rgba(94, 70, 42, 0.20);
box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72);
max-height: calc(100dvh - 36px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.signboard-editor-titlebar {
display: flex;
@ -599,10 +622,6 @@ h1 {
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.97));
border: 1px solid rgba(94, 70, 42, 0.20);
box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72);
max-height: calc(100dvh - 36px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.rotator-editor-titlebar {
display: flex;
@ -668,8 +687,7 @@ h1 {
border-color: rgba(112, 80, 170, 0.38);
font-weight: 700;
}
#rotatorCanvas,
#reciprocatorCanvas {
#rotatorCanvas {
width: 100%;
height: min(58vh, 360px);
display: block;
@ -691,44 +709,7 @@ h1 {
margin-top: 12px;
}
@media (max-width: 560px) {
.rotator-editor {
place-items: start center;
padding: 8px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.rotator-editor-panel {
width: calc(100vw - 16px);
max-height: calc(100dvh - 16px);
padding: 10px;
}
.rotator-editor-panel { padding: 10px; }
.rotator-editor-controls label { grid-template-columns: 1fr; align-items: stretch; }
.rotator-editor-tools { max-height: 22vh; overflow-y: auto; padding-right: 2px; }
#rotatorCanvas, #reciprocatorCanvas { height: min(44vh, 270px); }
}
.fan-editor-panel { width: min(430px, calc(100vw - 28px)); }
.fan-editor-help {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
margin: 0 2px 10px;
}
.fan-editor-controls label {
grid-template-columns: 56px 1fr 72px auto;
}
.fan-editor-controls .fan-editor-check {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255,255,255,0.58);
border: 1px solid rgba(84,68,48,0.11);
}
.fan-editor-controls input[type="checkbox"] { accent-color: #70aee8; }
@media (max-width: 560px) {
.fan-editor-controls label { grid-template-columns: 1fr; align-items: stretch; }
.fan-editor-panel { width: calc(100vw - 16px); }
#rotatorCanvas { height: 320px; }
}

View file

@ -17,7 +17,7 @@
backdrop-filter: blur(1px);
text-shadow: 0 1px 3px rgba(25, 18, 12, 0.55);
pointer-events: none;
white-space: pre-line;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
@ -643,60 +643,6 @@
font-size: 12px;
line-height: 1.45;
}
.visual-settings-dialog-panel {
width: min(560px, calc(100vw - 32px));
}
.visual-setting-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 0;
border-top: 1px solid rgba(115, 91, 62, 0.16);
}
.visual-setting-row strong,
.visual-setting-row span {
display: block;
}
.visual-setting-row strong {
color: #4f3928;
font-size: 15px;
}
.visual-setting-row span {
margin-top: 3px;
color: #6d5a46;
font-size: 12px;
}
.visual-setting-options {
display: inline-flex;
gap: 5px;
padding: 4px;
border: 1px solid rgba(115, 91, 62, 0.18);
border-radius: 999px;
background: rgba(255, 255, 255, 0.50);
}
.visual-setting-options button {
min-width: 46px;
min-height: 32px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: #5d4938;
cursor: pointer;
font: inherit;
font-weight: 800;
}
.visual-setting-options button.active,
.visual-setting-options button[aria-pressed="true"] {
border-color: rgba(127, 94, 54, 0.28);
background: #fff4d8;
color: #3f3023;
box-shadow: 0 2px 8px rgba(96, 70, 40, 0.12);
}
.visual-setting-options button:focus-visible {
outline: 2px solid rgba(184, 128, 54, 0.45);
outline-offset: 2px;
}
@media (max-width: 640px) {
.save-slot-row {
grid-template-columns: 1fr;
@ -704,16 +650,6 @@
.save-slot-actions {
justify-content: flex-start;
}
.visual-setting-row {
grid-template-columns: 1fr;
}
.visual-setting-options {
width: 100%;
justify-content: stretch;
}
.visual-setting-options button {
flex: 1 1 0;
}
}
.ground-stat {
@ -762,10 +698,12 @@
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98));
border-bottom-color: rgba(82,128,190,0.30);
}
.tool-category[data-tool-category="operate"] .tool-category-body {
background: rgba(238, 247, 255, 0.98);
}
.tool-category[data-tool-category="operate"] .tool-category-body {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 5px;
background: rgba(238, 247, 255, 0.98);
}
.tool-category[data-tool-category="operate"] .tool {
grid-template-columns: 1fr;
@ -800,7 +738,7 @@
}
}
/* Collapsed event card must shrink to its header.
/* v39.15.47: collapsed event card must shrink to its header.
layout.css gives .log-card a min-height, which left a large blank panel when folded. */
.collapsible-card.collapsed { min-height: 0 !important; }
.log-card.collapsed { min-height: 0 !important; height: auto !important; flex: 0 0 auto !important; flex-basis: auto !important; }

View file

@ -2,15 +2,26 @@
.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.webp"); }
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); }
.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); }
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); --tool-icon-zoom: 1.36; }
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); }
.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); }
.tool[data-tool="area_delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); }
.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); }
.tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; }
.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 "; }
.tool[data-tool="rod"] { --tool-emoji: "\2501 "; }
.tool[data-tool="reciprocator"] { --tool-emoji: "\21c4 "; }
.tool[data-tool="undo"] { --tool-emoji: "\21b6 "; }
.tool[data-tool="redo"] { --tool-emoji: "\21b7 "; }
.tool.tool-emoji-icon { --tool-icon: none; }
.tool.tool-emoji-icon::before {
content: var(--tool-emoji, "");
content: var(--tool-emoji);
background: none;
display: grid;
place-items: center;
@ -44,6 +55,7 @@
transform: translate(-50%, -50%) scale(var(--tool-icon-zoom, 1)) !important;
}
.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; }
.tool[data-tool="observe"],
.tool[data-tool="new"] { --tool-icon-zoom: 1.20; }
.tool[data-tool="giant_drug"] { --tool-icon-zoom: 1.58; }
@ -66,16 +78,16 @@
.tool-body { margin-top: 10px; }
.tool-palette { display: flex; flex-direction: column; gap: 8px; }
.tool-category {
border: 1px solid var(--tool-cat-line, rgba(84,68,48,0.10));
border: 1px solid rgba(84,68,48,0.10);
border-radius: 14px;
background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,255,255,0.36)), rgba(255,255,255,0.28));
background: rgba(255,255,255,0.34);
overflow: hidden;
}
.tool-category-toggle {
width: 100%;
border: 0;
border-bottom: 1px solid var(--tool-cat-line, rgba(84,68,48,0.08));
background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54)));
border-bottom: 1px solid rgba(84,68,48,0.08);
background: rgba(255,252,244,0.66);
padding: 7px 10px;
text-align: left;
cursor: pointer;
@ -102,8 +114,8 @@
.selected-category-toggle {
width: 100%;
border: 0;
border-bottom: 1px solid var(--tool-cat-line, rgba(84,68,48,0.08));
background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54)));
border-bottom: 1px solid rgba(84,68,48,0.08);
background: rgba(255,252,244,0.66);
padding: 8px 10px;
text-align: left;
cursor: pointer;
@ -328,6 +340,8 @@ meter {
.tool-category[data-tool-category="habitat"] { --tool-cat-a: rgba(255,245,218,0.84); --tool-cat-b: rgba(250,229,186,0.76); --tool-cat-line: rgba(174,125,54,0.28); }
.tool-category[data-tool-category="physics"] { --tool-cat-a: rgba(226,247,246,0.84); --tool-cat-b: rgba(199,232,231,0.76); --tool-cat-line: rgba(70,145,148,0.28); }
.tool-category[data-tool-category="hazard"] { --tool-cat-a: rgba(255,230,224,0.84); --tool-cat-b: rgba(250,207,197,0.78); --tool-cat-line: rgba(189,88,66,0.30); }
.tool-category { border-color: var(--tool-cat-line, rgba(84,68,48,0.10)); background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,255,255,0.36)), rgba(255,255,255,0.28)); }
.tool-category-toggle { background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54))); border-bottom-color: var(--tool-cat-line, rgba(84,68,48,0.08)); }
@media (min-width: 921px) {
.mobile-mode-controls { display: none !important; }
}

View file

@ -1,4 +1,4 @@
/* Sound menu */
/* v15.12.1: top-right sound settings panel */
.sound-menu { position: relative; display: inline-block; }
.sound-menu-btn.active { background: linear-gradient(180deg, #f3ffe9, #e7f8db); border-color: rgba(103,178,44,.46); }
.sound-panel {
@ -22,8 +22,19 @@
.sound-panel input:disabled + span { opacity: .55; }
.sound-master { font-weight: 700; }
.sound-panel-divider { height: 1px; background: rgba(84,68,48,.12); margin: 2px 0; }
@media (max-width: 980px) { .sound-panel { right: auto; left: 0; } }
/* Shared layout and gesture guards */
/* Collapsed card sizing is shared by the base panel rules; no event-only shrink override. */
/* v15.12.4: event log scroll chains to the right menu at top/bottom */
.log {
overscroll-behavior-y: auto !important;
}
/* v15.12.4: sound settings live only in the top-right sound menu */
/* v15.17.0: mobile layout isolation and canvas touch gestures */
#app { overflow-x: clip; }
.layout { position: relative; z-index: 2; }
.panel { position: relative; z-index: 3; }
@ -44,21 +55,81 @@
max-width: 100%;
box-sizing: border-box;
}
.log {
overscroll-behavior-y: auto !important;
}
body.touch-mobile-ui .tool-tip-popover {
max-width: min(520px, calc(100vw - 24px));
}
body.touch-mobile-ui .tool-category:not([data-tool-category="operate"]).collapsed .tool-category-body {
display: none;
@media (max-width: 980px) {
.layout {
position: relative;
z-index: 4;
}
.panel {
position: relative;
z-index: 5;
isolation: isolate;
}
.lineage-main-card {
z-index: 1;
width: 100%;
max-width: 100%;
margin-top: 14px;
clear: both;
}
.lineage-main-content {
height: auto;
min-height: 340px;
max-height: 72vh;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y pinch-zoom;
}
.lineage-family-tree {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y pinch-zoom;
}
.lineage-family-tree .lineage-tree-panel {
max-width: none;
}
}
/* Mobile input controls */
.mobile-mode-controls,
/* Mobile input mode */
.mobile-mode-controls {
display: none;
position: absolute;
left: 10px;
bottom: 10px;
z-index: 24;
gap: 5px;
padding: 6px;
border-radius: 14px;
background: rgba(255, 251, 245, 0.90);
box-shadow: 0 8px 22px rgba(80,60,35,.18);
border: 1px solid rgba(120, 95, 60, .16);
backdrop-filter: blur(8px);
}
.mobile-mode-controls button {
border: 0;
border-radius: 10px;
padding: 7px 8px;
font-size: 12px;
font-weight: 800;
color: #65533f;
background: rgba(255,255,255,.78);
box-shadow: inset 0 -1px 0 rgba(0,0,0,.05);
}
.mobile-mode-controls button.active {
color: #3d321f;
background: linear-gradient(180deg, #ffe9a8, #ffd376);
box-shadow: 0 3px 10px rgba(150, 105, 30, .20);
}
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;
@ -68,17 +139,6 @@ body.touch-mobile-ui .tool-category:not([data-tool-category="operate"]).collapse
border: 1px solid rgba(120, 95, 60, .16);
backdrop-filter: blur(8px);
}
.mobile-mode-controls {
left: 10px;
bottom: 10px;
gap: 5px;
background: rgba(255, 251, 245, 0.90);
}
.mobile-rotate-controls {
right: 10px;
bottom: 58px;
}
.mobile-mode-controls button,
.mobile-rotate-controls button {
border: 0;
border-radius: 10px;
@ -89,22 +149,83 @@ body.touch-mobile-ui .tool-category:not([data-tool-category="operate"]).collapse
background: rgba(255,255,255,.82);
box-shadow: inset 0 -1px 0 rgba(0,0,0,.05);
}
.mobile-mode-controls button { padding: 7px 8px; background: rgba(255,255,255,.78); }
.mobile-mode-controls button.active {
color: #3d321f;
background: linear-gradient(180deg, #ffe9a8, #ffd376);
box-shadow: 0 3px 10px rgba(150, 105, 30, .20);
}
.mobile-rotate-controls button:active { transform: translateY(1px); }
body.touch-mobile-ui .mobile-mode-controls:not([hidden]),
body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) { display: flex; }
body:not(.touch-mobile-ui) .mobile-mode-controls,
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; }
/* Back to canvas affordance */
/* Keep panel themes on narrow layouts. */
.card.tool-card { --ui-theme-rgb: 0, 114, 178; background: linear-gradient(180deg, rgba(242, 248, 252, 0.94), rgba(232, 243, 250, 0.91)); border-color: rgba(0, 114, 178, 0.24); }
.card.selected-card { --ui-theme-rgb: 204, 121, 167; background: linear-gradient(180deg, rgba(253, 245, 250, 0.94), rgba(249, 235, 244, 0.91)); border-color: rgba(204, 121, 167, 0.24); }
.card.colony-card { --ui-theme-rgb: 0, 158, 115; background: linear-gradient(180deg, rgba(240, 250, 247, 0.94), rgba(229, 245, 239, 0.91)); border-color: rgba(0, 158, 115, 0.24); }
.card.log-card { --ui-theme-rgb: 230, 159, 0; background: linear-gradient(180deg, rgba(255, 249, 236, 0.94), rgba(253, 242, 215, 0.91)); border-color: rgba(230, 159, 0, 0.24); }
/* v39.15.35: mobile controls and scroll affordances */
@media (max-width: 980px) {
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) {
display: flex !important;
}
.panel-quick-tabs {
grid-template-columns: repeat(5, minmax(0, 1fr));
position: sticky;
top: 0;
}
.panel-quick-tabs button {
padding-left: 3px;
padding-right: 3px;
font-size: 10.5px;
}
}
body.touch-mobile-ui .tool-tip-popover {
max-width: min(520px, calc(100vw - 24px));
}
/* v39.15.36: mobile navigation redesign without family input mode */
@media (max-width: 980px) {
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) {
position: fixed;
left: 50%;
right: auto;
bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
transform: translateX(-50%);
z-index: 12001;
max-width: calc(100vw - 22px);
}
.panel-quick-tabs {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.lineage-main-card {
margin: 10px 0 0;
contain: content;
}
.lineage-main-card .lineage-head {
gap: 8px;
}
.lineage-main-card .lineage-actions {
flex-wrap: wrap;
}
body.touch-mobile-ui .tool-category:not([data-tool-category="operate"]).collapsed .tool-category-body {
display: none;
}
body.touch-mobile-ui .log {
max-height: 240px;
}
}
.back-to-game-btn {
position: fixed;
right: calc(env(safe-area-inset-right, 0px) + 12px);
@ -121,7 +242,173 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
backdrop-filter: blur(8px);
}
.back-to-game-btn[hidden] { display: none !important; }
@media (min-width: 981px) {
.back-to-game-btn { display: none !important; }
}
/* 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(5, minmax(0, 1fr));
}
.panel-quick-tabs [data-panel-tab="game"] {
display: none !important;
}
}
/* v39.15.38: mobile navigation/footer room and family-graph reachability. */
@media (max-width: 980px) {
html,
body {
max-width: 100%;
overflow-x: hidden;
}
#app {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding-left: max(10px, env(safe-area-inset-left, 0px));
padding-right: max(10px, env(safe-area-inset-right, 0px));
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
overflow-x: clip;
}
.topbar,
.top-left,
.brand,
.layout,
.stage-wrap,
.panel,
.card,
.lineage-main-card {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.top-actions {
width: 100%;
max-width: 100%;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 5px;
overflow-x: auto;
overflow-y: hidden;
padding: 1px 0 5px;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.top-actions > .btn,
.top-actions > .sound-menu,
.top-actions .sound-menu-btn {
flex: 0 0 auto;
white-space: nowrap;
}
.top-actions .btn {
padding: 7px 9px;
font-size: 11px;
}
.room-link {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) {
position: absolute;
left: 50%;
right: auto;
bottom: 10px;
transform: translateX(-50%);
z-index: 24;
max-width: calc(100% - 18px);
}
body.touch-mobile-ui .panel-quick-tabs {
position: fixed;
left: max(8px, env(safe-area-inset-left, 0px));
right: max(8px, env(safe-area-inset-right, 0px));
bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
top: auto;
z-index: 12002;
width: auto;
max-width: none;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 4px;
margin: 0;
padding: 6px;
border: 1px solid rgba(102, 169, 215, 0.34);
border-radius: 16px;
background: rgba(244, 250, 247, 0.95);
box-shadow: 0 12px 28px rgba(42, 54, 64, 0.18);
}
body.touch-mobile-ui .panel-quick-tabs button {
min-height: 34px;
padding: 5px 2px;
font-size: 10px;
}
body.touch-mobile-ui .selected-card,
body.touch-mobile-ui .tool-card,
body.touch-mobile-ui .colony-card,
body.touch-mobile-ui .log-card,
body.touch-mobile-ui .lineage-main-card {
scroll-margin-top: 10px;
scroll-margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 88px);
}
body.touch-mobile-ui .lineage-main-card {
margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 78px);
}
.back-to-game-btn {
bottom: calc(env(safe-area-inset-bottom, 0px) + 62px);
right: max(10px, env(safe-area-inset-right, 0px));
}
}
/* v39.15.47: desktop rollback guard. Keep the normal PC layout; only remove duplicated quick-scroll buttons. */
@media (min-width: 981px) {
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 14px 18px;
}
.top-left {
flex: 1 0 auto;
min-width: max-content;
max-width: 100%;
}
.brand,
.brand h1,
h1 {
flex: 0 0 auto;
min-width: max-content;
max-width: none !important;
overflow: visible !important;
text-overflow: clip !important;
}
.top-actions {
flex: 1 1 560px;
margin-left: auto;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.top-actions .btn,
.top-actions .sound-menu-btn,
.room-link {
min-height: 42px;
padding: 10px 16px;
font-size: 13px;
line-height: 1.15;
}
.panel-quick-tabs {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.panel-quick-tabs [data-panel-tab="game"] {
display: none !important;
}
}
/* v39.15.47: PC / smartphone UI isolation. All override UI placement is scoped by width. */
@media (min-width: 981px) {
html,
body {
@ -133,8 +420,18 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
padding: 18px;
overflow: visible;
}
.topbar { gap: 12px; }
.top-left { max-width: 100%; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 14px 18px;
}
.top-left {
flex: 1 0 auto;
min-width: max-content;
max-width: 100%;
}
.brand,
.brand h1,
h1 {
@ -144,19 +441,20 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
overflow: visible !important;
text-overflow: clip !important;
}
.top-actions { margin-left: auto; }
.top-actions {
flex: 1 1 560px;
margin-left: auto;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.top-actions .btn,
.top-actions .sound-menu-btn,
.room-link {
min-height: 38px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.12;
}
.room-link {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
min-height: 42px;
padding: 10px 16px;
font-size: 13px;
line-height: 1.15;
}
.layout {
display: grid;
@ -176,12 +474,18 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
top: 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.panel-quick-tabs [data-panel-tab="game"] { display: none !important; }
.panel-quick-tabs [data-panel-tab="game"] {
display: none !important;
}
.lineage-main-card {
display: block;
visibility: visible;
position: relative;
z-index: 1;
width: auto;
margin: 16px 0 0;
overflow: hidden;
contain: layout paint;
}
.lineage-main-content {
height: min(78vh, 980px);
@ -190,7 +494,6 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
overflow: auto;
overscroll-behavior: auto;
}
.back-to-game-btn { display: none !important; }
}
@media (max-width: 980px) {
@ -223,11 +526,16 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
box-sizing: border-box;
}
.topbar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.top-left { gap: 8px; }
.top-left {
display: flex;
align-items: center;
gap: 8px;
}
.brand h1,
h1 {
max-width: 100%;
@ -237,6 +545,8 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
font-size: clamp(20px, 7vw, 30px);
}
.top-actions {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 5px;
width: 100%;
@ -257,12 +567,13 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
padding: 7px 9px;
font-size: 11px;
}
.sound-panel { right: auto; left: 0; }
.layout {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
position: relative;
z-index: 2;
overflow: visible;
}
.stage-wrap {
@ -317,15 +628,6 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
z-index: 24;
max-width: calc(100% - 18px);
}
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-auto #gameCanvas {
touch-action: pan-y pinch-zoom !important;
}
@ -337,10 +639,13 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
max-height: none !important;
height: auto !important;
overflow: visible !important;
overscroll-behavior-y: auto !important;
}
.lineage-main-card {
display: block !important;
visibility: visible !important;
position: relative;
z-index: 1;
width: 100%;
margin: 10px 0 calc(env(safe-area-inset-bottom, 0px) + 132px);
overflow: visible !important;
@ -359,6 +664,7 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
touch-action: pan-y pinch-zoom;
}
.lineage-family-tree {
max-width: 100%;
overflow-x: auto !important;
overflow-y: visible !important;
overscroll-behavior-x: contain;
@ -366,7 +672,9 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y pinch-zoom;
}
.lineage-family-tree .lineage-tree-panel { max-width: none; }
.lineage-family-tree .lineage-tree-panel {
max-width: none;
}
.selected-card,
.tool-card,
.colony-card,
@ -376,8 +684,4 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
scroll-margin-top: 0 !important;
scroll-margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 104px);
}
.back-to-game-btn {
bottom: calc(env(safe-area-inset-bottom, 0px) + 62px);
right: max(10px, env(safe-area-inset-right, 0px));
}
}

View file

@ -99,7 +99,6 @@ body::before {
box-shadow: 0 3px 8px rgba(64,48,32,0.045);
overflow: hidden;
isolation: isolate;
overflow: hidden !important;
}
.family-node.child {
border-color: rgba(103, 178, 44, 0.38);
@ -158,7 +157,7 @@ body::before {
box-shadow: inset 0 0 0 1px rgba(247, 200, 64, 0.30), 0 3px 8px rgba(120, 78, 12, 0.09);
}
.lineage-family-tree .tree-node.champion::before {
content: "\265B ";
content: "";
position: absolute;
right: 6px;
top: 4px;
@ -183,6 +182,40 @@ body::before {
text-overflow: ellipsis;
white-space: nowrap;
}
.family-personality-tags {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(13px, auto);
align-items: stretch;
gap: 2px 4px;
margin-top: 2px;
max-width: 84px;
width: 84px;
max-height: 34px;
overflow: hidden !important;
text-overflow: clip !important;
white-space: normal !important;
}
.family-personality-tags em {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
max-width: 100%;
padding: 1px 3px;
border-radius: 999px;
background: rgba(103,178,44,0.11);
border: 1px solid rgba(103,178,44,0.20);
color: rgba(64,72,48,0.86);
font-size: 8.2px;
font-style: normal;
line-height: 1.18;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool[data-tip] { position: relative; }
@ -320,6 +353,23 @@ body::before {
border-radius: 9px;
background: rgba(255,255,255,0.70);
}
.lineage-family-tree .tree-node .family-copy {
max-width: calc(100% - 46px);
width: auto;
}
.lineage-family-tree .tree-node .family-copy strong,
.lineage-family-tree .tree-node .family-copy > span:not(.family-genetic-stats):not(.family-fight-stat):not(.family-personality-tags) {
max-width: 100%;
}
.lineage-family-tree .tree-node .family-personality-tags {
max-width: 84px;
width: 84px;
}
.lineage-family-tree .tree-node .family-personality-tags em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 720px) {
.lineage-main-content {
height: 70vh;
@ -371,22 +421,21 @@ body::before {
}
/* Family tree personality tags: consolidated final cascade. */
/* v9: keep 2x2 personality tags inside fixed family-tree nodes. */
.lineage-family-tree .tree-node .family-personality-tags,
.family-personality-tags {
box-sizing: border-box !important;
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
grid-auto-rows: 13px !important;
gap: 2px 3px !important;
width: 100% !important;
max-width: 100% !important;
width: 86px !important;
max-width: 86px !important;
height: 28px !important;
max-height: 28px !important;
margin-left: 0 !important;
margin-right: 0 !important;
justify-self: stretch !important;
overflow: hidden !important;
}
.lineage-family-tree .tree-node .family-personality-tags em,
.family-personality-tags em {
box-sizing: border-box !important;
display: block !important;
@ -400,39 +449,42 @@ body::before {
white-space: nowrap !important;
line-height: 11px !important;
font-size: 7.2px !important;
font-style: normal;
letter-spacing: -0.08em !important;
text-align: center !important;
border-radius: 999px;
background: rgba(103,178,44,0.11);
border: 1px solid rgba(103,178,44,0.20);
color: rgba(64,72,48,0.86);
}
/* v39.15.85: push family-tree personality tags to the node's right edge. */
.lineage-family-tree .tree-node .family-personality-tags,
.family-personality-tags {
margin-left: auto !important;
margin-right: 0 !important;
justify-self: end !important;
}
.lineage-family-tree .tree-node .family-copy {
width: 100% !important;
max-width: 100% !important;
position: static !important;
display: flex !important;
flex-direction: column !important;
gap: 1px !important;
}
.lineage-family-tree .tree-node .family-copy strong,
.lineage-family-tree .tree-node .family-copy > span:not(.family-personality-tags) {
max-width: calc(100% - 46px) !important;
}
.lineage-family-tree .tree-node .family-copy > .family-personality-tags {
max-width: 86px !important;
width: 86px !important;
}
/* v39.15.87: let family personality tags use the full node width. */
.lineage-family-tree .tree-node .family-copy > .family-personality-tags,
.lineage-family-tree .tree-node .family-personality-tags,
.family-personality-tags {
width: 100% !important;
max-width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
justify-self: stretch !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
.lineage-family-tree .tree-node .family-copy strong,
.lineage-family-tree .tree-node .family-copy > span:not(.family-personality-tags) {
max-width: 100% !important;
}
.lineage-family-tree .tree-node .family-copy > .family-personality-tags {
position: static !important;
width: 100% !important;
max-width: 100% !important;
height: 24px !important;
max-height: 24px !important;
margin: 2px 0 0 !important;
grid-auto-rows: 11px !important;
gap: 1px 3px !important;
z-index: 3 !important;
}
.lineage-family-tree .tree-node .family-personality-tags em {
height: 11px !important;
line-height: 9px !important;
font-size: 6.8px !important;
}

View file

@ -7,11 +7,11 @@
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=16.07.17" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=16.07.17" />
<link rel="shortcut icon" href="favicon.ico?v=16.07.17" />
<link rel="stylesheet" href="css/base.css?v=39.15.116" />
<link rel="stylesheet" href="css/layout.css?v=39.15.116" />
<link rel="stylesheet" href="css/panel.css?v=39.15.116" />
<link rel="stylesheet" href="css/components.css?v=39.15.116" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.116" />
<link rel="stylesheet" href="css/base.css?v=39.15.90" />
<link rel="stylesheet" href="css/layout.css?v=39.15.90" />
<link rel="stylesheet" href="css/panel.css?v=39.15.90" />
<link rel="stylesheet" href="css/components.css?v=39.15.90" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.90" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -48,7 +48,6 @@
</div>
<button id="creditsBtn" class="btn">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</button>
<button id="saveBtn" class="btn">&#x30BB;&#x30FC;&#x30D6;</button>
<button id="visualSettingsBtn" class="btn" type="button">&#x8868;&#x793A;&#x8A2D;&#x5B9A;</button>
<button id="resetBtn" class="btn danger">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
</div>
</header>
@ -103,8 +102,8 @@
<div class="stat"><span>&#x72B6;&#x614B;</span><strong id="statColonyMood">&#x5B89;&#x5B9A;</strong></div>
<div class="stat ground-stat" data-ground-toggle role="button" tabindex="0"><span>&#x5730;&#x9762;</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="&#x5730;&#x9762;&#x3092;&#x304D;&#x308A;&#x304B;&#x3048;&#x308B;"><strong id="statGroundType">&#x7802;</strong></button></div>
</div>
<div class="temperature-control" aria-label="&#x6C17;&#x6E29;&#x8ABF;&#x6574;">
<div class="temperature-control-head"><span>&#x6C17;&#x6E29;</span><strong id="temperatureValue">10&#x2103;</strong><label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> &#x81EA;&#x52D5;</label></div>
<div class="temperature-control" aria-label="気温調整">
<div class="temperature-control-head"><span>気温</span><strong id="temperatureValue">10</strong><label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> 自動</label></div>
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x500B;&#x4F53;&#x6570;</button><button data-chart="life">&#x6B32;&#x6C42;</button><button data-chart="objects">&#x7269;</button></div>
@ -159,20 +158,20 @@
<h2 id="fieldDialogTitle">&#x30EA;&#x30BB;&#x30C3;&#x30C8;&#x8A2D;&#x5B9A;</h2>
<p class="field-dialog-lead">&#x30B5;&#x30A4;&#x30BA;&#x3068;&#x30D7;&#x30EA;&#x30BB;&#x30C3;&#x30C8;&#x3092;&#x7D44;&#x307F;&#x5408;&#x308F;&#x305B;&#x3066;&#x89B3;&#x5BDF;&#x74B0;&#x5883;&#x3092;&#x751F;&#x6210;&#x3057;&#x307E;&#x3059;&#x3002;</p>
<div class="field-dialog-section-title">&#x30B5;&#x30A4;&#x30BA;</div>
<div class="field-choice-grid">
<div class="field-choice-grid reset-size-choice-grid">
<button class="field-choice" data-field-type="park" type="button" aria-pressed="false"><strong>&#x516C;&#x5712;</strong><span>&#x5927;</span></button>
<button class="field-choice" data-field-type="garden" type="button" aria-pressed="true"><strong>&#x5EAD;</strong><span>&#x4E2D;</span></button>
<button class="field-choice" data-field-type="cage" type="button" aria-pressed="false"><strong>&#x30B1;&#x30FC;&#x30B8;</strong><span>&#x5C0F;</span></button>
</div>
<div class="field-dialog-section-title">&#x30D7;&#x30EA;&#x30BB;&#x30C3;&#x30C8;</div>
<div class="field-choice-grid reset-preset-choice-grid">
<button class="field-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>&#x7A7A;&#x767D;</strong><span>&#x4F55;&#x3082;&#x7121;&#x3044;</span></button>
<button class="field-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>&#x30C7;&#x30D5;&#x30A9;&#x30EB;&#x30C8;</strong><span>&#x521D;&#x671F;&#x751F;&#x6210;</span></button>
<button class="field-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>&#x30A2;&#x30B9;&#x30EC;&#x30C1;&#x30C3;&#x30AF;</strong><span>&#x67F5;&#x3068;&#x30DC;&#x30FC;&#x30EB;</span></button>
<button class="field-choice" data-reset-preset="crowded" type="button" aria-pressed="false"><strong>&#x904E;&#x5BC6;</strong><span>&#x5927;&#x91CF;&#x306E;&#x305F;&#x308A;&#x306A;&#x3044;&#x3002;</span></button>
<button class="field-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>&#x5BB6;&#x65CF;</strong><span>2&#x5339;&#x3068;&#x3078;&#x3053;&#x9905;</span></button>
<button class="field-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>&#x6226;&#x4E89;</strong><span>&#x3051;&#x3093;&#x304B;&#x3068;&#x67F5;</span></button>
<button class="field-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>&#x5E78;&#x798F;</strong><span>&#x6383;&#x9664;&#x6A5F;&#x3068;&#x5DE3;&#x7BB1;</span></button>
<button class="field-choice preset-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>&#x7A7A;&#x767D;</strong><span>&#x4F55;&#x3082;&#x7121;&#x3044;</span></button>
<button class="field-choice preset-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>&#x30C7;&#x30D5;&#x30A9;&#x30EB;&#x30C8;</strong><span>&#x521D;&#x671F;&#x751F;&#x6210;</span></button>
<button class="field-choice preset-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>&#x30A2;&#x30B9;&#x30EC;&#x30C1;&#x30C3;&#x30AF;</strong><span>&#x67F5;&#x3068;&#x30DC;&#x30FC;&#x30EB;</span></button>
<button class="field-choice preset-choice" data-reset-preset="crowded" type="button" aria-pressed="false"><strong>&#x904E;&#x5BC6;</strong><span>&#x5927;&#x91CF;&#x3068;&#x305A;&#x3093;&#x3060;</span></button>
<button class="field-choice preset-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>&#x5BB6;&#x65CF;</strong><span>2&#x5339;&#x3068;&#x3078;&#x3053;&#x9905;</span></button>
<button class="field-choice preset-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>&#x6226;&#x4E89;</strong><span>&#x3051;&#x3093;&#x304B;&#x3068;&#x67F5;</span></button>
<button class="field-choice preset-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>&#x5E78;&#x798F;</strong><span>&#x6383;&#x9664;&#x6A5F;&#x3068;&#x5DE3;&#x7BB1;</span></button>
</div>
<div class="field-dialog-actions">
<button id="fieldCancelBtn" class="btn" type="button">&#x30AD;&#x30E3;&#x30F3;&#x30BB;&#x30EB;</button>
@ -183,53 +182,15 @@
<div id="visualSettingsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="visualSettingsDialogTitle">
<div class="field-dialog-panel visual-settings-dialog-panel">
<h2 id="visualSettingsDialogTitle">&#x8868;&#x793A;&#x8A2D;&#x5B9A;</h2>
<p class="hint">&#x91CD;&#x3044;&#x8868;&#x73FE;&#x3092;&#x624B;&#x52D5;&#x3067;&#x8ABF;&#x6574;&#x3057;&#x307E;&#x3059;&#x3002;&#x500B;&#x4F53;&#x6570;&#x3084;&#x7269;&#x306E;&#x4E0A;&#x9650;&#x306F;&#x5909;&#x3048;&#x307E;&#x305B;&#x3093;&#x3002;</p>
<div class="visual-setting-row">
<div><strong>&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;</strong><span>&#x7834;&#x7247;&#x3001;&#x96E8;&#x3001;&#x5149;&#x306E;&#x6F14;&#x51FA;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;">
<button type="button" data-visual-setting="effects" data-visual-value="off">OFF</button>
<button type="button" data-visual-setting="effects" data-visual-value="low">&#x4F4E;</button>
<button type="button" data-visual-setting="effects" data-visual-value="mid">&#x4E2D;</button>
<button type="button" data-visual-setting="effects" data-visual-value="high">&#x9AD8;</button>
</div>
</div>
<div class="visual-setting-row">
<div><strong>&#x5F71;</strong><span>&#x5730;&#x9762;&#x306E;&#x843D;&#x3061;&#x5F71;&#x3068;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;&#x306E;&#x5F71;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x5F71;">
<button type="button" data-visual-setting="shadows" data-visual-value="off">OFF</button>
<button type="button" data-visual-setting="shadows" data-visual-value="low">&#x4F4E;</button>
<button type="button" data-visual-setting="shadows" data-visual-value="mid">&#x4E2D;</button>
<button type="button" data-visual-setting="shadows" data-visual-value="high">&#x9AD8;</button>
</div>
</div>
<div class="visual-setting-row">
<div><strong>&#x63CF;&#x753B;</strong><span>&#x88C5;&#x98FE;&#x3068;&#x89E3;&#x50CF;&#x5EA6;&#x306E;&#x8A2D;&#x5B9A;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x63CF;&#x753B;">
<button type="button" data-visual-setting="details" data-visual-value="low">&#x4F4E;</button>
<button type="button" data-visual-setting="details" data-visual-value="mid">&#x4E2D;</button>
<button type="button" data-visual-setting="details" data-visual-value="high">&#x9AD8;</button>
</div>
</div>
<div class="field-dialog-actions">
<button id="visualSettingsCloseBtn" class="btn" type="button">&#x9589;&#x3058;&#x308B;</button>
</div>
</div>
</div>
<div id="creditsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="creditsDialogTitle">
<div class="field-dialog-panel">
<div class="field-dialog-panel credits-dialog-panel">
<h2 id="creditsDialogTitle">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</h2>
<div>
<div class="credits-content">
<p><strong>&#x88FD;&#x4F5C;:</strong> 333</p>
<p><strong>&#x30A4;&#x30E9;&#x30B9;&#x30C8;:</strong> &#x3080;&#x3089;&#x3055;&#x304D;&#x6C0F; / &#x3044;&#x3089;&#x3059;&#x3068;&#x3084;: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
<p><strong>&#x52B9;&#x679C;&#x97F3;:</strong> VOICEVOX: &#x305A;&#x3093;&#x3060;&#x3082;&#x3093;&#x3000;&#x52B9;&#x679C;&#x97F3;&#x30E9;&#x30DC;: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
</div>
<div class="field-dialog-actions">
<div class="field-dialog-actions credits-actions">
<button id="creditsCloseBtn" class="btn" type="button">&#x9589;&#x3058;&#x308B;</button>
</div>
</div>
@ -257,164 +218,152 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.15.116" defer></script>
<script src="js/event_bus.js?v=39.15.116" defer></script>
<script src="js/registry_base.js?v=39.15.116" defer></script>
<script src="js/disease_registry.js?v=39.15.116" defer></script>
<script src="js/sound_pack.js?v=39.15.116" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.116" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.116" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.116" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.116" defer></script>
<script src="js/item_food_definitions.js?v=39.15.116" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.116" defer></script>
<script src="js/item_registry.js?v=39.15.116" defer></script>
<script src="js/data.js?v=39.15.116" defer></script>
<script src="js/ground_types.js?v=39.15.116" defer></script>
<script src="js/input_mode_manager.js?v=39.15.116" defer></script>
<script src="js/math.js?v=39.15.116" defer></script>
<script src="js/geometry_helpers.js?v=39.15.116" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.116" defer></script>
<script src="js/physics_helpers.js?v=39.15.116" defer></script>
<script src="js/placement_preview_system.js?v=39.15.116" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.116" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.116" defer></script>
<script src="js/mechanical_system.js?v=39.15.116" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.116" defer></script>
<script src="js/constraint_system.js?v=39.15.116" defer></script>
<script src="js/physics_world_system.js?v=39.15.116" defer></script>
<script src="js/physics_projection_system.js?v=39.15.116" defer></script>
<script src="js/assets.js?v=39.15.116" defer></script>
<script src="js/audio.js?v=39.15.116" defer></script>
<script src="js/perf_profiler.js?v=39.15.116" defer></script>
<script src="js/render.js?v=39.15.116" defer></script>
<script src="js/sim_core.js?v=39.15.116" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.116" defer></script>
<script src="js/structures.js?v=39.15.116" defer></script>
<script src="js/items.js?v=39.15.116" defer></script>
<script src="js/item_type_initializers.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.116" defer></script>
<script src="js/item_update_policy.js?v=39.15.116" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.116" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.116" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.116" defer></script>
<script src="js/item_runtime.js?v=39.15.116" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.116" defer></script>
<script src="js/item_render_helpers.js?v=39.15.116" defer></script>
<script src="js/item_render_runtime.js?v=39.15.116" defer></script>
<script src="js/burn_motion_util.js?v=39.15.116" defer></script>
<script src="js/ants.js?v=39.15.116" defer></script>
<script src="js/health.js?v=39.15.116" defer></script>
<script src="js/tarinai.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.116" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.116" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.116" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.116" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.116" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.116" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.116" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.116" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.116" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.116" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.116" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.116" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.116" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.116" defer></script>
<script src="js/tarinai_render.js?v=39.15.116" defer></script>
<script src="js/world.js?v=39.15.116" defer></script>
<script src="js/world_view.js?v=39.15.116" defer></script>
<script src="js/family_graph.js?v=39.15.116" defer></script>
<script src="js/world_reset_presets.js?v=39.15.116" defer></script>
<script src="js/world_family_social.js?v=39.15.116" defer></script>
<script src="js/impact_core_system.js?v=39.15.116" defer></script>
<script src="js/impact_response_system.js?v=39.15.116" defer></script>
<script src="js/collision_response_system.js?v=39.15.116" defer></script>
<script src="js/world_combat_effects.js?v=39.15.116" defer></script>
<script src="js/world_environment.js?v=39.15.116" defer></script>
<script src="js/world_temperature_system.js?v=39.15.116" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.116" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.116" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.116" defer></script>
<script src="js/world_ants_system.js?v=39.15.116" defer></script>
<script src="js/weather_system.js?v=39.15.116" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.116" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.116" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.116" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.116" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.116" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.116" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.116" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.116" defer></script>
<script src="js/simulation_systems.js?v=39.15.116" defer></script>
<script src="js/system_order.js?v=39.15.116" defer></script>
<script src="js/simulation.js?v=39.15.116" defer></script>
<script src="js/colony_situation_system.js?v=39.15.116" defer></script>
<script src="js/world_update.js?v=39.15.116" defer></script>
<script src="js/world_tool_actions.js?v=39.15.116" defer></script>
<script src="js/world_placement_log.js?v=39.15.116" defer></script>
<script src="js/world_event_effects.js?v=39.15.116" defer></script>
<script src="js/command_dispatcher.js?v=39.15.116" defer></script>
<script src="js/text_catalog.js?v=39.15.116" defer></script>
<script src="js/ui.js?v=39.15.116" defer></script>
<script src="js/ui_log.js?v=39.15.116" defer></script>
<script src="js/ui_helpers.js?v=39.15.116" defer></script>
<script src="js/ui_selected.js?v=39.15.116" defer></script>
<script src="js/save_schema.js?v=39.15.116" defer></script>
<script src="js/snapshot_system.js?v=39.15.116" defer></script>
<script src="js/restore_coordinator.js?v=39.15.116" defer></script>
<script src="js/history_system.js?v=39.15.116" defer></script>
<script src="js/save_codec.js?v=39.15.116" defer></script>
<script src="js/save_storage.js?v=39.15.116" defer></script>
<script src="js/save_system.js?v=39.15.116" defer></script>
<script src="js/ui_tooltips.js?v=39.15.116" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.116" defer></script>
<script src="js/ui_ground.js?v=39.15.116" defer></script>
<script src="js/ui_tools.js?v=39.15.116" defer></script>
<script src="js/ui_input_shared.js?v=39.15.116" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.116" defer></script>
<script src="js/ui_input_touch.js?v=39.15.116" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.116" defer></script>
<script src="js/ui_bind.js?v=39.15.116" defer></script>
<script src="js/ui_charts.js?v=39.15.116" defer></script>
<script src="js/ui_family_data.js?v=39.15.116" defer></script>
<script src="js/ui_family_async.js?v=39.15.116" defer></script>
<script src="js/ui_family_layout.js?v=39.15.116" defer></script>
<script src="js/ui_family_paths.js?v=39.15.116" defer></script>
<script src="js/ui_family_render.js?v=39.15.116" defer></script>
<script src="js/main.js?v=39.15.116" defer></script>
<script src="js/debug_tools.js?v=39.15.116" defer></script>
<script src="js/version.js?v=39.15.90" defer></script>
<script src="js/event_bus.js?v=39.15.90" defer></script>
<script src="js/registry_base.js?v=39.15.90" defer></script>
<script src="js/disease_registry.js?v=39.15.90" defer></script>
<script src="js/sound_pack.js?v=39.15.90" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.90" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.90" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.90" defer></script>
<script src="js/item_food_definitions.js?v=39.15.90" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.90" defer></script>
<script src="js/item_registry.js?v=39.15.90" defer></script>
<script src="js/data.js?v=39.15.90" defer></script>
<script src="js/ground_types.js?v=39.15.90" defer></script>
<script src="js/input_mode_manager.js?v=39.15.90" defer></script>
<script src="js/math.js?v=39.15.90" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.90" defer></script>
<script src="js/physics_helpers.js?v=39.15.90" defer></script>
<script src="js/placement_preview_system.js?v=39.15.90" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.90" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.90" defer></script>
<script src="js/mechanical_system.js?v=39.15.90" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.90" defer></script>
<script src="js/constraint_system.js?v=39.15.90" defer></script>
<script src="js/physics_world_system.js?v=39.15.90" defer></script>
<script src="js/physics_projection_system.js?v=39.15.90" defer></script>
<script src="js/assets.js?v=39.15.90" defer></script>
<script src="js/audio.js?v=39.15.90" defer></script>
<script src="js/perf_profiler.js?v=39.15.90" defer></script>
<script src="js/render.js?v=39.15.90" defer></script>
<script src="js/sim_core.js?v=39.15.90" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.90" defer></script>
<script src="js/structures.js?v=39.15.90" defer></script>
<script src="js/items.js?v=39.15.90" defer></script>
<script src="js/item_type_initializers.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.90" defer></script>
<script src="js/item_update_policy.js?v=39.15.90" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.90" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.90" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.90" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.90" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.90" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.90" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.90" defer></script>
<script src="js/item_runtime.js?v=39.15.90" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.90" defer></script>
<script src="js/item_render_runtime.js?v=39.15.90" defer></script>
<script src="js/ants.js?v=39.15.90" defer></script>
<script src="js/health.js?v=39.15.90" defer></script>
<script src="js/tarinai.js?v=39.15.90" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.90" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.90" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.90" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.90" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.90" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.90" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.90" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.90" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.90" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.90" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.90" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.90" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.90" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.90" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.90" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.90" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.90" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.90" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.90" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.90" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.90" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.90" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.90" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.90" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.90" defer></script>
<script src="js/tarinai_render.js?v=39.15.90" defer></script>
<script src="js/world.js?v=39.15.90" defer></script>
<script src="js/world_view.js?v=39.15.90" defer></script>
<script src="js/family_graph.js?v=39.15.90" defer></script>
<script src="js/world_family_social.js?v=39.15.90" defer></script>
<script src="js/impact_core_system.js?v=39.15.90" defer></script>
<script src="js/impact_response_system.js?v=39.15.90" defer></script>
<script src="js/collision_response_system.js?v=39.15.90" defer></script>
<script src="js/world_combat_effects.js?v=39.15.90" defer></script>
<script src="js/world_environment.js?v=39.15.90" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.90" defer></script>
<script src="js/world_ants_system.js?v=39.15.90" defer></script>
<script src="js/weather_system.js?v=39.15.90" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.90" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.90" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.90" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.90" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.90" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.90" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.90" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.90" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.90" defer></script>
<script src="js/simulation_systems.js?v=39.15.90" defer></script>
<script src="js/system_order.js?v=39.15.90" defer></script>
<script src="js/simulation.js?v=39.15.90" defer></script>
<script src="js/colony_situation_system.js?v=39.15.90" defer></script>
<script src="js/world_update.js?v=39.15.90" defer></script>
<script src="js/world_tool_actions.js?v=39.15.90" defer></script>
<script src="js/world_placement_log.js?v=39.15.90" defer></script>
<script src="js/world_event_effects.js?v=39.15.90" defer></script>
<script src="js/command_dispatcher.js?v=39.15.90" defer></script>
<script src="js/text_catalog.js?v=39.15.90" defer></script>
<script src="js/ui.js?v=39.15.90" defer></script>
<script src="js/ui_log.js?v=39.15.90" defer></script>
<script src="js/ui_helpers.js?v=39.15.90" defer></script>
<script src="js/ui_selected.js?v=39.15.90" defer></script>
<script src="js/save_schema.js?v=39.15.90" defer></script>
<script src="js/snapshot_system.js?v=39.15.90" defer></script>
<script src="js/restore_coordinator.js?v=39.15.90" defer></script>
<script src="js/history_system.js?v=39.15.90" defer></script>
<script src="js/save_codec.js?v=39.15.90" defer></script>
<script src="js/save_storage.js?v=39.15.90" defer></script>
<script src="js/save_system.js?v=39.15.90" defer></script>
<script src="js/ui_tooltips.js?v=39.15.90" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.90" defer></script>
<script src="js/ui_ground.js?v=39.15.90" defer></script>
<script src="js/ui_tools.js?v=39.15.90" defer></script>
<script src="js/ui_input_shared.js?v=39.15.90" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.90" defer></script>
<script src="js/ui_input_touch.js?v=39.15.90" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.90" defer></script>
<script src="js/ui_bind.js?v=39.15.90" defer></script>
<script src="js/ui_charts.js?v=39.15.90" defer></script>
<script src="js/ui_family_data.js?v=39.15.90" defer></script>
<script src="js/ui_family_async.js?v=39.15.90" defer></script>
<script src="js/ui_family_layout.js?v=39.15.90" defer></script>
<script src="js/ui_family_paths.js?v=39.15.90" defer></script>
<script src="js/ui_family_render.js?v=39.15.90" defer></script>
<script src="js/main.js?v=39.15.90" defer></script>
<script src="js/debug_tools.js?v=39.15.90" defer></script>
</body>
</html>

View file

@ -23,8 +23,6 @@ function buildAntWorkerPool(count = ANT_NEST_START_COUNT) {
return Array.from({ length: n }, () => ANT_WORKER_HP);
}
function sanitizeAntWorkerPool(list, fallbackCount = 0) {
if (!Array.isArray(list)) return buildAntWorkerPool(fallbackCount);
const out = list.map(v => clampAntHp(v)).filter(v => v > 0.01);
@ -84,84 +82,6 @@ class AntActor {
this.carryLogAt = opts.carryLogAt || -999;
this.foundingAttempts = opts.foundingAttempts || 0;
this.dead = Boolean(opts.dead);
this.burning = Boolean(opts.burning);
this.burnTimer = Number.isFinite(opts.burnTimer) ? opts.burnTimer : 0;
this.burnTurnTimer = 0;
}
igniteFire(source = null) {
if (this.dead) return false;
this.burning = true;
this.burnTimer = Math.max(this.burnTimer || 0, 4.2 + rand(0, 2.4));
this.state = "panic";
this.targetId = "";
this.targetToken = 0;
this.targetRef = null;
this.burnWanderAngle = rand(0, Math.PI * 2);
this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.0);
this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(10, this.r * 2.8), life: 0.18, color: "rgba(255,110,36,0.56)" }));
this.world?.emit?.("fire:ignite-ant", { ant: this, source });
return true;
}
extinguishFire(reason = "") {
if (!this.burning && !(this.burnTimer > 0)) return false;
this.burning = false;
this.burnTimer = 0;
this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(9, this.r * 2.2), life: 0.16, color: "rgba(108,192,236,0.58)" }));
return true;
}
updateBurning(dt) {
if (!this.burning && !(this.burnTimer > 0)) return false;
this.burning = true;
this.burnTimer = Math.max(0, Number(this.burnTimer || 0) - dt);
this.hp = Math.max(0, Number(this.hp || 0) - dt * (this.kind === "queen" ? 11 : 7.5));
this.hpBarTimer = Math.max(this.hpBarTimer || 0, 0.55);
const water = globalThis.TarinaiItemDynamicToolSystem?.contactWaterDrop?.(this.world, this.x, this.y, Math.max(this.r || 5, 7));
if (water) {
globalThis.TarinaiItemDynamicToolSystem?.consumeWaterForFire?.(this.world, water, 5);
this.extinguishFire("water");
return true;
}
const mul = globalThis.TarinaiItemDynamicToolSystem?.fireTempMultiplier?.(this.world, this.x, this.y, this) || 1;
if (deterministicChance(this.world, "ant-fire-extinguish", dt * 0.020 * mul, this)) {
this.extinguishFire("natural");
return true;
}
globalThis.TarinaiBurnMotionUtil.updateBurnWanderAngle(this, dt, {
angleKey: "ant-burn-angle",
turnKey: "ant-burn-turn",
jitterKey: "ant-burn-jitter",
bucketScale: 6,
turnMin: 0.08,
turnMax: 0.28,
jitterMin: -9.0,
jitterMax: 9.0,
});
const speed = this.kind === "queen" ? ANT_QUEEN_SPEED * 3.0 : ANT_WORKER_RETURN_SPEED * 3.3;
this.vx = Math.cos(this.burnWanderAngle || 0) * speed;
this.vy = Math.sin(this.burnWanderAngle || 0) * speed;
this.x = clamp(this.x + this.vx * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
this.y = clamp(this.y + this.vy * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
for (const viewer of [...(this.world?.nearbyTarinai?.(this.x, this.y, 170, true) || [])]) {
if (!viewer || viewer.dead) continue;
globalThis.TarinaiItemDynamicToolSystem?.panicTarinaiFromFire?.(viewer, this, this.world);
}
const contactRadius = Math.max(10, (this.r || 5) * 2.1);
for (const t of [...(this.world?.nearbyTarinai?.(this.x, this.y, contactRadius + 26, true) || [])]) {
if (!t || t.dead) continue;
if (distXY(this.x, this.y, t.x, t.y) <= contactRadius + Math.max(t.radius || 18, 12) * 0.7) t.igniteFire?.(this, false);
}
for (const it of [...(this.world?.nearbyItems?.(this.x, this.y, contactRadius + 28, true) || [])]) {
if (!it || it.dead || it.type !== "grass") continue;
if (distXY(this.x, this.y, it.x, it.y) <= contactRadius + Math.max(it.r || 16, 12)) globalThis.TarinaiItemDynamicToolSystem?.igniteGrassByFire?.(it, this, this.world, dt);
}
if (deterministicChance(this.world, "ant-fire-effect", dt * 2.0, this)) {
this.world?.effects?.push(new Effect("flame", this.x, this.y - this.r, { vx: rand(-2, 2), vy: rand(-14, -6), size: Math.max(4, this.r * 1.0), life: 0.22, color: "rgba(236,46,24,0.72)" }));
}
if (this.burnTimer <= 0.01) this.extinguishFire("timeout");
return true;
}
homeNest() {
@ -260,10 +180,7 @@ class AntActor {
if (this.dead) return;
const hpBefore = this.hp;
this.age += dt;
if (this.burning || (this.burnTimer || 0) > 0.02) {
this.updateBurning(dt);
if (this.hp <= 0) this.dieAsCorpse(this.homeNest?.() || null);
} else if (this.kind === "queen") this.updateQueen(dt);
if (this.kind === "queen") this.updateQueen(dt);
else this.updateWorker(dt);
if (Number.isFinite(hpBefore) && this.hp < hpBefore - 0.01) this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.25);
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
@ -418,6 +335,7 @@ class AntActor {
this.world?.emit?.("ant:died", { ant: this, home, reason: "hp" });
const corpse = new Item("ant_corpse", this.x, this.y);
corpse.amount = 24;
corpse.workerSprite = this.workerSpriteId();
this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse);
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
if (home) syncAntNestCount(this.world, home);
@ -526,19 +444,6 @@ class AntActor {
ctx.arc(this.r * 1.15, -this.r * 0.05, this.r * 0.46, 0, Math.PI * 2);
ctx.fill();
}
if (this.burning || (this.burnTimer || 0) > 0.02) {
const drawFire = typeof drawSimpleFireShape === "function" ? drawSimpleFireShape : null;
if (drawFire) {
const phase = this.seed + this.age * 8.0;
drawFire(ctx, -this.r * 0.10, -this.r * 0.86, Math.max(5, this.r * 1.05), { phase, alpha: 0.86, shadow: false });
drawFire(ctx, this.r * 0.54, -this.r * 0.48, Math.max(4, this.r * 0.78), { phase: phase + 1.5, alpha: 0.72, shadow: false });
} else {
ctx.fillStyle = "rgba(236,46,24,0.78)";
ctx.beginPath();
ctx.ellipse(0, -this.r * 0.8, this.r * 0.42, this.r * 0.86, 0, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
this.drawHealthBar(ctx);
}

View file

@ -280,8 +280,8 @@ const audio = {
},
bubble(text) {
const s = String(text || "");
if (s.includes("\u3076\u308B\u3076\u308B")) return this.playSample("voice_temp_cold", 1.20, "voice") || this.play("voice_temp_cold", { category: "voice", minGap: 1.20 });
if (s.includes("\u3042\u3061\u3085\u3044")) return this.playSample("voice_temp_hot", 1.20, "voice") || this.play("voice_temp_hot", { category: "voice", minGap: 1.20 });
if (s.includes("ぶるぶる")) return this.playSample("voice_temp_cold", 1.20, "voice") || this.play("voice_temp_cold", { category: "voice", minGap: 1.20 });
if (s.includes("あちゅい")) return this.playSample("voice_temp_hot", 1.20, "voice") || this.play("voice_temp_hot", { category: "voice", minGap: 1.20 });
if (s.includes("\u3082\u3050")) return this.playSample("sfx_eat", 0.20, "voice") || this.play("sfx_eat", { category: "voice", minGap: 0.20 });
if (s.includes("\u3076\u308a")) return this.playSample("voice_poop", 0.28, "voice") || this.play("sfx_zunchi", { category: "ops", minGap: 0.28 });
if (s.includes("\u306f\u3063")) return this.playSample("voice_stress", 0.80, "voice") || this.play("sfx_damage_soft", { category: "voice", minGap: 0.80 });
@ -307,7 +307,7 @@ const audio = {
if (type === "firecracker") return this.play("sfx_firecracker_fuse", { category: "ops", minGap: 0.18 });
if (type === "grass") return this.play("sfx_grass", { category: "ops", minGap: 0.08 });
if (type === "zunchi") return this.play("sfx_zunchi", { category: "ops", minGap: 0.12 });
if (type === "ant_nest" || type === "nest_box" || type === "pipe") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 });
if (type === "ant_nest" || type === "nest_box") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 });
return this.play("sfx_place", { category: "ops", minGap: 0.08 });
},
delete() { this.play("sfx_delete", { category: "ops", minGap: 0.08 }); },

View file

@ -1,20 +0,0 @@
"use strict";
// Layer: shared-runtime/motion
// Shared deterministic panic wander used by burning creatures.
(function (global) {
function updateBurnWanderAngle(entity, dt, opts = {}) {
const worldRef = entity?.world || null;
entity.burnTurnTimer = Math.max(0, Number(entity.burnTurnTimer || 0) - dt);
if (entity.burnTurnTimer <= 0) {
const bucket = Math.floor((worldRef?.time || 0) * (opts.bucketScale || 5));
entity.burnWanderAngle = deterministicAngle(worldRef, opts.angleKey || "burn-angle", entity, bucket);
entity.burnTurnTimer = deterministicRange(worldRef, opts.turnKey || "burn-turn", opts.turnMin ?? 0.10, opts.turnMax ?? 0.34, entity);
} else {
entity.burnWanderAngle += deterministicRange(worldRef, opts.jitterKey || "burn-jitter", opts.jitterMin ?? -8.0, opts.jitterMax ?? 8.0, entity) * dt;
}
return entity.burnWanderAngle || 0;
}
global.TarinaiBurnMotionUtil = Object.freeze({ updateBurnWanderAngle });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -5,18 +5,129 @@
// and item footprint checks. This keeps placement, grabbing, and physics using
// the same current-frame shape instead of separate radius approximations.
(function (global) {
const Geometry = global.TarinaiGeometry;
const num = Geometry.num;
const rectLocalPoint = Geometry.rectLocalPoint;
const rectWorldVector = Geometry.rectWorldVector;
const rectCorners = Geometry.rectCorners;
const rectAxes = Geometry.rectAxes;
const projectPoints = Geometry.projectPoints;
const aabbOverlap = Geometry.aabbOverlap;
const rectOverlapInfo = Geometry.rectOverlapInfo;
const rectsOverlap = Geometry.rectsOverlap;
function num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function rectAabb(rect) {
if (!rect) return null;
if (!rect.oriented) return rect;
return {
left: num(rect.left), right: num(rect.right), top: num(rect.top), bottom: num(rect.bottom),
cx: num(rect.cx), cy: num(rect.cy), halfW: num(rect.halfW), halfH: num(rect.halfH),
angle: num(rect.angle), cos: Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle)),
sin: Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle)), oriented: true,
type: rect.type, item: rect.item,
};
}
function rectLocalPoint(rect, x, y) {
const dx = num(x) - num(rect?.cx);
const dy = num(y) - num(rect?.cy);
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
function rectWorldVector(rect, x, y) {
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: num(x) * c - num(y) * s, y: num(x) * s + num(y) * c };
}
function pointInRect(x, y, rect, padding = 0) {
return Geometry.pointInRect(x, y, rect, padding);
if (!rect) return false;
const pad = Math.max(0, num(padding));
if (rect.oriented) {
const local = rectLocalPoint(rect, x, y);
return local.x >= -num(rect.halfW) - pad && local.x <= num(rect.halfW) + pad
&& local.y >= -num(rect.halfH) - pad && local.y <= num(rect.halfH) + pad;
}
return num(x) >= num(rect.left) - pad && num(x) <= num(rect.right) + pad
&& num(y) >= num(rect.top) - pad && num(y) <= num(rect.bottom) + pad;
}
function rectCorners(rect, padding = 0) {
const r = rectAabb(rect);
const pad = Math.max(0, num(padding));
if (!r) return [];
if (!r.oriented) {
return [
{ x: num(r.left) - pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.bottom) + pad },
{ x: num(r.left) - pad, y: num(r.bottom) + pad },
];
}
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(num(r.angle));
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(num(r.angle));
const hw = num(r.halfW) + pad;
const hh = num(r.halfH) + pad;
const out = [];
for (const [lx, ly] of [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]) {
out.push({ x: num(r.cx) + lx * c - ly * s, y: num(r.cy) + lx * s + ly * c });
}
return out;
}
function rectAxes(rect) {
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
const s = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
return [{ x: c, y: s }, { x: -s, y: c }];
}
function projectPoints(points, axis) {
let min = Infinity;
let max = -Infinity;
for (const p of points || []) {
const v = num(p.x) * axis.x + num(p.y) * axis.y;
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
function aabbOverlap(a, b, padding = 0) {
const pad = Math.max(0, num(padding));
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
}
function rectOverlapInfo(a, b, padding = 0) {
if (!aabbOverlap(a, b, padding)) return null;
const ptsA = rectCorners(a, padding);
const ptsB = rectCorners(b, padding);
if (!ptsA.length || !ptsB.length) return null;
let bestOverlap = Infinity;
let bestAxis = null;
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
const len = Math.hypot(axis.x, axis.y) || 1;
const n = { x: axis.x / len, y: axis.y / len };
const pa = projectPoints(ptsA, n);
const pb = projectPoints(ptsB, n);
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
if (overlap <= 0) return null;
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
}
if (!bestAxis) return null;
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
return {
overlap: bestOverlap,
nx: bestAxis.x,
ny: bestAxis.y,
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
};
}
function rectsOverlap(a, b, margin = 0) {
return Boolean(rectOverlapInfo(a, b, margin));
}
function rectCircleOverlap(rect, cx, cy, radius, margin = 0) {
@ -48,7 +159,7 @@
}
const radiusFor = typeof global.itemRadiusFor === "function" ? global.itemRadiusFor : ((type, fallback = 12) => fallback);
if (item.type === "duplicator") {
// \u4FDD\u5B58\u6E08\u307F\u30C7\u30FC\u30BF\u306B\u53E4\u3044 r=34 \u304C\u6B8B\u3063\u3066\u3044\u3066\u3082\u3001\u914D\u7F6E\u5224\u5B9A\u306F\u5916\u89B3\u306B\u8FD1\u3044\u534A\u5F84\u3067\u6271\u3046\u3002
// 保存済みデータに古い r=34 が残っていても、配置判定は外観に近い半径で扱う。
return Math.max(14, Math.min(24, num(item.r, radiusFor(item.type, 24)) || radiusFor(item.type, 24)));
}
return Math.max(14, num(item.r, radiusFor(item.type, 12)) || radiusFor(item.type, 12));
@ -85,7 +196,7 @@
if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true;
} else if (!rects.length) {
const baseRadius = item.type === "duplicator" ? Math.min(24, num(item.r, 24) || 24) : num(item.r, 12);
const defaultMul = (item.type === "ball" || item.type === "balloon") ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2);
const defaultMul = item.type === "ball" ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2);
const mul = num(opts.radiusMultiplier, defaultMul);
const r = Math.max(item.type === "duplicator" ? 18 : 24, baseRadius * mul);
hit = bestDistance <= r + padding;
@ -95,7 +206,7 @@
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]);
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "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" || isFenceItemType(item.type));
if (!important.has(item.type) && !isPhysicsItem) return false;
const itemRects = placementRectsFor(worldRef, item);
@ -129,17 +240,11 @@
}
global.TarinaiCollisionFootprints = Object.freeze({
rectCorners,
rectAxes,
projectPoints,
aabbOverlap,
rectOverlapInfo,
rectLocalPoint,
rectWorldVector,
pointInRect,
rectsOverlap,
rectCircleOverlap,
isSoftPlacementIgnoredType,
itemFootprintRadius,
placementRectsFor,
hitTestItem,

View file

@ -6,35 +6,22 @@
const physicalDamageFromImpactSpeed = ImpactCore.physicalDamageFromImpactSpeed;
const itemHasAttachedLink = ImpactCore.itemHasAttachedLink;
function isBallLikeType(type = "") { return type === "ball" || type === "balloon"; }
function ballLikeCount(worldRef) { return (worldRef?.itemCounts?.ball || 0) + (worldRef?.itemCounts?.balloon || 0); }
function ballLikeItems(worldRef) {
if (!worldRef) return [];
if (worldRef.itemsOfType) {
const balls = worldRef.itemsOfType("ball") || [];
const balloons = worldRef.itemsOfType("balloon") || [];
return balls.concat(balloons);
}
return (worldRef.items || []).filter(it => it && !it.dead && isBallLikeType(it.type));
}
function ballLikeLabel(item) { return item?.type === "balloon" ? "水風船" : "ボール"; }
function resolveBallBallCollisions(worldRef, dt) {
if (!worldRef) return undefined;
if (ballLikeCount(worldRef) < 2) return;
if ((worldRef.itemCounts?.ball || 0) < 2) return;
const now = worldRef.time || 0;
if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map();
const balls = ballLikeItems(worldRef);
const balls = worldRef.itemsOfType?.("ball") || (worldRef.items || []).filter(it => it && !it.dead && it.type === "ball");
for (const a of balls) {
if (!a || a.dead || !isBallLikeType(a.type)) continue;
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 worldRef.nearbyItems(a.x, a.y, range) || []) {
if (!b || b === a || b.dead || !isBallLikeType(b.type)) continue;
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 - (worldRef.ballCollisionMemo.get(key) || -999) < 0.055) continue;
const br = b.r || 18;
@ -88,7 +75,7 @@
const relNormal = rvx * nx + rvy * ny;
if (relNormal > 0 && d >= hitRadius - 0.5) continue;
worldRef.ballCollisionMemo.set(key, now);
const restitution = (a.type === "balloon" || b.type === "balloon") ? 0.58 : 0.86;
const restitution = 0.86;
const impulse = -(1 + restitution) * relNormal / 2;
if (Number.isFinite(impulse)) {
a.vx = (a.vx || 0) - impulse * nx;
@ -115,9 +102,9 @@
function resolveBallInteractions(worldRef, dt) {
if (!worldRef) return undefined;
if (ballLikeCount(worldRef) < 1) return;
for (const ball of ballLikeItems(worldRef)) {
if (!ball || ball.dead || !isBallLikeType(ball.type)) continue;
if (!worldRef.itemCounts?.ball) return;
for (const ball of (worldRef.itemsOfType ? worldRef.itemsOfType("ball") : worldRef.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;
@ -140,7 +127,7 @@
if (d > hitDistance) continue;
const now = worldRef.time || 0;
const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18;
if (repeat && ballSpeed < (ball.type === "balloon" ? 180 : 300)) continue;
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) {
@ -153,7 +140,7 @@
}
}
const transferredPins = ball.type === "ball" ? (globalThis.TarinaiItemDynamicPinSystem.transferBallPinsToTarinai(ball, t, worldRef) || 0) : 0;
const transferredPins = globalThis.TarinaiItemDynamicPinSystem.transferBallPinsToTarinai(ball, t, worldRef) || 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));
@ -163,7 +150,7 @@
ball.lastKickedAt = now;
ball.lastKickerId = t.id;
worldRef.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 (worldRef.relationNotice(t.id, ball.id || "ball", "ball-pin-transfer", 1.2)) worldRef.log(`${t.name}\u306B\u30DC\u30FC\u30EB\u304B\u3089\u92F2\u304C\u79FB\u3063\u305F\u3002`, "accident", { participants: [t] });
if (worldRef.relationNotice(t.id, ball.id || "ball", "ball-pin-transfer", 1.2)) worldRef.log(`${t.name}にボールから鋲が移った。`, "accident", { participants: [t] });
continue;
}
@ -198,9 +185,7 @@
continue;
}
const isBalloon = ball.type === "balloon";
const objectLabel = ballLikeLabel(ball);
const isDangerous = !isBalloon && ballSpeed >= 285;
const isDangerous = ballSpeed >= 285;
if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) {
const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
const damage = physicalDamageFromImpactSpeed(ballSpeed, { min: 3.5, max: 38, scale: 18 });
@ -238,10 +223,9 @@
const relativeSpeed = Math.hypot(relVx, relVy);
const playIntent = t.state === "play_ball" || t.target === ball;
const playful = t.shouldApplyPersonalityBehavior?.("openness", 1);
const impulseBase = isBalloon ? 34 : 48;
const impulse = clamp(impulseBase + relativeSpeed * (isBalloon ? 0.48 : 0.72) + (playIntent ? (isBalloon ? 34 : 52) : 0) + (playful ? (isBalloon ? 18 : 24) : 0), impulseBase, isDangerous ? 360 : (isBalloon ? 165 : 260));
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + nx * impulse + (t.vx || 0) * (isBalloon ? 0.32 : 0.52);
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + ny * impulse + (t.vy || 0) * (isBalloon ? 0.32 : 0.52);
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;
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, worldRef.w - CONFIG.worldPadding);
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
@ -251,7 +235,7 @@
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 = !isBalloon && t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0;
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);
@ -262,8 +246,8 @@
t.nextPlayBubbleAt = now + deterministicRange(worldRef, "ball-play-bubble-delay", 2.0, 3.8, ball, t);
worldRef.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)");
}
if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}${objectLabel}を追いかけて弾いた。`, "observe", { participants: [t] });
worldRef.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: isBalloon ? "rgba(248,122,166,0.42)" : "rgba(170,210,95,0.50)" }));
if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] });
worldRef.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)" }));
}
}

View file

@ -81,7 +81,7 @@
}
function evaluate(worldRef, force = false) {
const now = worldRef?.time || 0;
if (!worldRef) return { id: "relaxed", label: "\u5B89\u5B9A", effects: { personality: {} } };
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);

View file

@ -139,8 +139,7 @@
return found || null;
}
let found = worldRef.itemById?.(endpoint.id) || null;
if (found && (found.dead || (Number.isFinite(Number(found.amount)) && Number(found.amount) <= 0))) found = null;
if (!found && endpoint.id != null) found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id && !(Number.isFinite(Number(it.amount)) && Number(it.amount) <= 0));
if (!found && endpoint.id != null) found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id);
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
const source = worldRef.nearbyItems?.(endpoint.x, endpoint.y, 120, true) || worldRef.items || [];
found = source.find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8));
@ -314,16 +313,19 @@
function endpointCanRemainAnchored(endpoint, point) {
if (!endpoint || !point || !point.obj || point.obj.dead) return false;
if (Number.isFinite(Number(point.obj.amount)) && Number(point.obj.amount) <= 0) return false;
return true;
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;
if (obj.radius && obj.name) return 1.0;
if (obj.type === "balloon") return 0.22;
if (obj.type === "ball") return 0.7;
if (obj.type === "zunchi") return 0.55;
if (obj.type === "rope" || obj.type === "rod") return 1.6;
@ -333,7 +335,7 @@
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" || obj.type === "pipe")) return obj.type.includes("fence") ? Infinity : 5.5;
if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5;
return 2.2;
}
@ -484,15 +486,18 @@
}
function ropeLodSettings(worldRef, length) {
const tier = global.TarinaiPerf.renderQualityTier() || "high";
const ropeCount = Number(worldRef?.itemCounts?.rope || 0) || 0;
let spacing = 34;
let maxParticles = 24;
let iterations = 6;
let particleStride = 1;
if (tier === "mid") { spacing = 42; maxParticles = 20; iterations = 5; }
else if (tier === "low") { spacing = 56; maxParticles = 15; iterations = 4; particleStride = 2; }
if (ropeCount > 12) { spacing *= 1.18; maxParticles = Math.max(10, maxParticles - 4); iterations = Math.max(3, iterations - 1); }
if (ropeCount > 24) { spacing *= 1.28; maxParticles = Math.max(8, maxParticles - 4); iterations = Math.max(3, iterations - 1); particleStride = 2; }
const targetCount = Math.max(4, Math.min(maxParticles, Math.ceil(Math.max(24, length) / spacing) + 1));
return { ropeCount, spacing, maxParticles, iterations, particleStride, targetCount };
return { tier, ropeCount, spacing, maxParticles, iterations, particleStride, targetCount };
}
function ensureRopeParticles(item, a, b, length, worldRef = null) {
@ -534,11 +539,13 @@
}
function rectLocalPointForConstraint(r, x, y) {
return global.TarinaiGeometry.rectLocalPoint(r, x, y);
return global.TarinaiCollisionFootprints.rectLocalPoint(r, x, y);
}
function rectWorldNormalForConstraint(r, nx, ny) {
return global.TarinaiGeometry.rectWorldNormal(r, nx, ny);
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
return { x: nx * c - ny * s, y: nx * s + ny * c };
}
function pushRopeParticleOutOfRect(p, rect, radius) {
@ -680,7 +687,8 @@
const getRopeObstacleRects = () => {
if (ropeObstacleRects) return ropeObstacleRects;
if (!worldRef?.nearbySolidObstacleRects) return (ropeObstacleRects = []);
ropeObstacleRects = worldRef.nearbySolidObstacleRects(ropeCx, ropeCy, ropeQueryRadius + 64, { maxChecks: 38 }) || [];
const maxChecks = lod.tier === "low" ? 18 : (lod.tier === "mid" ? 28 : 38);
ropeObstacleRects = worldRef.nearbySolidObstacleRects(ropeCx, ropeCy, ropeQueryRadius + 64, { maxChecks }) || [];
return ropeObstacleRects;
};
const collideParticles = () => {
@ -712,7 +720,7 @@
for (let iter = 0; iter < lod.iterations; iter += 1) {
ps[0].x = a.x; ps[0].y = a.y;
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
const shouldCollide = iter === 2 || iter === lod.iterations - 1;
const shouldCollide = lod.tier === "low" ? iter === 1 : (lod.tier === "mid" ? iter === 2 : (iter === 2 || iter === lod.iterations - 1));
if (shouldCollide) changed = collideParticles() || changed;
for (let i = 0; i < ps.length - 1; i += 1) {
const p1 = ps[i], p2 = ps[i + 1];
@ -755,8 +763,8 @@
return changed;
}
function beginLinkUpdate(item, worldRef, expectedType) {
if (!item || item.dead || item.type !== expectedType) return null;
function updateFlexibleLink(item, dt, worldRef) {
if (!item || item.dead || item.type !== "rope") return false;
item.amount = 999;
item.world = worldRef || item.world || null;
const a = endpointWorld(ep(item, 0), worldRef);
@ -764,26 +772,11 @@
if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) {
item.amount = 0;
if (worldRef) worldRef.drawListDirty = true;
return { detached: true };
return true;
}
return { a, b, x: Number(item.x || 0) || 0, y: Number(item.y || 0) || 0, r: Number(item.r || item.radius || 0) || 0 };
}
function finishLinkSpatialUpdate(item, worldRef, before, reason, threshold, minInterval) {
if (!worldRef || !before) return false;
worldRef.drawListDirty = true;
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - before.x, (Number(item.y || 0) || 0) - before.y) > threshold
|| Math.abs((Number(item.r || item.radius || 0) || 0) - before.r) > threshold;
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, reason, threshold, minInterval);
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
return movedForSpatial;
}
function updateFlexibleLink(item, dt, worldRef) {
const before = beginLinkUpdate(item, worldRef, "rope");
if (!before) return false;
if (before.detached) return true;
const { a, b } = before;
const beforeSpatialX = Number(item.x || 0) || 0;
const beforeSpatialY = Number(item.y || 0) || 0;
const beforeSpatialR = Number(item.r || item.radius || 0) || 0;
let length = primeLinkState(item, a, b);
let changed = applyDistanceConstraint(item, a, b, dt, length, "rope");
const aa = changed ? endpointWorld(ep(item, 0), worldRef) : a;
@ -795,15 +788,30 @@
}
length = primeLinkState(item, aa, bb);
changed = solveRopeParticleChain(item, aa, bb, dt, length, worldRef) || changed;
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rope-tension", 0.38, 0.085);
if (changed && worldRef) {
worldRef.drawListDirty = true;
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.38
|| Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.38;
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rope-tension", 0.38, 0.085);
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
}
return changed;
}
function updateRigidLink(item, dt, worldRef) {
const before = beginLinkUpdate(item, worldRef, "rod");
if (!before) return false;
if (before.detached) return true;
const { a, b } = before;
if (!item || item.dead || item.type !== "rod") return false;
item.amount = 999;
item.world = worldRef || item.world || null;
const a = endpointWorld(ep(item, 0), worldRef);
const b = endpointWorld(ep(item, 1), worldRef);
if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) {
item.amount = 0;
if (worldRef) worldRef.drawListDirty = true;
return true;
}
const beforeSpatialX = Number(item.x || 0) || 0;
const beforeSpatialY = Number(item.y || 0) || 0;
const beforeSpatialR = Number(item.r || item.radius || 0) || 0;
const length = primeLinkState(item, a, b);
let changed = applyDistanceConstraint(item, a, b, dt, length, "rod");
const aa = endpointWorld(ep(item, 0), worldRef) || a;
@ -828,7 +836,13 @@
ep(item, 0).x = fa.x; ep(item, 0).y = fa.y;
ep(item, 1).x = fb.x; ep(item, 1).y = fb.y;
}
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rod-constraint", 0.30, 0.070);
if (changed && worldRef) {
worldRef.drawListDirty = true;
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.30
|| Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.30;
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rod-constraint", 0.30, 0.070);
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
}
return changed;
}
@ -847,7 +861,10 @@
function endpointRuntimeStamp(endpoint, worldRef) {
const obj = resolveEndpointObject(endpoint, worldRef);
const q = (v, scale = 10) => Math.round((Number(v) || 0) * scale);
if (!obj || obj.dead || (Number.isFinite(Number(obj.amount)) && Number(obj.amount) <= 0)) {
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)}`;
@ -981,7 +998,11 @@
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
return global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
const vx = bx - ax;
const vy = by - ay;
const len2 = vx * vx + vy * vy || 1;
const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1);
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
}
const api = Object.freeze({

View file

@ -140,14 +140,13 @@
const topBehaviors = topMap(behaviorCounts, 5);
const perf = diag.perf || window.TarinaiPerf.snapshot() || {};
const metrics = perf.metrics || {};
const visual = perf.visual || {};
const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?";
const creature = diag.creatures || {};
const scheduler = diag.scheduler || {};
const terrain = diag.terrain || {};
const lines = [
`tarinai v${window.TARINAI_VERSION || "?"} debug profiler:${perf.enabled ? "on" : "off"}${Date.now() < copiedMessageUntil ? " copied" : " copy:button/select"}`,
`fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms visual effects:${visual.effects || "?"} shadows:${visual.shadows || "?"} details:${visual.details || perf.tier || "?"} dprScale:${perf.dprScale || 1}`,
`fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms quality ${perf.tier || "?"} dprScale:${perf.dprScale || 1}`,
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} items ${(w?.items || []).length} ants ${(w?.ants || []).length} effects ${(w?.effects || []).length}`,
`visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}`,
`creatures full:${creature.full ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,

View file

@ -45,83 +45,5 @@
return comps;
}
function addParentEdge(childId, parentId, known, parentsByChild, childrenByParent) {
if (!childId || !parentId || childId === parentId || !known?.has?.(childId) || !known.has(parentId)) return false;
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
parentsByChild.get(childId).add(parentId);
childrenByParent.get(parentId).add(childId);
return true;
}
function buildRelationIndex(family = {}) {
const known = new Set(Object.keys(family || {}));
const parentsByChild = new Map();
const childrenByParent = new Map();
const addParent = (childId, parentId) => addParentEdge(childId, parentId, known, parentsByChild, childrenByParent);
for (const n of Object.values(family || {})) {
if (!n?.id) continue;
for (const parentId of n.parents || []) addParent(n.id, parentId);
for (const childId of n.children || []) addParent(childId, n.id);
}
return { parentsByChild, childrenByParent };
}
function relationAllowsParentChild(family = {}, childId, parentId) {
const child = family?.[childId];
const parent = family?.[parentId];
if (!child || !parent) return false;
const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId);
const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId);
return childListsParent || parentListsChild;
}
function relatedNodesFromIndex(family = {}, relationIndex = buildRelationIndex(family)) {
const relatedIds = new Set();
for (const n of Object.values(family || {})) {
if (!n?.id) continue;
const parents = relationIndex.parentsByChild.get(n.id) || new Set();
const children = relationIndex.childrenByParent.get(n.id) || new Set();
if (parents.size || children.size) relatedIds.add(n.id);
for (const id of parents) relatedIds.add(id);
for (const id of children) relatedIds.add(id);
}
return Array.from(relatedIds).map(id => family[id]).filter(Boolean);
}
function buildLineageGraph(family = {}, nodes = relatedNodesFromIndex(family), relationIndex = buildRelationIndex(family)) {
const graph = new Map();
for (const n of nodes || []) if (n?.id) graph.set(n.id, new Set());
const addGraphEdge = (a, b) => {
if (!a || !b || !graph.has(a) || !graph.has(b)) return;
graph.get(a).add(b);
graph.get(b).add(a);
};
for (const n of nodes || []) {
if (!n?.id) continue;
const parentCandidates = new Set((n.parents || []).filter(Boolean));
for (const parentId of relationIndex.parentsByChild.get(n.id) || []) parentCandidates.add(parentId);
for (const parentId of parentCandidates) {
if (parentId !== n.id && relationAllowsParentChild(family, n.id, parentId)) addGraphEdge(n.id, parentId);
}
const childCandidates = new Set((n.children || []).filter(Boolean));
for (const childId of relationIndex.childrenByParent.get(n.id) || []) childCandidates.add(childId);
for (const childId of childCandidates) {
if (childId !== n.id && relationAllowsParentChild(family, childId, n.id)) addGraphEdge(n.id, childId);
}
}
return graph;
}
global.TarinaiFamilyGraph = {
connectedComponents,
addParentEdge,
buildRelationIndex,
buildLineageGraph,
relatedNodesFromIndex,
relationAllowsParentChild,
};
global.TarinaiFamilyGraph = { connectedComponents };
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,469 +0,0 @@
"use strict";
// Runtime behavior for fire, firecracker, flame spread, and fire contact effects.
(function (global) {
function updateFirecracker(item, dt, worldRef) {
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
if (item.fuseTimer <= 0) {
if (worldRef?.explodeFirecracker) worldRef.explodeFirecracker(item);
else item.amount = 0;
}
}
const FIRE_BURNING_TEXT = "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
const FIRE_SEEN_TEXT = "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002";
const FIRE_CONTACT_INTERVAL = 0.35;
const FIRE_PANIC_INTERVAL = 1.20;
const FIRE_WATER_INTERVAL = 0.16;
const FIRE_CLUSTER_SCAN_INTERVAL = 0.45;
const FIRE_INTERACTION_BUDGET_DESKTOP = 18;
const FIRE_INTERACTION_BUDGET_MOBILE = 10;
const FIRE_MAX_ACTIVE_DESKTOP = 72;
const FIRE_MAX_ACTIVE_MOBILE = 44;
const GRASS_FIRE_EXTINGUISH_RATE = 0.56;
function fireTempMultiplier(worldRef, x, y, entity = null) {
const weather = String(worldRef?.weather || "");
const temp = Number(worldRef?.temperatureAt?.(x, y, entity) ?? worldRef?.feltTemperatureFor?.(entity) ?? worldRef?.temperature ?? global.CONFIG?.standardTemperature ?? 15);
let mul = 1;
if (weather === "light_rain") mul *= 2;
if (Number.isFinite(temp) && temp <= 10) mul *= 2;
return mul;
}
function pushFireEffect(worldRef, x, y, size = 12, seed = null) {
if (!worldRef?.effects || typeof Effect !== "function") return;
if ((worldRef.effects.length || 0) > 180) return;
const a = global.deterministicAngle(worldRef, "fire-effect-angle", seed || x, y, worldRef.time || 0);
const speed = global.deterministicRange(worldRef, "fire-effect-speed", 3, 15, seed || x, y);
worldRef.effects.push(new Effect("flame", x, y, {
vx: Math.cos(a) * speed * 0.35,
vy: -Math.max(4, speed * 0.65),
size: Math.max(4, size * 0.58),
life: global.deterministicRange(worldRef, "fire-effect-life", 0.18, 0.34, seed || x, y),
color: "rgba(236,46,24,0.78)",
}));
}
function fireInteractionBudget(worldRef) {
const frame = Math.floor((Number(worldRef?.time || 0) || 0) * 60);
if (!worldRef._fireInteractionBudget || worldRef._fireInteractionBudget.frame !== frame) {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
worldRef._fireInteractionBudget = {
frame,
remaining: mobile ? FIRE_INTERACTION_BUDGET_MOBILE : FIRE_INTERACTION_BUDGET_DESKTOP,
};
}
return worldRef._fireInteractionBudget;
}
function useFireInteractionBudget(worldRef, cost = 1) {
const budget = fireInteractionBudget(worldRef);
if (!budget || budget.remaining < cost) return false;
budget.remaining -= cost;
return true;
}
function contactWaterDrop(worldRef, x, y, radius = 16) {
if (!worldRef) return null;
const list = typeof worldRef.nearbyItems === "function" ? [...(worldRef.nearbyItems(x, y, radius + 28, true) || [])] : (worldRef.items || []);
for (const it of list) {
if (!it || it.dead || it.type !== "water") continue;
const rr = Math.max(radius, 10) + Math.max(it.r || 10, 10);
if (distXY(x, y, it.x, it.y) <= rr) return it;
}
return null;
}
function consumeWaterForFire(worldRef, water, amount = 8) {
if (!water) return false;
water.amount = Math.max(0, Number(water.amount || 0) - amount);
worldRef?.effects?.push(new Effect("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" }));
worldRef?.markItemBucketsDirty?.("fire-water");
worldRef?.markSpatialDirty?.("fire-water");
return true;
}
function igniteTarinaiByFire(t, source = null, worldRef = null, force = false) {
if (!t || t.dead) return false;
if (typeof t.igniteFire === "function") return t.igniteFire(source, force);
t.burning = true;
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
t.thought = FIRE_BURNING_TEXT;
t.lastPanicCause = "burning";
t.lastPanicDetail = FIRE_BURNING_TEXT;
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
t.sleeping = false;
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_BURNING_TEXT, wake: true });
worldRef?.emit?.("fire:ignite", { tarinai: t, source });
return true;
}
function panicTarinaiFromFire(t, source = null, worldRef = null) {
if (!t || t.dead || t.burning || (t.burnTimer || 0) > 0.02) return false;
const now = Number(worldRef?.time || t.world?.time || 0) || 0;
if (now < Number(t.firePanicCooldownUntil || 0)) return false;
t.firePanicCooldownUntil = now + 0.85;
t.firePanicTimer = Math.max(t.firePanicTimer || 0, 1.4);
t.lastPanicCause = "fire_seen";
t.lastPanicDetail = FIRE_SEEN_TEXT;
t.thought = FIRE_SEEN_TEXT;
if (typeof t.enterPanic === "function") t.enterPanic({ threat: source, target: source, reason: FIRE_SEEN_TEXT, fear: 1.25, wake: true, cause: "fire_seen", surpriseTimer: 0.55, awakeLockTimer: 0.35 });
else { t.fearTimer = Math.max(t.fearTimer || 0, 1.25); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_SEEN_TEXT, wake: true }); }
return true;
}
function igniteAntByFire(ant, source = null, worldRef = null) {
if (!ant || ant.dead) return false;
if (typeof ant.igniteFire === "function") return ant.igniteFire(source);
ant.burning = true;
ant.burnTimer = Math.max(ant.burnTimer || 0, 5.0);
ant.state = "panic";
ant.targetId = "";
ant.targetRef = null;
worldRef?.emit?.("fire:ignite-ant", { ant, source });
return true;
}
function grassFireExtinguishChance(worldRef, grass, dt = 0.016) {
const baseDt = Math.max(Number(dt || 0) || 0, FIRE_CONTACT_INTERVAL);
const weather = String(worldRef?.weather || "");
const fresh = clamp(Number(grass?.growth ?? grass?.health ?? 0.65) || 0, 0, 1);
const rainMul = weather === "light_rain" ? 1.75 : 1;
return clamp(baseDt * GRASS_FIRE_EXTINGUISH_RATE * rainMul * (1 + fresh * 0.45), 0, 0.58);
}
function tryGrassExtinguishFire(grass, fire, worldRef = null, dt = 0.016) {
if (!grass || grass.dead || grass.type !== "grass" || !fire || fire.dead || fire.type !== "fire") return false;
const chance = grassFireExtinguishChance(worldRef, grass, dt);
if (!global.deterministicChance(worldRef, "grass-fire-extinguish", chance, grass, fire)) return false;
const reduction = Math.max(18, Math.max(grass.r || 16, 12) * 0.72 + Math.max(fire.r || 14, 12) * 0.52);
fire.amount = Math.max(0, Number(fire.amount || 0) - reduction);
fire.flameLife = Math.max(0, Number(fire.flameLife || 0) - 0.85);
grass.health = clamp((grass.health ?? 1) - 0.035, 0, 1);
if ((fire.amount || 0) <= 24 || (fire.flameLife || 0) <= 0.35) fire.amount = 0;
worldRef?.effects?.push(new Effect("ring", fire.x, fire.y, { size: Math.max(18, (fire.r || 14) * 1.8), life: 0.18, color: "rgba(118,188,94,0.46)" }));
worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 28), "grass-extinguish-fire");
worldRef?.markItemBucketsDirty?.("grass-extinguish-fire");
worldRef?.markSpatialDirty?.("grass-extinguish-fire");
worldRef.drawListDirty = true;
return true;
}
function igniteGrassByFire(grass, source = null, worldRef = null, dt = 0.016) {
if (!grass || grass.dead || grass.type !== "grass") return false;
grass.burning = true;
grass.burnTimer = Math.max(grass.burnTimer || 0, 2.8);
grass.health = clamp((grass.health ?? 1) - dt * 0.30, 0, 1);
grass.amount = Math.max(0, Number(grass.amount || 0) - dt * 18);
if (grass.health <= 0.02 || grass.amount <= 0.5) grass.amount = 0;
if (worldRef && (worldRef.time || 0) > Number(grass.nextFireSpreadAt || -Infinity)) {
grass.nextFireSpreadAt = (worldRef.time || 0) + 1.6;
if (typeof worldRef.spawnFireAt === "function") {
const a = global.deterministicAngle(worldRef, "grass-fire-spread", grass, source);
worldRef.spawnFireAt(grass.x + Math.cos(a) * (grass.r || 14) * 0.55, grass.y + Math.sin(a) * (grass.r || 14) * 0.35, { amount: 60, radius: 12, life: 4.2, source: grass });
}
}
worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 26), "grass-fire");
return true;
}
function igniteZunchiByFire(zunchi, source = null, worldRef = null, dt = 0.016) {
if (!zunchi || zunchi.dead || zunchi.type !== "zunchi") return false;
const wasBurning = Boolean(zunchi.burning || (zunchi.burnTimer || 0) > 0.02);
zunchi.burning = true;
zunchi.burnTimer = Math.max(Number(zunchi.burnTimer || 0) || 0, 4.6);
zunchi.spawnGrace = 0;
zunchi.freshness = clamp((zunchi.freshness ?? 1) - dt * 0.55, 0, 1);
zunchi.amount = Math.max(0, Number(zunchi.amount || 0) - dt * 34);
zunchi.stageTimer = Math.max(0, Number(zunchi.stageTimer || 0) + dt * 2.4);
if (!wasBurning) {
pushFireEffect(worldRef, zunchi.x, zunchi.y - Math.max(5, (zunchi.r || 14) * 0.2), Math.max(10, zunchi.r || 14), zunchi);
worldRef?.emit?.("fire:ignite-zunchi", { item: zunchi, source });
}
if ((zunchi.amount || 0) <= 0.5) zunchi.amount = 0;
worldRef?.markTerrainDirtyAt?.(zunchi.x, zunchi.y, Math.max(zunchi.r || 15, 28), "zunchi-fire");
worldRef?.markItemBucketsDirty?.("zunchi-fire");
worldRef?.markSpatialDirty?.("zunchi-fire");
worldRef.drawListDirty = true;
return true;
}
function updateFireContacts(item, dt, worldRef) {
if (!item || !worldRef) return false;
const r = Math.max(14, Number(item.r || 0) || 14);
const spreadRadius = r + 16;
let changed = false;
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, spreadRadius + 32, true) || []) {
if (!t || t.dead) continue;
const d = distXY(item.x, item.y, t.x, t.y);
if (d <= spreadRadius + Math.max(t.radius || 18, 12) * 0.72) changed = igniteTarinaiByFire(t, item, worldRef, false) || changed;
}
for (const ant of worldRef.nearbyAnts?.(item.x, item.y, spreadRadius + 20, true) || []) {
if (!ant || ant.dead) continue;
const d = distXY(item.x, item.y, ant.x, ant.y);
if (d <= spreadRadius + Math.max(ant.r || 5, 4)) changed = igniteAntByFire(ant, item, worldRef) || changed;
}
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(item.x, item.y, spreadRadius + 38, true) || []) : (worldRef.items || []);
for (const it of nearby) {
if (!it || it.dead || it === item) continue;
if (it.type === "grass" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 16, 12)) {
const extinguished = tryGrassExtinguishFire(it, item, worldRef, dt);
changed = extinguished || changed;
if (!extinguished && (item.amount || 0) > 0) changed = igniteGrassByFire(it, item, worldRef, dt) || changed;
}
if (it.type === "zunchi" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 15, 12)) changed = igniteZunchiByFire(it, item, worldRef, dt) || changed;
if (it.type === "water" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 10, 8)) {
consumeWaterForFire(worldRef, it, Math.max(6, r * 0.45));
item.amount = 0;
changed = true;
}
}
return changed;
}
function panicFromNearbyFire(item, worldRef) {
if (!item || !worldRef) return;
const r = Math.max(170, (item.r || 14) + 170);
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, r, true) || []) {
if (!t || t.dead) continue;
panicTarinaiFromFire(t, item, worldRef);
}
}
function updateFire(item, dt, worldRef) {
if (!item || item.dead || !worldRef) return false;
dt = Math.max(0, Math.min(0.08, Number(dt || 0) || 0));
item.amount = Math.max(0, Number(item.amount || 0) - dt * 4.2);
item.flameLife = Math.max(0, Number(item.flameLife ?? 8.0) - dt);
item.flameSpreadCooldown = Math.max(0, Number(item.flameSpreadCooldown || 0) - dt);
item.flamePanicCooldown = Math.max(0, Number(item.flamePanicCooldown || 0) - dt);
item.flameWaterCooldown = Math.max(0, Number(item.flameWaterCooldown || 0) - dt);
item.flameClusterCooldown = Math.max(0, Number(item.flameClusterCooldown || 0) - dt);
item.vx = 0;
item.vy = 0;
const lowFire = (item.amount || 0) < 24 || item.flameLife < 1.2;
if (item.flameWaterCooldown <= 0) {
item.flameWaterCooldown = FIRE_WATER_INTERVAL;
const water = contactWaterDrop(worldRef, item.x, item.y, Math.max(item.r || 14, 12));
if (water) {
consumeWaterForFire(worldRef, water, Math.max(8, (item.r || 14) * 0.6));
item.amount = 0;
return true;
}
}
const chance = dt * 0.012 * fireTempMultiplier(worldRef, item.x, item.y, item);
if (global.deterministicChance(worldRef, "fire-random-extinguish", chance, item)) {
item.amount = 0;
return true;
}
if (item.flameLife <= 0) item.amount = Math.min(item.amount, 1);
if (item.flameClusterCooldown <= 0) {
mergeNearbyFires(worldRef, item, Math.max(24, (item.r || 14) * 2.4), 4);
item.flameClusterCooldown = FIRE_CLUSTER_SCAN_INTERVAL;
}
if (!lowFire && item.flameSpreadCooldown <= 0 && useFireInteractionBudget(worldRef, 1)) {
updateFireContacts(item, dt, worldRef);
item.flameSpreadCooldown = FIRE_CONTACT_INTERVAL;
}
if (!lowFire && item.flamePanicCooldown <= 0 && useFireInteractionBudget(worldRef, 1)) {
panicFromNearbyFire(item, worldRef);
item.flamePanicCooldown = FIRE_PANIC_INTERVAL;
}
if (!lowFire && global.deterministicChance(worldRef, "fire-effect", dt * 0.72, item)) pushFireEffect(worldRef, item.x, item.y - (item.r || 14) * 0.2, Math.max(8, (item.r || 14) * 0.9), item);
return true;
}
function mergeNearbyFires(worldRef, fire, radius = 36, maxMerges = 4) {
if (!worldRef || !fire || fire.dead) return 0;
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(fire.x, fire.y, radius, true) || []) : (worldRef.items || []);
let merged = 0;
for (const other of nearby) {
if (!other || other === fire || other.dead || other.type !== "fire") continue;
if (distXY(fire.x, fire.y, other.x, other.y) > radius + Math.max(other.r || 14, 12)) continue;
fire.amount = Math.min(260, Math.max(fire.amount || 0, other.amount || 0) + Math.min(34, Math.max(0, Number(other.amount || 0) * 0.35)));
fire.flameLife = Math.max(fire.flameLife || 0, other.flameLife || 0);
fire.r = Math.min(42, Math.max(fire.r || 14, other.r || 14));
other.amount = 0;
merged += 1;
if (merged >= maxMerges) break;
}
if (merged) {
worldRef.markItemBucketsDirty?.("fire-merge");
worldRef.markSpatialDirty?.("fire-merge");
worldRef.drawListDirty = true;
}
return merged;
}
function activeFireLimit() {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
return mobile ? FIRE_MAX_ACTIVE_MOBILE : FIRE_MAX_ACTIVE_DESKTOP;
}
function activeFires(worldRef) {
return worldRef?.itemsOfType?.("fire") || (worldRef?.items || []).filter(it => it && !it.dead && it.type === "fire");
}
function foldIntoExistingFire(worldRef, x, y, opts = {}) {
const fires = activeFires(worldRef);
if (!fires.length) return null;
let best = null;
let bestD = Infinity;
const exclude = opts.exclude || null;
for (const fire of fires) {
if (!fire || fire.dead) continue;
if (exclude && fire === exclude) continue;
const d = distXY(x, y, fire.x, fire.y);
if (d < bestD) { best = fire; bestD = d; }
}
if (!best) return null;
best.amount = Math.min(280, Math.max(Number(best.amount || 0), Number(opts.amount || 0) * 0.45) + 14);
best.flameLife = Math.max(Number(best.flameLife || 0), Math.max(1.0, Number(opts.life || 3.5) * 0.75));
best.r = Math.min(44, Math.max(best.r || 14, Number(opts.radius || 14)));
best.vx = 0;
best.vy = 0;
worldRef.drawListDirty = true;
return best;
}
function compactWorldFires(worldRef, opts = {}) {
const fires = activeFires(worldRef).filter(fire => fire && !fire.dead);
const limit = Math.max(8, Number(opts.limit || activeFireLimit()) || activeFireLimit());
if (fires.length <= limit) return 0;
const cellSize = Math.max(46, Number(opts.cellSize || 72) || 72);
const cells = new Map();
for (const fire of fires) {
const key = `${Math.floor((fire.x || 0) / cellSize)}:${Math.floor((fire.y || 0) / cellSize)}`;
let group = cells.get(key);
if (!group) {
group = [];
cells.set(key, group);
}
group.push(fire);
}
let removed = 0;
for (const group of cells.values()) {
if (group.length <= 1) continue;
group.sort((a, b) => ((b.amount || 0) + (b.flameLife || 0) * 8 + (b.r || 0)) - ((a.amount || 0) + (a.flameLife || 0) * 8 + (a.r || 0)));
const keep = group[0];
for (let i = 1; i < group.length; i += 1) {
const fire = group[i];
keep.amount = Math.min(300, Math.max(keep.amount || 0, fire.amount || 0) + Math.min(18, Math.max(0, Number(fire.amount || 0) * 0.20)));
keep.flameLife = Math.max(keep.flameLife || 0, fire.flameLife || 0);
keep.r = Math.min(46, Math.max(keep.r || 14, fire.r || 14));
fire.amount = 0;
removed += 1;
}
}
if (fires.length - removed > limit) {
const survivors = fires.filter(fire => !fire.dead).sort((a, b) => ((a.amount || 0) + (a.flameLife || 0) * 8) - ((b.amount || 0) + (b.flameLife || 0) * 8));
let remaining = survivors.length;
for (const fire of survivors) {
if (remaining <= limit) break;
if (fire.amount <= 0) continue;
foldIntoExistingFire(worldRef, fire.x, fire.y, { ...fire, exclude: fire });
fire.amount = 0;
removed += 1;
remaining -= 1;
}
}
if (removed) {
worldRef.compactItems?.();
worldRef.markItemBucketsDirty?.("fire-compact");
worldRef.markSpatialDirty?.("fire-compact");
worldRef.drawListDirty = true;
}
return removed;
}
function spawnFireAt(worldRef, x, y, opts = {}) {
if (!worldRef || typeof Item !== "function") return null;
const currentFires = activeFires(worldRef);
if (currentFires.length >= activeFireLimit()) return foldIntoExistingFire(worldRef, x, y, opts);
const mergeRadius = Math.max(70, Number(opts.mergeRadius || opts.radius || 16) * 4.5);
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(x, y, mergeRadius, true) || []) : (worldRef.items || []);
for (const existing of nearby) {
if (!existing || existing.dead || existing.type !== "fire") continue;
if (distXY(x, y, existing.x, existing.y) > mergeRadius + Math.max(existing.r || 14, 12)) continue;
existing.amount = Math.min(260, Math.max(Number(existing.amount || 0), Number(opts.amount || 0) * 0.55) + 18);
existing.flameLife = Math.max(Number(existing.flameLife || 0), Math.max(1.2, Number(opts.life || 4.0) * 0.85));
existing.r = Math.min(42, Math.max(existing.r || 14, Number(opts.radius || 14)));
existing.vx = 0;
existing.vy = 0;
worldRef.drawListDirty = true;
return existing;
}
const fire = new Item("fire", x, y);
const scale = Math.max(0.45, Math.min(2.0, Number(opts.scale || 1) || 1));
fire.r = Math.max(9, Number(opts.radius || fire.r || 14) * scale);
fire.amount = Math.max(12, Number(opts.amount || fire.amount || 100) * scale);
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
fire.vx = 0;
fire.vy = 0;
worldRef.addItem?.(fire, "spawn-fire", { terrain: false });
worldRef.drawListDirty = true;
return fire;
}
function scatterFireFrom(item, worldRef) {
if (!item || !worldRef) return false;
const existing = activeFires(worldRef).length;
const limit = activeFireLimit();
const room = Math.max(0, limit - existing);
const desired = Math.max(5, Math.round(8 * Math.max(0.75, Math.min(1.4, (item.r || 15) / 15))));
const count = Math.max(3, Math.min(desired, room > 0 ? room : 3));
for (let i = 0; i < count; i++) {
const a = (Math.PI * 2 * i / count) + global.deterministicRange(worldRef, "flame-cracker-angle", -0.20, 0.20, item, i);
const d = global.deterministicRange(worldRef, "flame-cracker-radius", 28, 118, item, i);
const fire = spawnFireAt(worldRef, item.x + Math.cos(a) * d, item.y + Math.sin(a) * d, {
radius: global.deterministicRange(worldRef, "flame-cracker-fire-radius", 10, 18, item, i),
amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 55, 120, item, i),
life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 4.0, 8.5, item, i),
source: item,
});
if (fire) {
fire.vx = 0;
fire.vy = 0;
updateFireContacts(fire, FIRE_CONTACT_INTERVAL, worldRef);
}
pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item);
}
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * 3.0), life: 0.36, color: "rgba(255,126,54,0.58)" }));
item.amount = 0;
worldRef.markItemBucketsDirty?.("flame-firecracker");
worldRef.markSpatialDirty?.("flame-firecracker");
return true;
}
function updateFlameFirecracker(item, dt, worldRef) {
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
if (item.fuseTimer <= 0) {
if (worldRef) scatterFireFrom(item, worldRef);
else item.amount = 0;
}
return true;
}
global.TarinaiFireRuntimeSystem = Object.freeze({
updateFirecracker,
updateFlameFirecracker,
updateFire,
fireBurningText: FIRE_BURNING_TEXT,
fireSeenText: FIRE_SEEN_TEXT,
fireTempMultiplier,
contactWaterDrop,
consumeWaterForFire,
igniteTarinaiByFire,
panicTarinaiFromFire,
igniteAntByFire,
igniteGrassByFire,
spawnFireAt: (worldRef, x, y, opts = {}) => spawnFireAt(worldRef, x, y, opts),
scatterFireFrom,
compactWorldFires,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,201 +0,0 @@
"use strict";
// Layer: math/geometry
// Shared primitive geometry helpers used by placement, collision, mechanical bodies,
// editor hit-testing, and world obstacle queries.
(function (global) {
function num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function clampValue(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
const c = Math.cos(num(angle));
const s = Math.sin(num(angle));
const ex = Math.abs(c) * num(halfW) + Math.abs(s) * num(halfH);
const ey = Math.abs(s) * num(halfW) + Math.abs(c) * num(halfH);
return { left: num(cx) - ex, right: num(cx) + ex, top: num(cy) - ey, bottom: num(cy) + ey, cos: c, sin: s };
}
function rectAabb(rect) {
if (!rect) return null;
if (!rect.oriented) return rect;
const aabb = orientedRectAabb(rect.cx, rect.cy, rect.halfW, rect.halfH, rect.angle);
return {
left: num(rect.left, aabb.left), right: num(rect.right, aabb.right), top: num(rect.top, aabb.top), bottom: num(rect.bottom, aabb.bottom),
cx: num(rect.cx), cy: num(rect.cy), halfW: num(rect.halfW), halfH: num(rect.halfH), angle: num(rect.angle),
cos: Number.isFinite(rect.cos) ? rect.cos : aabb.cos,
sin: Number.isFinite(rect.sin) ? rect.sin : aabb.sin,
oriented: true, type: rect.type, item: rect.item,
};
}
function rectLocalPoint(rect, x, y) {
const dx = num(x) - num(rect?.cx);
const dy = num(y) - num(rect?.cy);
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
function rectWorldVector(rect, x, y) {
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: num(x) * c - num(y) * s, y: num(x) * s + num(y) * c };
}
function rectWorldNormal(rect, nx, ny) {
return rectWorldVector(rect, nx, ny);
}
function rectCorners(rect, padding = 0) {
const r = rectAabb(rect);
const pad = Math.max(0, num(padding));
if (!r) return [];
if (!r.oriented) {
return [
{ x: num(r.left) - pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.bottom) + pad },
{ x: num(r.left) - pad, y: num(r.bottom) + pad },
];
}
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(num(r.angle));
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(num(r.angle));
const hw = num(r.halfW) + pad;
const hh = num(r.halfH) + pad;
const out = [];
for (const [lx, ly] of [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]) {
out.push({ x: num(r.cx) + lx * c - ly * s, y: num(r.cy) + lx * s + ly * c });
}
return out;
}
function rectAxes(rect) {
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
const s = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
return [{ x: c, y: s }, { x: -s, y: c }];
}
function projectPoints(points, axis) {
let min = Infinity;
let max = -Infinity;
for (const p of points || []) {
const v = num(p.x) * axis.x + num(p.y) * axis.y;
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
function aabbOverlap(a, b, padding = 0) {
const pad = Math.max(0, num(padding));
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
}
function rectOverlapInfo(a, b, padding = 0) {
if (!aabbOverlap(a, b, padding)) return null;
const ptsA = rectCorners(a, padding);
const ptsB = rectCorners(b, padding);
if (!ptsA.length || !ptsB.length) return null;
let bestOverlap = Infinity;
let bestAxis = null;
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
const len = Math.hypot(axis.x, axis.y) || 1;
const n = { x: axis.x / len, y: axis.y / len };
const pa = projectPoints(ptsA, n);
const pb = projectPoints(ptsB, n);
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
if (overlap <= 0) return null;
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
}
if (!bestAxis) return null;
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
return {
overlap: bestOverlap,
nx: bestAxis.x,
ny: bestAxis.y,
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
};
}
function rectsOverlap(a, b, margin = 0) {
return Boolean(rectOverlapInfo(a, b, margin));
}
function pointInRect(x, y, rect, padding = 0) {
if (!rect) return false;
const pad = Math.max(0, num(padding));
if (rect.oriented) {
const p = rectLocalPoint(rect, x, y);
return Math.abs(p.x) <= num(rect.halfW) + pad && Math.abs(p.y) <= num(rect.halfH) + pad;
}
return num(x) >= num(rect.left) - pad && num(x) <= num(rect.right) + pad
&& num(y) >= num(rect.top) - pad && num(y) <= num(rect.bottom) + pad;
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
const dx = num(bx) - num(ax);
const dy = num(by) - num(ay);
const lenSq = dx * dx + dy * dy;
if (lenSq <= 0.0001) return Math.hypot(num(px) - num(ax), num(py) - num(ay));
const u = clampValue(((num(px) - num(ax)) * dx + (num(py) - num(ay)) * dy) / lenSq, 0, 1);
return Math.hypot(num(px) - (num(ax) + dx * u), num(py) - (num(ay) + dy * u));
}
function segmentAabbHit(x1, y1, x2, y2, left, top, right, bottom) {
if ((x1 >= left && x1 <= right && y1 >= top && y1 <= bottom) || (x2 >= left && x2 <= right && y2 >= top && y2 <= bottom)) return true;
const minX = Math.min(left, right), maxX = Math.max(left, right);
const minY = Math.min(top, bottom), maxY = Math.max(top, bottom);
let t0 = 0, t1 = 1;
const clip = (p, q) => {
if (Math.abs(p) < 1e-9) return q >= 0;
const t = q / p;
if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; }
else { if (t < t0) return false; if (t < t1) t1 = t; }
return true;
};
const dx = num(x2) - num(x1);
const dy = num(y2) - num(y1);
return clip(-dx, num(x1) - minX) && clip(dx, maxX - num(x1)) && clip(-dy, num(y1) - minY) && clip(dy, maxY - num(y1));
}
function segmentIntersectsOrientedRect(x1, y1, x2, y2, r, padding = 0) {
if (!r) return false;
if (!r.oriented) return segmentAabbHit(x1, y1, x2, y2, r.left - padding, r.top - padding, r.right + padding, r.bottom + padding);
const a = rectLocalPoint(r, x1, y1);
const b = rectLocalPoint(r, x2, y2);
return segmentAabbHit(a.x, a.y, b.x, b.y, -(r.halfW || 0) - padding, -(r.halfH || 0) - padding, (r.halfW || 0) + padding, (r.halfH || 0) + padding);
}
global.TarinaiGeometry = Object.freeze({
num,
clampValue,
orientedRectAabb,
rectAabb,
rectLocalPoint,
rectWorldVector,
rectWorldNormal,
rectCorners,
rectAxes,
projectPoints,
aabbOverlap,
rectOverlapInfo,
rectsOverlap,
pointInRect,
pointSegmentDistance,
segmentAabbHit,
segmentIntersectsOrientedRect,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -34,7 +34,7 @@ function normalizedGroundGrassMultiplier(id = "soil") {
return Number.isFinite(mult) && mult >= 0 ? mult : 1;
}
function grassGrowthMulForGround(id = "soil") {
function normalizedGroundGrassGrowthMultiplier(id = "soil") {
const def = groundDefinition(id);
const mult = Number(def.grassGrowthMultiplier ?? def.grassMultiplier);
return Number.isFinite(mult) && mult >= 0 ? mult : 1;
@ -73,7 +73,7 @@ window.TarinaiGround = Object.freeze({
label: groundLabel,
stressMultiplier: normalizedGroundStressMultiplier,
grassMultiplier: normalizedGroundGrassMultiplier,
grassGrowthMultiplier: grassGrowthMulForGround,
grassGrowthMultiplier: normalizedGroundGrassGrowthMultiplier,
grassLimit: groundGrassLimit,
nextId: nextGroundTypeId,
tooltipText: groundTooltipText,

View file

@ -2,8 +2,6 @@
const HEALTH = (() => {
const MAJOR_DAMAGE_WINDOW = 24;
const WEAKENED_MAJOR_DAMAGE_MIN_AGE = 3;
const WEAKENED_MAJOR_DAMAGE_MAX_AGE = 20;
const MAJOR_DAMAGE_REACTION_WINDOW = 1.0;
const WEAKENED_TRACE_WINDOW = 45;
const WEAKENED_SUFFIX = "\u3067\u8870\u5f31";
@ -13,7 +11,6 @@ const HEALTH = (() => {
stress: "\u30b9\u30c8\u30ec\u30b9\u904e\u591a",
poke: "\u3064\u3064\u304b\u308c\u3059\u304e",
firecracker: "\u7206\u7af9",
fire: "\u708e\u4e0a",
genkotsu: "\u3052\u3093\u3053\u3064",
shooting: "\u5c04\u6483",
stoneCrush: "\u77f3\u306b\u6f70\u3055\u308c\u305f",
@ -30,8 +27,8 @@ const HEALTH = (() => {
antEaten: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f",
giantDrug: "\u5de8\u5927\u85ac",
dwarfDrug: "\u77ee\u5c0f\u85ac",
freeze: "\u51CD\u7D50",
heatstroke: "\u71B1\u4E2D\u75C7",
freeze: "凍結",
heatstroke: "熱中症",
});
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
@ -39,8 +36,8 @@ const HEALTH = (() => {
const text = String(reason || "");
if (!text) return "";
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
if (/\u51CD\u7D50|\u4F4E\u6E29|\u5BD2\u3055|cold|freeze|freezing/i.test(text)) return CAUSES.freeze;
if (/\u71B1\u4E2D\u75C7|\u9AD8\u6E29|\u6691\u3055|heat|hot/i.test(text)) return CAUSES.heatstroke;
if (/凍結|低温|寒さ|cold|freeze|freezing/i.test(text)) return CAUSES.freeze;
if (/熱中症|高温|暑さ|heat|hot/i.test(text)) return CAUSES.heatstroke;
if (/\u5c04\u6483|shoot/.test(text)) return CAUSES.shooting;
if (/\u77f3\u306b\u6f70\u3055\u308c\u305f|\u77f3.*\u6f70|stone.*crush|crushed.*stone/.test(text)) return CAUSES.stoneCrush;
const collisionWith = text.match(/([^\u3001\u3002\s]+?)(?:\u306b|\u3068\u306e)\u885d\u7a81/);
@ -51,7 +48,6 @@ const HEALTH = (() => {
if (/\u5de8\u5927\u85ac|giant/.test(text)) return CAUSES.giantDrug;
if (/\u77ee\u5c0f\u85ac|dwarf/.test(text)) return CAUSES.dwarfDrug;
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker;
if (/\u708e\u4e0a|\u706b|\u708e|\u71c3|burn|fire/.test(text)) return CAUSES.fire;
if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds;
if (/\u885d\u7a81|collision|\u6025\u6fc0\u306a\u901f\u5ea6\u5909\u5316/.test(text)) return CAUSES.collision;
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke;
@ -102,12 +98,6 @@ const HEALTH = (() => {
return Boolean(t.lastMajorDamageCause && now - (t.lastMajorDamageAt || -999) <= MAJOR_DAMAGE_WINDOW);
}
function hasLingeringMajorDamage(t) {
const now = t.world?.time || 0;
const age = now - (t.lastMajorDamageAt || -999);
return Boolean(t.lastMajorDamageCause && age >= WEAKENED_MAJOR_DAMAGE_MIN_AGE && age < WEAKENED_MAJOR_DAMAGE_MAX_AGE);
}
function isFightingContext(t, recent) {
return Boolean(t.fightTimer > 0.04 || t.defeatedTimer > 0.04 || t.defeatedById || t.fightWinnerId || recent === CAUSES.fight);
}
@ -127,9 +117,8 @@ const HEALTH = (() => {
return CAUSES.accident;
}
if (direct === CAUSES.fire || recent === CAUSES.fire || t.lastMajorDamageCause === CAUSES.fire) return CAUSES.fire;
if (recentMajor && t.lastMajorDamageCause === CAUSES.collision && !(t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) return CAUSES.collision;
if (hasLingeringMajorDamage(t) && (!direct || direct === t.lastMajorDamageCause || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) {
if (recentMajor && (!direct || direct === t.lastMajorDamageCause || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) {
return weakenedDeathReasonFor(t.lastMajorDamageCause);
}
const weakenedTrace = recentDamageCause(t, WEAKENED_TRACE_WINDOW);
@ -152,8 +141,7 @@ const HEALTH = (() => {
if (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan;
if (direct) return direct;
if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress;
if ((opts.lowHealth || t.energy <= 0.5) && hasLingeringMajorDamage(t)) return weakenedDeathReasonFor(t.lastMajorDamageCause);
if (opts.lowHealth || t.energy <= 0.5) return CAUSES.accident;
if (opts.lowHealth || t.energy <= 0.5) return CAUSES.weakness;
return "";
}
@ -163,8 +151,8 @@ const HEALTH = (() => {
const cause = dominantDeathCause(t, text, opts) || CAUSES.accident;
if (cause.includes(WEAKENED_SUFFIX)) return cause;
if (opts.fromDamage) return cause;
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu && cause !== CAUSES.fire && cause !== CAUSES.weakness && cause !== CAUSES.freeze && cause !== CAUSES.heatstroke;
if (canWeaken && (opts.weakened || (hasLingeringMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause);
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu && cause !== CAUSES.weakness && cause !== CAUSES.freeze && cause !== CAUSES.heatstroke;
if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause);
return cause;
}
@ -272,6 +260,7 @@ const HEALTH = (() => {
t.world.spawnZunchi?.(x, y, t);
t.poopCount = (t.poopCount || 0) + 1;
t.digest = Math.max(0, Number(t.digest || 0) - 1.0);
t.nextPoopBubbleAt = now + 2.0;
// If this zunchi release was caused by a major-damage event, keep the pain
// voice bubble visible and suppress the incidental \u300c\u3076\u308a\u3085\u3063\u300d bubble.
if (now >= Number(t._damageBubblePriorityUntil || -999)) {

View file

@ -50,19 +50,14 @@
hit += 1;
}
let ballHit = 0;
if (isGenkotsu && (worldRef.itemCounts?.ball || worldRef.itemCounts?.balloon)) {
if (isGenkotsu && worldRef.itemCounts?.ball) {
const ballRange = radius + Math.max(72, (it.r || 88) * 0.78);
const balls = worldRef.nearbyItems?.(it.x, it.y, ballRange + 42, true) || worldRef.items || [];
for (const ball of balls) {
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue;
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;
if (ball.type === "balloon") {
globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(ball, worldRef, it);
ballHit += 1;
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) {

View file

@ -1,20 +1,10 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns ball-like item motion, balloon drift/pop behavior, and fan wind.
// Item.prototype ball/balloon/fan methods delegate to this system.
// Owns ball item motion and obstacle bounce behavior. Item.prototype ball
// methods delegate to this system.
(function (global) {
function isBallLike(item) {
return !!item && !item.dead && (item.type === "ball" || item.type === "balloon");
}
function update(item, dt, worldRef) {
if (!isBallLike(item)) return false;
if (item.type === "balloon") return updateBalloon(item, dt, worldRef);
return updateBall(item, dt, worldRef);
}
function updateBall(item, dt, worldRef) {
if (!item || item.dead || item.type !== "ball") return false;
item.prevX = item.x;
item.prevY = item.y;
@ -42,58 +32,14 @@
return true;
}
function updateBalloon(item, dt, worldRef) {
if (!item || item.dead || item.type !== "balloon") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
item.prevX = item.x;
item.prevY = item.y;
// A balloon should never be completely inert: it slowly wobbles, then fans
// and collisions can turn that small drift into visible motion.
const phase = (worldRef?.time || 0) * 1.35 + (item.seed || 0) * 0.017 + (item.balloonWobble || 0);
item.vx = (item.vx || 0) + Math.cos(phase * 0.83) * 6.5 * frameDt + Math.sin(phase * 0.31) * 2.4 * frameDt;
item.vy = (item.vy || 0) + Math.sin(phase * 0.71) * 5.4 * frameDt;
const maxSpeed = 560;
const speedBefore = Math.hypot(item.vx || 0, item.vy || 0);
if (speedBefore > maxSpeed) {
const s = maxSpeed / speedBefore;
item.vx *= s;
item.vy *= s;
}
const moving = Math.hypot(item.vx || 0, item.vy || 0);
if (moving > 0.04) {
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; }
resolveObstacleCollisions(item, dt, worldRef);
item.x = clamp(item.x, p, worldRef.w - p);
item.y = clamp(item.y, p, worldRef.h - p);
const airFriction = Math.pow(0.47, frameDt);
item.vx *= airFriction;
item.vy *= airFriction;
}
checkBalloonHazards(item, worldRef);
item.spin = (item.spin || 0) + (item.spinVelocity || 0) * frameDt + (item.vx || 0) * frameDt / Math.max(12, item.r || 20) * 0.42;
item.spinVelocity *= Math.pow(0.58, frameDt);
if (Math.hypot(item.vx || 0, item.vy || 0) < 0.08) { item.vx = 0; item.vy = 0; }
return true;
}
function resolveObstacleCollisions(item, dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || [];
const seen = new Set(items);
// 大きな機構は中心が近傍セル外でも当たる可能性がある。
// 中心ではなく外接半径で追加走査して、長い棒・往復幅にも衝突させる。
// \u5927\u304d\u306a\u6a5f\u69cb\u306f\u4e2d\u5fc3\u304c\u8fd1\u508d\u30bb\u30eb\u5916\u3067\u3082\u5f53\u305f\u308b\u53ef\u80fd\u6027\u304c\u3042\u308b\u3002
// \u4e2d\u5fc3\u3067\u306f\u306a\u304f\u5916\u63a5\u534a\u5f84\u3067\u8ffd\u52a0\u8d70\u67fb\u3057\u3066\u3001\u9577\u3044\u68d2\u30fb\u5f80\u5fa9\u5e45\u306b\u3082\u885d\u7a81\u3055\u305b\u308b\u3002
if (Array.isArray(worldRef.items)) {
for (const it of worldRef.items) {
if (!it || it.dead || it === item || !global.TarinaiMechanicalSystem.isMechanicalType(it.type) || seen.has(it)) continue;
@ -109,14 +55,7 @@
if (it.type === "bed") {
applyHayDrag(item, it, dt, worldRef);
} else if (it.type === "stone") {
resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), item.type === "balloon" ? 0.48 : 0.82, worldRef);
} else if (it.type === "fan" && item.type === "balloon") {
const wind = fanWindAt(it, item.x, item.y);
if (wind) {
const a = itemAngleFor(it);
item.vx += Math.cos(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016);
item.vy += Math.sin(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016);
}
resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), 0.82, worldRef);
}
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
if (!rects.length) continue;
@ -131,7 +70,7 @@
const dy = item.y - bed.y;
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
if (!inside) return;
const slow = Math.pow(item.type === "balloon" ? 0.28 : 0.16, Math.max(0.016, dt || 0.016));
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
item.vx *= slow;
item.vy *= slow;
item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
@ -180,34 +119,116 @@
item.vx = (item.vx || 0) - (1 + restitution) * toward * nx;
item.vy = (item.vy || 0) - (1 + restitution) * toward * ny;
} else {
const push = item.type === "balloon" ? 12 : 24;
item.vx = (item.vx || 0) + nx * push;
item.vy = (item.vy || 0) + ny * push;
item.vx = (item.vx || 0) + nx * 24;
item.vy = (item.vy || 0) + ny * 24;
}
const damp = item.type === "balloon" ? 0.84 : 0.96;
item.vx *= damp;
item.vy *= damp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * (item.type === "balloon" ? 2.2 : 5.5), -38, 38);
item.vx *= 0.96;
item.vy *= 0.96;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
emitBounce(item, worldRef, obstacle);
}
function resolveRectBounce(item, rect, worldRef, source = null) {
if (!rect) return;
const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect;
if (typeof bounceCircleOffRect !== "function") return;
const hitRadius = (item.r || 18) + 2;
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : (item.type === "balloon" ? 0.54 : 0.78);
const velocityDamp = rect.bounce ? 1.0 : (item.type === "balloon" ? 0.82 : 0.94);
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78;
const velocityDamp = rect.bounce ? 1.0 : 0.94;
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, {
minBounceSpeed: rect.bounce ? minBounceSpeed : 0,
fallbackImpulse: item.type === "balloon" ? 10 : 18,
spinKick: item.type === "balloon" ? 2.4 : 6.2,
spinLimit: 38,
});
if (!bounced) return;
if (rect.oriented && global.TarinaiPhysics.bounceCircleOffRect) {
const bounced = global.TarinaiPhysics.bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { minBounceSpeed });
if (!bounced) return;
item.vx *= velocityDamp;
item.vy *= velocityDamp;
emitBounce(item, worldRef, source || rect.item || rect);
return;
}
const ensureMinNormalSpeed = (nx, ny) => {
if (!(rect.bounce && minBounceSpeed > 0)) return;
const normalSpeed = (item.vx || 0) * nx + (item.vy || 0) * ny;
if (normalSpeed >= minBounceSpeed) return;
const add = minBounceSpeed - normalSpeed;
item.vx = (item.vx || 0) + nx * add;
item.vy = (item.vy || 0) + ny * add;
};
const cx = clamp(item.x, rect.left, rect.right);
const cy = clamp(item.y, rect.top, rect.bottom);
let dx = item.x - cx;
let dy = item.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = item.prevX;
const py = item.prevY;
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, item.x, item.y, expanded)) return;
let nx = 0;
let ny = 0;
const vx = item.vx || 0;
const vy = item.vy || 0;
const candidates = [];
const sx = item.x - px;
const sy = item.y - py;
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
if (candidates.length) {
candidates.sort((a, b) => a.t - b.t);
nx = candidates[0].nx;
ny = candidates[0].ny;
} else if (Math.abs(vx) >= Math.abs(vy)) {
nx = vx >= 0 ? -1 : 1;
} else {
ny = vy >= 0 ? -1 : 1;
}
const toward = vx * nx + vy * ny;
if (nx < 0) item.x = expanded.left - 0.5;
else if (nx > 0) item.x = expanded.right + 0.5;
if (ny < 0) item.y = expanded.top - 0.5;
else if (ny > 0) item.y = expanded.bottom + 0.5;
if (nx) item.y = clamp(item.y, expanded.top, expanded.bottom);
if (ny) item.x = clamp(item.x, expanded.left, expanded.right);
if (toward < 0) {
item.vx = vx - (1 + restitution) * toward * nx;
item.vy = vy - (1 + restitution) * toward * ny;
} else {
item.vx = vx + nx * 18;
item.vy = vy + ny * 18;
}
ensureMinNormalSpeed(nx, ny);
item.vx *= velocityDamp;
item.vy *= velocityDamp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
emitBounce(item, worldRef, source || rect.item || rect);
return;
}
if (d < 0.001) {
const left = Math.abs(item.x - rect.left);
const right = Math.abs(rect.right - item.x);
const top = Math.abs(item.y - rect.top);
const bottom = Math.abs(rect.bottom - item.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; }
else if (m === right) { dx = 1; dy = 0; }
else if (m === top) { dx = 0; dy = -1; }
else { dx = 0; dy = 1; }
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const toward = (item.vx || 0) * nx + (item.vy || 0) * ny;
item.x = cx + nx * (hitRadius + 0.5);
item.y = cy + ny * (hitRadius + 0.5);
if (toward < 0) {
item.vx = (item.vx || 0) - (1 + restitution) * toward * nx;
item.vy = (item.vy || 0) - (1 + restitution) * toward * ny;
} else {
item.vx = (item.vx || 0) + nx * 18;
item.vy = (item.vy || 0) + ny * 18;
}
ensureMinNormalSpeed(nx, ny);
item.vx *= velocityDamp;
item.vy *= velocityDamp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
emitBounce(item, worldRef, source || rect.item || rect);
}
@ -215,343 +236,12 @@
const now = worldRef?.time || 0;
if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return;
item.lastObstacleBounceAt = now;
const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"));
const color = (obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)");
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }));
}
function normalizeAngleLocal(angle = 0, fallback = 0) {
if (typeof normalizedItemAngle === "function") return normalizedItemAngle(angle, fallback);
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 updateFanSwing(item, dt) {
if (!item || item.dead || item.type !== "fan") return false;
if (!item.fanSwingOn) {
item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : itemAngleFor(item);
return false;
}
const center = normalizeAngleLocal(item.fanSwingCenter, itemAngleFor(item));
const range = clamp(Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0, 0, 150 * Math.PI / 180);
const speed = clamp(Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0, 0, 360 * Math.PI / 180);
item.fanSwingCenter = center;
item.fanSwingRange = range;
item.fanSwingSpeed = speed;
item.fanSwingPhase = Number(item.fanSwingPhase || 0) + speed * Math.max(0.016, Number(dt || 0.016) || 0.016);
item.angle = normalizeAngleLocal(center + Math.sin(item.fanSwingPhase || 0) * range, center);
return true;
}
function fanWindAt(fan, x, y, opts = {}) {
if (!fan || fan.dead || fan.type !== "fan") return null;
const range = Math.max(80, Number(opts.range || 300) || 300);
const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62);
const dx = x - fan.x;
const dy = y - fan.y;
const angle = itemAngleFor(fan);
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const along = dx * ax + dy * ay;
if (along < 8 || along > range) return null;
const lateral = Math.abs(-dx * ay + dy * ax);
const halfWidth = 26 + along * Math.tan(spread);
if (lateral > halfWidth) return null;
const distFalloff = clamp(1 - along / range, 0, 1);
const edgeFalloff = clamp(1 - lateral / Math.max(1, halfWidth), 0, 1);
const falloff = Math.pow(distFalloff, 0.64) * Math.pow(edgeFalloff, 0.34);
if (falloff <= 0.02) return null;
return { angle, along, lateral, halfWidth, range, falloff };
}
function updateFan(item, dt, worldRef) {
if (!item || item.dead || item.type !== "fan") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
const now = worldRef?.time || 0;
item.prevX = item.x;
item.prevY = item.y;
item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt;
item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt;
updateFanSwing(item, frameDt);
const angle = itemAngleFor(item);
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const range = 320;
const searchRadius = range + 40;
const nearbyItems = worldRef?.nearbyItems?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || [];
for (const target of nearbyItems) {
if (!target || target.dead || target === item) continue;
const wind = fanWindAt(item, target.x, target.y, { range });
if (!wind) continue;
applyFanToItem(item, target, wind, frameDt, worldRef);
}
const nearbyTarinai = worldRef?.nearbyTarinai?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || [];
for (const t of nearbyTarinai) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const wind = fanWindAt(item, t.x, t.y, { range });
if (!wind) continue;
applyFanToTarinai(item, t, wind, frameDt, worldRef);
}
if ((item.lastFanEffectAt || -999) + 0.18 < now) {
item.lastFanEffectAt = now;
const phase = Math.sin(item.fanPulse || 0);
const px = item.x + ax * (42 + 24 * phase);
const py = item.y + ay * (42 + 24 * phase);
worldRef?.effects?.push(new Effect("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" }));
}
return true;
}
function applyFanToItem(fan, target, wind, dt, worldRef) {
const type = target.type || "";
let massFactor = 0;
if (type === "balloon") massFactor = 1.85;
else if (type === "ball") massFactor = 0.72;
else if (type === "pushpin" || type === "oshibyo") massFactor = 0.90;
else if (type === "water" || type === "zunda_juice" || type === "mercury") massFactor = 0.42;
else if (type === "zunchi") massFactor = 0.30;
else if (type === "fire") massFactor = 0.18;
else return;
const force = 236 * wind.falloff * massFactor;
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
target.prevX = Number.isFinite(target.prevX) ? target.prevX : target.x;
target.prevY = Number.isFinite(target.prevY) ? target.prevY : target.y;
target.vx = clamp((target.vx || 0) + ax * force * dt, -820, 820);
target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820);
if (type === "balloon") target.spinVelocity = clamp((target.spinVelocity || 0) + 3.2 * wind.falloff * dt, -12, 12);
else if (type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + 5.4 * wind.falloff * dt, -38, 38);
if (type === "fire") target.amount = Math.max(0, (target.amount ?? 1) - wind.falloff * dt * 1.0);
worldRef?.markSpatialDirty?.("fan-wind-item");
}
function applyFanToTarinai(fan, t, wind, dt, worldRef) {
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
const radius = t.radius || 18;
const speed = 30 * wind.falloff;
t.vx = clamp((t.vx || 0) + ax * speed * dt, -360, 360);
t.vy = clamp((t.vy || 0) + ay * speed * dt, -360, 360);
t.fanBreezeTimer = Math.max(t.fanBreezeTimer || 0, 0.35);
if (wind.falloff > 0.58 && (worldRef?.time || 0) - (t.lastFanSurpriseAt || -999) > 2.2) {
t.lastFanSurpriseAt = worldRef?.time || 0;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12);
if (typeof t.spawnBubble === "function") t.spawnBubble("~", { life: 0.55 });
worldRef?.effects?.push(new Effect("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" }));
}
}
function checkBalloonHazards(balloon, worldRef) {
if (!balloon || balloon.dead || balloon.type !== "balloon" || !worldRef?.nearbyItems) return;
const now = worldRef.time || 0;
if ((balloon.balloonPoppedAt || -999) + 0.5 > now) return;
const radius = Math.max(26, (balloon.r || 20) + 22);
for (const it of worldRef.nearbyItems(balloon.x, balloon.y, radius) || []) {
if (!it || it.dead || it === balloon) continue;
if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker") {
const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + (it.type === "fire" ? 16 : 4);
if (hit) {
popBalloon(balloon, worldRef, it);
return;
}
}
}
}
const WATER_BALLOON_SURPRISE_TEXT = "水風船が割れておどろいている。";
function tarinaiOpennessValue(t) {
const v = (typeof global.effectivePersonalityValue === "function")
? global.effectivePersonalityValue(t, "openness")
: (t?.currentPersonality?.openness ?? t?.birthPersonality?.openness ?? t?.personality?.openness ?? 0);
return Number(v) || 0;
}
function shouldPanicFromWaterBalloon(t, strength, worldRef, balloon) {
const openness = tarinaiOpennessValue(t);
if (openness >= -0.08) return false;
if (openness <= -0.48) return true;
const chance = clamp(0.32 + Math.max(0, -openness) * 0.70 + Math.max(0, strength) * 0.26, 0.34, 0.92);
return deterministicChance(worldRef || t?.world || null, "water-balloon-low-openness-panic", chance, t, balloon, Math.round(strength * 1000));
}
function setWaterBalloonSurpriseBehavior(t, target, panic = false) {
if (!t || t.dead) return false;
t.lastPanicDetail = WATER_BALLOON_SURPRISE_TEXT;
t.thought = WATER_BALLOON_SURPRISE_TEXT;
if (typeof global.setBehavior === "function") {
global.setBehavior(t, {
actionId: panic ? "panic_escape" : "water_balloon_surprise",
need: "safety",
label: WATER_BALLOON_SURPRISE_TEXT,
text: WATER_BALLOON_SURPRISE_TEXT,
reason: WATER_BALLOON_SURPRISE_TEXT,
target: target || null,
phase: "perform",
source: "behavior",
tiedNeeds: ["safety"],
lockSeconds: panic ? 2.6 : 1.1,
minDuration: panic ? 1.2 : 0.55,
});
return true;
}
if (typeof global.setBehaviorText === "function") {
global.setBehaviorText(t, { need: "safety", actionId: panic ? "panic_escape" : "water_balloon_surprise", actionLabel: WATER_BALLOON_SURPRISE_TEXT, reasonText: WATER_BALLOON_SURPRISE_TEXT, target: target || null, phase: "perform", source: "behavior" });
return true;
}
return false;
}
function spawnWaterFromBalloon(balloon, worldRef) {
if (!balloon || !worldRef) return 0;
const baseR = Math.max(14, Number(balloon.r || 20) || 20);
const count = Math.max(7, Math.min(13, Math.round(baseR * 0.42)));
let spawned = 0;
for (let i = 0; i < count; i++) {
if (typeof global.Item !== "function") break;
const a = deterministicAngle(worldRef || balloon, "water-balloon-splash-angle", balloon, i);
const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.10, baseR * 0.58, balloon, i);
const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 150, 420, balloon, i);
const drop = new global.Item("water", balloon.x + Math.cos(a) * d, balloon.y + Math.sin(a) * d);
drop.r = deterministicRange(worldRef || balloon, "water-balloon-drop-radius", 5.5, 10.5, balloon, i);
drop.amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 10, 28, balloon, i);
drop.prevX = drop.x;
drop.prevY = drop.y;
drop.vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.18;
drop.vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.18;
drop.splashFlightTimer = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.30, 0.62, balloon, i);
drop.splashSourceId = balloon.id || "";
if (worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false })) spawned += 1;
else { worldRef.items?.push?.(drop); spawned += 1; }
if (i < 8) {
worldRef.effects?.push(new Effect("fight", drop.x, drop.y, {
size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i),
life: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-life", 0.24, 0.42, balloon, i),
color: "rgba(96,196,244,0.66)",
vx: drop.vx * 0.42,
vy: drop.vy * 0.42,
}));
}
}
if (spawned) {
worldRef.markItemBucketsDirty?.("water-balloon-splash");
worldRef.markSpatialDirty?.("water-balloon-splash");
worldRef.markTerrainDirtyAt?.(balloon.x, balloon.y, Math.max(70, baseR * 5.2), "water-balloon-splash");
}
return spawned;
}
function updateWaterDropMotion(item, dt, worldRef) {
if (!item || item.dead || item.type !== "water") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
const speed = Math.hypot(item.vx || 0, item.vy || 0);
if (speed <= 0.05 && !(item.splashFlightTimer > 0)) return false;
item.prevX = Number.isFinite(Number(item.x)) ? item.x : 0;
item.prevY = Number.isFinite(Number(item.y)) ? item.y : 0;
item.x += (Number(item.vx || 0) || 0) * frameDt;
item.y += (Number(item.vy || 0) || 0) * frameDt;
const pad = Math.max(28, CONFIG.worldPadding || 30);
const r = Math.max(5, Number(item.r || 8) || 8);
if (worldRef) {
if (item.x < pad) { item.x = pad; item.vx = Math.abs(item.vx || 0) * 0.30; }
if (item.x > worldRef.w - pad) { item.x = worldRef.w - pad; item.vx = -Math.abs(item.vx || 0) * 0.30; }
if (item.y < pad) { item.y = pad; item.vy = Math.abs(item.vy || 0) * 0.30; }
if (item.y > worldRef.h - pad) { item.y = worldRef.h - pad; item.vy = -Math.abs(item.vy || 0) * 0.30; }
}
item.splashFlightTimer = Math.max(0, Number(item.splashFlightTimer || 0) - frameDt);
const damp = item.splashFlightTimer > 0 ? Math.pow(0.78, frameDt * 60) : Math.pow(0.58, frameDt * 60);
item.vx *= damp;
item.vy *= damp;
if (Math.hypot(item.vx || 0, item.vy || 0) < 8 && item.splashFlightTimer <= 0) {
item.vx = 0;
item.vy = 0;
}
if (worldRef && ((item.lastSplashTrailAt || -999) + 0.12 < (worldRef.time || 0)) && Math.hypot(item.vx || 0, item.vy || 0) > 72) {
item.lastSplashTrailAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 }));
}
worldRef?.markSpatialDirty?.("water-drop-motion");
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(28, r * 3.2), "water-drop-motion");
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function applyWaterBalloonSurprise(balloon, worldRef) {
for (const t of worldRef?.nearbyTarinai?.(balloon.x, balloon.y, 165) || []) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const d = Math.max(1, distXY(balloon.x, balloon.y, t.x, t.y));
const strength = clamp(1 - d / 165, 0, 1);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.34 + strength * 0.44);
if (typeof t.addStress === "function") t.addStress(0.18 + strength * 0.42, "水風船が割れて驚いた");
if (typeof t.spawnBubble === "function" && strength > 0.35) t.spawnBubble("!", { life: 0.72 });
const awayX = (t.x - balloon.x) / d;
const awayY = (t.y - balloon.y) / d;
t.vx = clamp((t.vx || 0) + awayX * 68 * strength, -360, 360);
t.vy = clamp((t.vy || 0) + awayY * 68 * strength, -360, 360);
const panic = shouldPanicFromWaterBalloon(t, strength, worldRef, balloon);
if (panic) {
const threat = { x: balloon.x, y: balloon.y, dead: true, type: "water_balloon_pop" };
const destination = t.panicDestination?.(threat, true) || { x: t.x + awayX * 190, y: t.y + awayY * 190, dead: false, panic: true };
t.enterPanic?.({
threat,
destination,
target: destination,
reason: WATER_BALLOON_SURPRISE_TEXT,
cause: "water_balloon_pop",
fearTimer: 1.35 + strength * 1.30,
stress: 14 + strength * 24,
surpriseTimer: 0.70 + strength * 0.45,
wake: true,
forceDestination: true,
bubble: "!!",
bubbleCooldown: 0.7,
}) || t.setActionState?.("panic", { target: destination, reason: WATER_BALLOON_SURPRISE_TEXT, wake: true, sleeping: false });
t.fearTimer = Math.max(t.fearTimer || 0, 1.85 + strength * 1.40);
t.panicStartedAt = worldRef?.time || t.panicStartedAt || 0;
t.panicHardStopAt = Math.max(t.panicHardStopAt || 0, (worldRef?.time || 0) + 5.5);
t.lastPanicCause = "water_balloon_pop";
setWaterBalloonSurpriseBehavior(t, destination, true);
} else {
setWaterBalloonSurpriseBehavior(t, null, false);
}
}
}
function popBalloon(balloon, worldRef, cause = null) {
if (!balloon || balloon.dead) return;
const now = worldRef?.time || 0;
balloon.balloonPoppedAt = now;
// Item.dead is a getter based on amount, so mark the water balloon gone
// through amount/hp instead of assigning to .dead directly.
balloon.amount = 0;
balloon.hp = 0;
worldRef?.effects?.push(new Effect("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" }));
for (let i = 0; i < 9; i++) {
const a = deterministicAngle(worldRef || balloon, "water-balloon-pop-piece", balloon, i);
const s = deterministicRange(worldRef || balloon, "water-balloon-pop-speed", 24, 96, balloon, i);
worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s }));
}
spawnWaterFromBalloon(balloon, worldRef);
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火" : (cause?.type === "genkotsu" ? "げんこつ" : (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "何か"));
if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) {
worldRef.log?.(`水風船が${sourceLabel}に触れて割れた。`, "accident", { participants: [] });
}
applyWaterBalloonSurprise(balloon, worldRef);
worldRef?.markSpatialDirty?.("water-balloon-pop");
}
global.TarinaiItemDynamicBallSystem = Object.freeze({
update,
updateFan,
updateWaterDropMotion,
fanWindAt,
resolveObstacleCollisions,
popBalloon,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -342,7 +342,7 @@
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);

View file

@ -8,8 +8,6 @@
if (!item || item.dead) return false;
switch (item.type) {
case "firecracker":
case "flame_firecracker":
case "fire":
case "genkotsu":
case "rotator":
case "poison_block":
@ -20,12 +18,7 @@
case "rod":
return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false;
case "ball":
case "balloon":
return global.TarinaiItemDynamicBallSystem.update(item, dt, worldRef) !== false;
case "fan":
return global.TarinaiItemDynamicBallSystem.updateFan(item, dt, worldRef) !== false;
case "water":
return global.TarinaiItemDynamicBallSystem.updateWaterDropMotion(item, dt, worldRef) !== false;
case "duplicator":
return global.TarinaiItemDynamicDuplicatorSystem.update(item, dt, worldRef) !== false;
case "zunchi":

View file

@ -5,16 +5,13 @@
// links, and static gate-fence ticking. Item.prototype methods delegate here for
// direct Item.prototype calls; lifecycle dispatch also calls this boundary directly.
(function (global) {
function fireApi() {
const api = global.TarinaiFireRuntimeSystem;
if (!api) throw new Error("TarinaiFireRuntimeSystem must be loaded before fire item behavior is used");
return api;
}
function robotApi() {
const api = global.TarinaiRobotCleanerSystem;
if (!api) throw new Error("TarinaiRobotCleanerSystem must be loaded before robot cleaner behavior is used");
return api;
function updateFirecracker(item, dt, worldRef) {
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
if (item.fuseTimer <= 0) {
if (worldRef?.explodeFirecracker) worldRef.explodeFirecracker(item);
else item.amount = 0;
}
}
function updateGenkotsu(item, dt) {
@ -51,6 +48,330 @@
const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
const ROBOT_MOVE_SPEED = 60;
const ROBOT_HIGH_SPEED = 120;
const ROBOT_RESCAN_INTERVAL = 2.0;
const ROBOT_TARGET_BITS = Object.freeze({
zunchi: 1 << 0,
ant_corpse: 1 << 1,
tarinai: 1 << 2,
grass: 1 << 3,
grass_bed: 1 << 4,
water: 1 << 5,
});
const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water"]);
function robotCleanerDefaultMask() {
return ROBOT_DEFAULT_MASK;
}
function robotCleanerMask(item) {
const raw = Number(item?.robotCleanerMask);
return Number.isFinite(raw) ? (raw | 0) : ROBOT_DEFAULT_MASK;
}
function setRobotCleanerMask(item, mask = ROBOT_DEFAULT_MASK) {
if (!item || item.type !== "robot_cleaner") return false;
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(mask) | 0));
return true;
}
function robotCleanerHighSpeed(item) {
return Boolean(item?.robotCleanerHighSpeed);
}
function setRobotCleanerHighSpeed(item, enabled = false) {
if (!item || item.type !== "robot_cleaner") return false;
item.robotCleanerHighSpeed = Boolean(enabled);
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
return true;
}
function robotCleanerEffectiveSpeed(item) {
return robotCleanerHighSpeed(item) ? ROBOT_HIGH_SPEED : ROBOT_MOVE_SPEED;
}
function robotCleanerHasTarget(item, key = "") {
const bit = ROBOT_TARGET_BITS[String(key || "")];
return Boolean(bit && (robotCleanerMask(item) & bit));
}
function robotTargetLabel(key = "") {
switch (String(key || "")) {
case "zunchi": return "\u305a\u3093\u3061";
case "ant_corpse": return "\u6b7b\u9ab8";
case "trace": return "\u6b7b\u9ab8";
case "splat": return "\u8840\u75d5";
case "tarinai": return "\u305f\u308a\u306a\u3044";
case "grass": return "\u8349";
case "grass_bed": return "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9";
case "water": return "\u6c34\u6ef4";
default: return "\u5bfe\u8c61";
}
}
function normalizeRobotCleanerState(item) {
if (!item || item.type !== "robot_cleaner") return null;
if (!Number.isFinite(Number(item.robotCleanerMask))) item.robotCleanerMask = ROBOT_DEFAULT_MASK;
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask) | 0));
item.amount = Math.max(Number(item.amount || 0) || 0, 999);
if (!Number.isFinite(Number(item.robotCleanerScanTimer))) item.robotCleanerScanTimer = 0;
if (!Number.isFinite(Number(item.robotCleanerCleanFlash))) item.robotCleanerCleanFlash = 0;
if (!Number.isFinite(Number(item.robotCleanerAngle))) item.robotCleanerAngle = Number(item.angle || 0) || 0;
if (!Number.isFinite(Number(item.robotCleanerWanderAngle))) item.robotCleanerWanderAngle = Number(item.robotCleanerAngle || 0) || 0;
item.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed);
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
return item;
}
function robotTargetAlive(target) {
if (!target) return false;
return !target.dead && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y));
}
function robotTargetBlocked(item, target, worldRef) {
if (!item || !target || !worldRef) return false;
return Boolean(worldRef.targetBlockedByObstacle?.(item, target, Math.max(18, (item.r || 22) * 0.65)));
}
function currentRobotTarget(item, worldRef) {
const key = String(item?.robotCleanerTargetType || "");
const id = String(item?.robotCleanerTargetId || "");
if (!key || !id || !worldRef) return null;
if (key === "tarinai") {
const t = (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead);
return robotTargetAlive(t) && !robotTargetBlocked(item, t, worldRef) ? t : null;
}
const found = (worldRef.items || []).find(it => it && it.id === id && !it.dead && (
it.type === key || (key === "ant_corpse" && (it.type === "trace" || it.type === "splat"))
));
return robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null;
}
function robotTargetCandidates(item, worldRef) {
const out = [];
if (!worldRef || !item) return out;
const mask = robotCleanerMask(item);
worldRef.ensureItemBuckets?.("robot-cleaner-scan");
for (const key of ROBOT_TARGET_ORDER) {
const bit = ROBOT_TARGET_BITS[key];
if (!(mask & bit)) continue;
if (key === "tarinai") {
for (const t of worldRef.tarinai || []) {
if (!t || t.dead) continue;
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (robotTargetBlocked(item, t, worldRef)) continue;
out.push({ target: t, type: key });
}
continue;
}
const buckets = key === "ant_corpse"
? ["ant_corpse", "trace", "splat"].flatMap(type => worldRef.itemsOfType?.(type) || (worldRef.items || []).filter(it => it && it.type === type))
: (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key));
for (const it of buckets || []) {
if (!it || it.dead || it === item) continue;
if (robotTargetBlocked(item, it, worldRef)) continue;
out.push({ target: it, type: key });
}
}
return out;
}
function retargetRobotCleaner(item, worldRef, force = false) {
const current = currentRobotTarget(item, worldRef);
if (current && !force) return current;
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
let best = null;
let bestType = "";
let bestD = Infinity;
for (const rec of robotTargetCandidates(item, worldRef)) {
const target = rec.target;
if (!robotTargetAlive(target)) continue;
const d = distXY(item.x, item.y, target.x, target.y);
if (d < bestD) { best = target; bestType = rec.type; bestD = d; }
}
if (best) {
item.robotCleanerTargetId = best.id || "";
item.robotCleanerTargetType = bestType || best.type || "";
}
return best;
}
function triggerRobotPanic(item, target, worldRef, force = false) {
if (!item || !target || target.dead) return false;
const now = Number(worldRef?.time || target.world?.time || 0) || 0;
if (!force && now < Number(target.robotCleanerPanicCooldownUntil || 0)) return false;
target.robotCleanerPanicCooldownUntil = now + 0.65;
const reason = "\u30ed\u30dc\u6383\u9664\u6a5f\u304b\u3089\u9003\u3052\u3066\u3044\u308b";
if (typeof target.enterPanic === "function") {
target.enterPanic({ threat: item, reason, fear: 0.86, wake: true, cause: "robot_cleaner", surpriseTimer: 0.18, awakeLockTimer: 0.45 });
} else {
target.fearTimer = Math.max(target.fearTimer || 0, 0.86);
target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(item, true) : null, reason, wake: true });
}
return true;
}
function isRobotTarinaiTarget(target) {
return Boolean(target && (target.constructor?.name === "Tarinai" || (target.thought !== undefined && target.name && target.die)));
}
function robotTargetTypeForContact(item, target) {
if (!item || !target || target.dead) return "";
if (isRobotTarinaiTarget(target)) return robotCleanerHasTarget(item, "tarinai") ? "tarinai" : "";
const type = String(target.type || "");
if ((type === "ant_corpse" || type === "trace" || type === "splat") && robotCleanerHasTarget(item, "ant_corpse")) return type;
return robotCleanerHasTarget(item, type) ? type : "";
}
function robotCleanerCaptureRadius(item, target) {
return Math.max(18, (item?.r || 22) * 0.72 + (target?.r || target?.radius || 10) * 0.72);
}
function clearRobotTrackedTargetIfMatched(item, target) {
if (!item || !target) return false;
const id = String(target.id || "");
if (!id || String(item.robotCleanerTargetId || "") !== id) return false;
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
item.robotCleanerScanTimer = 0;
return true;
}
function cleanRobotTarget(item, target, worldRef, typeOverride = "") {
if (!item || !target || !worldRef || target.dead) return false;
const type = String(typeOverride || item.robotCleanerTargetType || target.type || "");
const label = robotTargetLabel(type);
let removed = false;
const cleanReason = "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u5438\u3044\u8fbc\u307e\u308c\u305f";
if (type === "tarinai" || isRobotTarinaiTarget(target)) {
triggerRobotPanic(item, target, worldRef, true);
if (typeof target.die === "function") {
target.die(cleanReason, { lowHealth: true });
removed = true;
} else {
target.dead = true;
target.deathReason = cleanReason;
worldRef.markDead?.(target, cleanReason);
removed = true;
}
} else {
removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, {
reason: "robot-cleaner",
userReason: type === "grass_bed" ? "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u7247\u4ed8\u3051\u3089\u308c\u305f" : cleanReason,
wake: true,
breaker: item,
}));
if (!removed) {
target.amount = 0;
target.hp = 0;
target.dead = true;
target.removedReason = "robot-cleaner";
removed = true;
}
}
if (!removed) return false;
clearRobotTrackedTargetIfMatched(item, target);
item.robotCleanerScanTimer = 0;
item.robotCleanerCleanFlash = 0.42;
item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1;
worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }));
worldRef.markItemBucketsDirty?.("robot-cleaner");
worldRef.markSpatialDirty?.("robot-cleaner");
if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner");
worldRef.updateItemCounts?.();
worldRef.drawListDirty = true;
if ((worldRef.time || 0) - Number(item.robotCleanerLastLogAt || -999) > 1.2) {
worldRef.log?.(`\u30ed\u30dc\u6383\u9664\u6a5f\u304c${label}\u3092\u7247\u4ed8\u3051\u305f\u3002`, "observe");
item.robotCleanerLastLogAt = worldRef.time || 0;
}
return true;
}
function cleanRobotContacts(item, worldRef) {
if (!item || !worldRef) return false;
const seen = new Set();
let changed = false;
for (const rec of robotTargetCandidates(item, worldRef)) {
const target = rec?.target;
if (!robotTargetAlive(target)) continue;
const key = String(target.id || `${rec.type}:${target.x}:${target.y}`);
if (seen.has(key)) continue;
seen.add(key);
const type = robotTargetTypeForContact(item, target) || rec.type || "";
if (!type) continue;
const capture = robotCleanerCaptureRadius(item, target);
if (distXY(item.x, item.y, target.x, target.y) <= capture) {
if (cleanRobotTarget(item, target, worldRef, type)) changed = true;
}
}
return changed;
}
function moveRobotCleaner(item, dt, target, worldRef) {
const r = Math.max(14, Number(item.r || 22) || 22);
const speed = robotCleanerEffectiveSpeed(item);
let desiredAngle = Number(item.robotCleanerWanderAngle || 0) || 0;
let desiredSpeed = speed;
if (target && robotTargetAlive(target)) {
desiredAngle = Math.atan2((target.y || 0) - (item.y || 0), (target.x || 0) - (item.x || 0));
desiredSpeed = speed;
} else if ((item.robotCleanerWanderTimer || 0) <= 0) {
desiredAngle = global.deterministicAngle ? global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2)) : Math.random() * Math.PI * 2;
item.robotCleanerWanderTimer = global.deterministicRange ? global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item) : (1.2 + Math.random() * 2.0);
}
item.robotCleanerWanderTimer = Math.max(0, Number(item.robotCleanerWanderTimer || 0) - dt);
item.robotCleanerWanderAngle = desiredAngle;
const vx = Math.cos(desiredAngle) * desiredSpeed;
const vy = Math.sin(desiredAngle) * desiredSpeed;
const blend = Math.min(1, dt * 6.2);
item.vx = (Number(item.vx || 0) || 0) + (vx - (Number(item.vx || 0) || 0)) * blend;
item.vy = (Number(item.vy || 0) || 0) + (vy - (Number(item.vy || 0) || 0)) * blend;
item.prevX = item.x;
item.prevY = item.y;
item.x += item.vx * dt;
item.y += item.vy * dt;
const minX = CONFIG.worldPadding + r * 0.5;
const minY = CONFIG.worldPadding + r * 0.5;
const maxX = Math.max(minX, (worldRef.w || 0) - CONFIG.worldPadding - r * 0.5);
const maxY = Math.max(minY, (worldRef.h || 0) - CONFIG.worldPadding - r * 0.5);
let bounced = false;
if (item.x < minX) { item.x = minX; item.vx = Math.abs(item.vx) * 0.35; bounced = true; }
if (item.x > maxX) { item.x = maxX; item.vx = -Math.abs(item.vx) * 0.35; bounced = true; }
if (item.y < minY) { item.y = minY; item.vy = Math.abs(item.vy) * 0.35; bounced = true; }
if (item.y > maxY) { item.y = maxY; item.vy = -Math.abs(item.vy) * 0.35; bounced = true; }
if (bounced) {
item.robotCleanerWanderAngle = Math.atan2(item.vy || 0, item.vx || 0) + (global.deterministicRange ? global.deterministicRange(worldRef, "robot-cleaner-bounce", -0.8, 0.8, item) : (Math.random() - 0.5) * 1.6);
item.robotCleanerWanderTimer = 0.4;
}
if (Math.hypot(item.vx || 0, item.vy || 0) > 2) item.robotCleanerAngle = Math.atan2(item.vy || 0, item.vx || 0);
worldRef.markSpatialDirty?.("robot-cleaner-move");
worldRef.drawListDirty = true;
}
function updateRobotCleaner(item, dt, worldRef) {
if (!item || item.dead || !worldRef) return false;
normalizeRobotCleanerState(item);
dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0));
item.robotCleanerCleanFlash = Math.max(0, Number(item.robotCleanerCleanFlash || 0) - dt * 2.8);
item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt);
let target = currentRobotTarget(item, worldRef);
if (!target || item.robotCleanerScanTimer <= 0) {
target = retargetRobotCleaner(item, worldRef, true);
item.robotCleanerScanTimer = ROBOT_RESCAN_INTERVAL;
}
moveRobotCleaner(item, dt, target, worldRef);
if (target && robotTargetAlive(target)) {
const isTarinaiTarget = String(item.robotCleanerTargetType || "") === "tarinai" || isRobotTarinaiTarget(target);
const d = distXY(item.x, item.y, target.x, target.y);
if (isTarinaiTarget && d <= Math.max(96, (item.r || 22) + (target.radius || target.r || 12) + 70)) triggerRobotPanic(item, target, worldRef);
}
cleanRobotContacts(item, worldRef);
return true;
}
function updateGateFence(item) {
if (item) item.amount = 999;
return Boolean(item);
@ -58,15 +379,13 @@
function update(item, dt, worldRef) {
if (!item || item.dead) return false;
if (item.type === "firecracker") return fireApi().updateFirecracker(item, dt, worldRef);
if (item.type === "flame_firecracker") return fireApi().updateFlameFirecracker(item, dt, worldRef);
if (item.type === "fire") return fireApi().updateFire(item, dt, worldRef);
if (item.type === "firecracker") return updateFirecracker(item, dt, worldRef);
if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef);
if (item.type === "rotator") return updateRotator(item, dt, worldRef);
if (item.type === "poison_block") return updatePoisonBlock(item, dt, worldRef);
if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef);
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
if (item.type === "robot_cleaner") return robotApi().updateRobotCleaner(item, dt, worldRef);
if (item.type === "robot_cleaner") return updateRobotCleaner(item, dt, worldRef);
if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef);
if (item.type === "rod") return updateRigidLink(item, dt, worldRef);
return false;
@ -74,28 +393,17 @@
global.TarinaiItemDynamicToolSystem = Object.freeze({
update,
robotCleanerDefaultMask: (...args) => robotApi().robotCleanerDefaultMask(...args),
robotCleanerMask: (...args) => robotApi().robotCleanerMask(...args),
setRobotCleanerMask: (...args) => robotApi().setRobotCleanerMask(...args),
robotCleanerHasTarget: (...args) => robotApi().robotCleanerHasTarget(...args),
robotCleanerHighSpeed: (...args) => robotApi().robotCleanerHighSpeed(...args),
setRobotCleanerHighSpeed: (...args) => robotApi().setRobotCleanerHighSpeed(...args),
robotCleanerEffectiveSpeed: (...args) => robotApi().robotCleanerEffectiveSpeed(...args),
robotTargetLabel: (...args) => robotApi().robotTargetLabel(...args),
get robotCleanerTargetBits() { return robotApi().robotCleanerTargetBits; },
get robotCleanerTargetOrder() { return robotApi().robotCleanerTargetOrder; },
get robotCleanerMoveSpeed() { return robotApi().robotCleanerMoveSpeed; },
get robotCleanerHighMoveSpeed() { return robotApi().robotCleanerHighMoveSpeed; },
get fireBurningText() { return fireApi().fireBurningText; },
get fireSeenText() { return fireApi().fireSeenText; },
fireTempMultiplier: (...args) => fireApi().fireTempMultiplier(...args),
contactWaterDrop: (...args) => fireApi().contactWaterDrop(...args),
consumeWaterForFire: (...args) => fireApi().consumeWaterForFire(...args),
panicTarinaiFromFire: (...args) => fireApi().panicTarinaiFromFire(...args),
igniteAntByFire: (...args) => fireApi().igniteAntByFire(...args),
igniteGrassByFire: (...args) => fireApi().igniteGrassByFire(...args),
spawnFireAt: (...args) => fireApi().spawnFireAt(...args),
scatterFireFrom: (...args) => fireApi().scatterFireFrom(...args),
compactWorldFires: (...args) => fireApi().compactWorldFires(...args),
robotCleanerDefaultMask,
robotCleanerMask,
setRobotCleanerMask,
robotCleanerHasTarget,
robotCleanerHighSpeed,
setRobotCleanerHighSpeed,
robotCleanerEffectiveSpeed,
robotTargetLabel,
robotCleanerTargetBits: ROBOT_TARGET_BITS,
robotCleanerTargetOrder: ROBOT_TARGET_ORDER,
robotCleanerMoveSpeed: ROBOT_MOVE_SPEED,
robotCleanerHighMoveSpeed: ROBOT_HIGH_SPEED,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -73,36 +73,8 @@
}
}
function updateBurningLifecycle(item, dt, worldRef) {
if (!item || item.dead || item.type !== "zunchi" || !(item.burning || (item.burnTimer || 0) > 0.02)) return false;
item.burning = true;
item.burnTimer = Math.max(0, Number(item.burnTimer || 0) - dt);
item.spawnGrace = 0;
item.freshness = clamp((item.freshness ?? 1) - dt * 0.62, 0, 1);
item.amount = Math.max(0, Number(item.amount || 0) - dt * 30);
item.stageTimer = Math.max(0, Number(item.stageTimer || 0) + dt * 1.8);
if (item.stage === "fresh" && item.freshness < 0.45) { item.stage = "dry"; item.stageTimer = 0; }
if (item.stage === "dry" && item.freshness < 0.18) { item.stage = "decomposing"; item.stageTimer = 0; item.fertility = Math.max(item.fertility || 0, 0.18); }
if (worldRef?.effects && typeof Effect === "function" && global.deterministicChance(worldRef, "zunchi-fire-effect", dt * 1.15, item)) {
worldRef.effects.push(new Effect("flame", item.x + deterministicRange(worldRef, "zunchi-fire-x", -5, 5, item), item.y - Math.max(4, (item.r || 14) * 0.25), {
vx: deterministicRange(worldRef, "zunchi-fire-vx", -3, 3, item),
vy: deterministicRange(worldRef, "zunchi-fire-vy", -10, -4, item),
size: deterministicRange(worldRef, "zunchi-fire-size", 6, 12, item),
life: deterministicRange(worldRef, "zunchi-fire-life", 0.18, 0.34, item),
color: "rgba(236,46,24,0.72)",
}));
}
if (item.burnTimer <= 0.01) item.burning = false;
if ((item.amount || 0) <= 0.5) item.amount = 0;
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-burning");
worldRef.drawListDirty = true;
return true;
}
function updateLifecycle(item, dt, worldRef) {
if (!item || item.dead || item.type !== "zunchi") return false;
updateBurningLifecycle(item, dt, worldRef);
const rainy = worldRef.weather === "light_rain";
const rainBoost = rainy ? 1.28 : 1;
const groundId = worldRef?.groundType || "soil";
@ -136,5 +108,5 @@
return true;
}
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, updateLifecycle, updateBurningLifecycle });
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, updateLifecycle });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -26,12 +26,6 @@
changed = true;
item.foodServingsRemaining = Math.max(0, before - lost);
item.amount = item.foodServingsRemaining;
const resized = (typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
if (resized) {
worldRef?.markItemBucketsDirty?.("food-passive-resize");
worldRef?.markSpatialDirty?.("food-passive-resize");
if (worldRef) worldRef.drawListDirty = true;
}
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true });
if (item.foodServingsRemaining <= 0.015) item.amount = 0;

View file

@ -11,14 +11,23 @@
"TarinaiItemGrowthLifecycleStep",
]);
const runner = global.TarinaiUpdateStepPipelineRunner.create(
CONCRETE_STEP_NAMES,
(step, item, dt, worldRef, context) => step.update(item, dt, worldRef, context)
);
let cachedSteps = null;
function concreteSteps() {
if (!cachedSteps) cachedSteps = CONCRETE_STEP_NAMES.map(name => global[name]);
return cachedSteps;
}
function updateOne(item, dt, worldRef, options = {}) {
if (!item || item.dead) return false;
return runner.run(item, dt, worldRef, options.context || {});
const steps = concreteSteps();
if (steps.length !== CONCRETE_STEP_NAMES.length) return false;
const context = options.context || {};
for (const step of steps) {
const result = step.update(item, dt, worldRef, context) || {};
if (result.done) return result.ok !== false;
}
return true;
}
global.TarinaiItemLifecyclePipeline = Object.freeze({ updateOne });

View file

@ -1,87 +0,0 @@
"use strict";
// Shared item rendering helpers. Field rendering stays in item_render_runtime.js.
function segmentPathCacheKey(item, segments, extra = "") {
const version = Number(item?._mechanicalShapeVersion || 0) || 0;
const len = Array.isArray(segments) ? segments.length : 0;
let h = len * 2654435761;
if (Array.isArray(segments)) {
const step = Math.max(1, Math.floor(len / 24));
for (let i = 0; i < len; i += step) {
const seg = segments[i];
if (!Array.isArray(seg)) continue;
for (let j = 0; j < 4; j += 1) {
h ^= Math.round((Number(seg[j]) || 0) * 10) & 0xffff;
h = Math.imul(h, 16777619);
}
}
}
return `${item?.type || ""}|${version}|${len}|${h >>> 0}|${extra}`;
}
function getSegmentPath(item, segments, extra = "") {
if (typeof Path2D === "undefined" || !Array.isArray(segments)) return null;
const key = segmentPathCacheKey(item, segments, extra);
const cache = item._segmentRenderPathCache || (item._segmentRenderPathCache = Object.create(null));
if (cache.key === key && cache.path) return cache.path;
const path = new Path2D();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
path.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
path.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
cache.key = key;
cache.path = path;
return path;
}
function strokeSegments(ctx, item, segments, extra = "") {
const path = getSegmentPath(item, segments, extra);
if (path) {
ctx.stroke(path);
return;
}
ctx.beginPath();
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
}
function mechanicalRenderTier() {
return window.TarinaiPerf.renderQualityTier();
}
function itemShadowQualityTier() {
return window.TarinaiPerf?.shadowQualityTier?.() || "high";
}
function itemShadowsEnabled() {
return itemShadowQualityTier() !== "off";
}
function drawRopeParticlePath(ctx, particles, ox, oy) {
if (!Array.isArray(particles) || particles.length < 2) return false;
const tier = mechanicalRenderTier();
const stride = tier === "low" && particles.length > 10 ? 2 : 1;
ctx.moveTo((Number(particles[0].x) || 0) - ox, (Number(particles[0].y) || 0) - oy);
for (let i = stride; i < particles.length - 1; i += stride) {
ctx.lineTo((Number(particles[i].x) || 0) - ox, (Number(particles[i].y) || 0) - oy);
}
const last = particles[particles.length - 1];
ctx.lineTo((Number(last.x) || 0) - ox, (Number(last.y) || 0) - oy);
return true;
}
function drawRopeRuntimePath(ctx, particles, item, pb, ax, ay, bx, by) {
if (particles && drawRopeParticlePath(ctx, particles, item.x, item.y)) return true;
const midX = Number(pb?.linkScalar?.(item, "midX", NaN));
const midY = Number(pb?.linkScalar?.(item, "midY", NaN));
const mx = Number.isFinite(midX) ? midX - item.x : (ax + bx) * 0.5;
const my = Number.isFinite(midY) ? midY - item.y : (ay + by) * 0.5;
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(mx, my, bx, by);
return true;
}

View file

@ -1,6 +1,74 @@
"use strict";
// Item field rendering. Kept separate from construction and lifecycle logic.
function segmentPathCacheKey(item, segments, extra = "") {
const version = Number(item?._mechanicalShapeVersion || 0) || 0;
const len = Array.isArray(segments) ? segments.length : 0;
let h = len * 2654435761;
if (Array.isArray(segments)) {
const step = Math.max(1, Math.floor(len / 24));
for (let i = 0; i < len; i += step) {
const seg = segments[i];
if (!Array.isArray(seg)) continue;
for (let j = 0; j < 4; j += 1) {
h ^= Math.round((Number(seg[j]) || 0) * 10) & 0xffff;
h = Math.imul(h, 16777619);
}
}
}
return `${item?.type || ""}|${version}|${len}|${h >>> 0}|${extra}`;
}
function getSegmentPath(item, segments, extra = "") {
if (typeof Path2D === "undefined" || !Array.isArray(segments)) return null;
const key = segmentPathCacheKey(item, segments, extra);
const cache = item._segmentRenderPathCache || (item._segmentRenderPathCache = Object.create(null));
if (cache.key === key && cache.path) return cache.path;
const path = new Path2D();
for (const seg of segments) {
if (!Array.isArray(seg) || seg.length < 4) continue;
path.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
path.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
cache.key = key;
cache.path = path;
return path;
}
function strokeSegments(ctx, item, segments, extra = "") {
const path = getSegmentPath(item, segments, extra);
if (path) {
ctx.stroke(path);
return;
}
ctx.beginPath();
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
}
ctx.stroke();
}
function mechanicalRenderTier() {
return window.TarinaiPerf.renderQualityTier();
}
function drawRopeParticlePath(ctx, particles, ox, oy) {
if (!Array.isArray(particles) || particles.length < 2) return false;
const tier = mechanicalRenderTier();
const stride = tier === "low" && particles.length > 10 ? 2 : 1;
ctx.moveTo((Number(particles[0].x) || 0) - ox, (Number(particles[0].y) || 0) - oy);
for (let i = stride; i < particles.length - 1; i += stride) {
ctx.lineTo((Number(particles[i].x) || 0) - ox, (Number(particles[i].y) || 0) - oy);
}
const last = particles[particles.length - 1];
ctx.lineTo((Number(last.x) || 0) - ox, (Number(last.y) || 0) - oy);
return true;
}
Object.assign(Item.prototype, {
draw(ctx, t, lighting = null) {
const lightState = lighting || getLightingState(world);
@ -13,12 +81,12 @@ draw(ctx, t, lighting = null) {
}
const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null;
const forceOpaque = this.type === "slave_chain" || this.type === "golden_crown";
const visibleAlpha = forceOpaque ? 1 : (isServingFoodType(this.type) ? clamp(0.72 + (servingRatio ?? 1) * 0.28, 0.72, 1) : clamp(this.amount / 40, 0.24, 1));
const visibleAlpha = forceOpaque ? 1 : (isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1));
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170;
const dropY = dropT > 0 ? -dropFallHeight * dropT : 0;
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
if (this.type !== "trace" && this.type !== "splat" && shadow.alpha > 0) {
if (this.type !== "trace" && this.type !== "splat") {
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
const zunchiImg = zunchiId ? getRenderableImage(zunchiId, "zunchi") : null;
if (zunchiImg) {
@ -61,11 +129,9 @@ draw(ctx, t, lighting = null) {
const img = getRenderableImage("genkotsu", "genkotsu");
const impact = clamp(this.impactFlash || 0, 0, 1);
ctx.save();
if (itemShadowsEnabled()) {
ctx.shadowColor = "rgba(54,42,31,0.26)";
ctx.shadowBlur = 5 + impact * 8;
ctx.shadowOffsetY = 4;
}
ctx.shadowColor = "rgba(54,42,31,0.26)";
ctx.shadowBlur = 5 + impact * 8;
ctx.shadowOffsetY = 4;
const squashY = 1 - impact * 0.08;
const stretchX = 1 + impact * 0.05;
ctx.scale(stretchX, squashY);
@ -104,11 +170,9 @@ draw(ctx, t, lighting = null) {
ctx.save();
if (stuck) ctx.scale((this.pinVisualSide || 1) < 0 ? -1 : 1, 1);
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
if (itemShadowsEnabled()) {
ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 2;
}
ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 2;
if (img) {
const metrics = getImageMetrics(assetId);
const ratio = metrics?.ratio || (stuck ? 1 : 2.0);
@ -279,86 +343,6 @@ draw(ctx, t, lighting = null) {
ctx.beginPath();
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "pipe") {
const r = this.r || 36;
ctx.save();
const body = styleNight ? "#506d5f" : (styleWarm ? "#7f9a72" : "#6f8d75");
const side = styleNight ? "#3e574d" : (styleWarm ? "#637f5d" : "#587260");
const lip = styleNight ? "#779083" : (styleWarm ? "#adc49b" : "#94b199");
const lipDark = styleNight ? "#2f4640" : "#486858";
const inner = styleNight ? "#0d1514" : "#152420";
const outline = styleNight ? "rgba(9,15,15,0.86)" : "rgba(30,50,42,0.78)";
const topY = -r * 0.42;
const bottomY = r * 0.54;
const topRx = r * 1.04;
const topRy = r * 0.43;
const bottomRx = r * 0.94;
const bottomRy = r * 0.34;
// Shadow under the visible front edge.
ctx.fillStyle = "rgba(18,24,22,0.20)";
ctx.beginPath();
ctx.ellipse(0, r * 0.73, r * 1.12, r * 0.30, 0, 0, Math.PI * 2);
ctx.fill();
// Cylindrical side wall: the lower edge is visible because the pipe is viewed from above-front.
ctx.fillStyle = side;
ctx.strokeStyle = outline;
ctx.lineWidth = Math.max(2.2, r * 0.07);
ctx.beginPath();
ctx.moveTo(-topRx, topY);
ctx.bezierCurveTo(-topRx * 0.98, topY + r * 0.30, -bottomRx * 1.02, bottomY - r * 0.16, -bottomRx, bottomY);
ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, Math.PI, 0, true);
ctx.bezierCurveTo(topRx * 1.02, bottomY - r * 0.16, topRx * 0.98, topY + r * 0.30, topRx, topY);
ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI, true);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Front lower rim shade.
ctx.fillStyle = body;
ctx.beginPath();
ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, 0, Math.PI);
ctx.lineTo(bottomRx * 0.88, bottomY + r * 0.08);
ctx.ellipse(0, bottomY + r * 0.08, bottomRx * 0.88, bottomRy * 0.58, 0, 0, Math.PI, true);
ctx.closePath();
ctx.fill();
// Top lip and visible round opening.
ctx.fillStyle = lip;
ctx.strokeStyle = outline;
ctx.lineWidth = Math.max(2.6, r * 0.08);
ctx.beginPath();
ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = inner;
ctx.strokeStyle = styleNight ? "rgba(2,7,7,0.88)" : "rgba(14,26,23,0.90)";
ctx.lineWidth = Math.max(2.0, r * 0.065);
ctx.beginPath();
ctx.ellipse(0, topY + r * 0.02, r * 0.64, r * 0.24, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = lipDark;
ctx.lineWidth = Math.max(1.4, r * 0.045);
ctx.beginPath();
ctx.ellipse(0, topY + r * 0.02, r * 0.78, r * 0.32, 0, Math.PI * 0.04, Math.PI * 0.96);
ctx.stroke();
// Upper-left highlight for the rim and side.
ctx.globalAlpha = 0.34;
ctx.fillStyle = "rgba(255,255,255,0.86)";
ctx.beginPath();
ctx.ellipse(-r * 0.42, topY - r * 0.17, r * 0.24, r * 0.055, -0.10, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.22;
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.lineWidth = Math.max(1.1, r * 0.026);
ctx.beginPath();
ctx.moveTo(-r * 0.72, -r * 0.10);
ctx.bezierCurveTo(-r * 0.66, r * 0.18, -r * 0.62, r * 0.32, -r * 0.48, r * 0.50);
ctx.stroke();
ctx.restore();
} else if (this.type === "robot_cleaner") {
ctx.save();
const flash = clamp(Number(this.robotCleanerCleanFlash || 0) || 0, 0, 1);
@ -493,14 +477,14 @@ draw(ctx, t, lighting = null) {
} else if (this.type === "signboard") {
ctx.save();
const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12);
const boardHalfW = this.r * 1.34;
const boardHalfW = this.r * 1.20;
boardGrad.addColorStop(0, styleNight ? "#d5cab8" : "#fff8e8");
boardGrad.addColorStop(0.55, styleNight ? "#c0b29a" : "#f3e3c6");
boardGrad.addColorStop(1, styleNight ? "#aa9a82" : "#dfc397");
ctx.fillStyle = styleNight ? "#8a7557" : "#d6ba87";
ctx.strokeStyle = styleNight ? "#5d4d3b" : "#9b7444";
ctx.lineWidth = 2.2;
roundedRect(ctx, -this.r * 0.13, this.r * 0.04, this.r * 0.26, this.r * 1.48, this.r * 0.04);
roundedRect(ctx, -this.r * 0.13, -this.r * 0.02, this.r * 0.26, this.r * 1.78, this.r * 0.04);
ctx.fill();
ctx.stroke();
ctx.fillStyle = boardGrad;
@ -552,12 +536,10 @@ draw(ctx, t, lighting = null) {
const r = this.r || 21;
const time = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0);
ctx.save();
if (itemShadowsEnabled()) {
ctx.shadowColor = "rgba(90, 178, 255, 0.30)";
ctx.shadowBlur = 11;
}
ctx.shadowColor = "rgba(90, 178, 255, 0.30)";
ctx.shadowBlur = 11;
// \u65E7\u7248\u306E\u6C37\u584A\u98A8\u304B\u3089\u3001\u767D\u3044\u4FDD\u51B7\u30C7\u30E5\u30EF\u30FC\u7F36\u98A8\u306E\u5916\u89B3\u3078\u5909\u66F4\u3002
// 旧版の氷塊風から、白い保冷デュワー缶風の外観へ変更。
const bodyW = r * 1.38;
const bodyH = r * 1.56;
const x = -bodyW / 2;
@ -590,7 +572,7 @@ draw(ctx, t, lighting = null) {
ctx.font = `bold ${Math.max(9, Math.round(r * 0.34))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("CO\u2082", 0, y + bodyH * 0.35);
ctx.fillText("CO", 0, y + bodyH * 0.35);
ctx.strokeStyle = "rgba(255,255,255,0.72)";
ctx.lineWidth = 1.2;
@ -617,10 +599,8 @@ draw(ctx, t, lighting = null) {
} else if (this.type === "stove") {
const r = this.r || 24;
ctx.save();
if (itemShadowsEnabled()) {
ctx.shadowColor = "rgba(255, 120, 43, 0.34)";
ctx.shadowBlur = 9;
}
ctx.shadowColor = "rgba(255, 120, 43, 0.34)";
ctx.shadowBlur = 9;
ctx.fillStyle = styleNight ? "rgba(96, 72, 58, 0.96)" : "rgba(150, 104, 76, 0.96)";
ctx.strokeStyle = styleNight ? "rgba(49, 35, 29, 0.78)" : "rgba(92, 62, 45, 0.72)";
ctx.lineWidth = 2.2;
@ -685,200 +665,22 @@ draw(ctx, t, lighting = null) {
ctx.fill();
}
ctx.restore();
} else if (this.type === "balloon") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);
const r = this.r || 20;
const wobble = Math.sin(t * 3.0 + (this.seed || 0) * 0.037 + (this.balloonWobble || 0)) * 0.10 + (this.spin || 0) * 0.05;
ctx.save();
ctx.rotate(wobble);
if (itemShadowsEnabled()) {
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(68,34,45,0.16)";
ctx.shadowBlur = 2 + moving * 4;
ctx.shadowOffsetY = 2;
}
const grad = ctx.createRadialGradient(-r * 0.34, -r * 0.54, r * 0.16, 0, -r * 0.18, r * 1.18);
grad.addColorStop(0, "rgba(232,250,255,0.98)");
grad.addColorStop(0.44, "rgba(96,196,238,0.94)");
grad.addColorStop(1, "rgba(37,126,198,0.90)");
ctx.fillStyle = grad;
ctx.strokeStyle = "rgba(34,82,125,0.70)";
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.ellipse(0, -r * 0.18, r * 0.82, r * 1.06, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowColor = "transparent";
ctx.fillStyle = "rgba(255,255,255,0.50)";
ctx.beginPath();
ctx.ellipse(-r * 0.28, -r * 0.62, r * 0.18, r * 0.28, -0.42, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(35,106,168,0.88)";
ctx.beginPath();
ctx.moveTo(-r * 0.18, r * 0.78);
ctx.lineTo(r * 0.18, r * 0.78);
ctx.lineTo(0, r * 1.12);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(61,113,150,0.58)";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(0, r * 1.06);
ctx.bezierCurveTo(-r * 0.18, r * 1.34, r * 0.20, r * 1.52, -r * 0.04, r * 1.76);
ctx.stroke();
if (moving > 0.42) {
ctx.globalAlpha *= 0.10 + moving * 0.12;
ctx.strokeStyle = "rgba(231,249,255,0.90)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(-r * 0.10, -r * 0.18, r * 0.92, -1.92, -0.95);
ctx.stroke();
}
ctx.restore();
} else if (this.type === "fan") {
const r = this.r || 26;
const angle = itemAngleFor(this);
const spin = (this.fanSpin || 0) + t * 10.5;
ctx.save();
ctx.rotate(angle);
const windRange = 320;
const windStart = r * 1.02;
const windSpread = 0.62;
const windHalfStart = 26 + windStart * Math.tan(windSpread);
const windHalfEnd = 26 + windRange * Math.tan(windSpread);
const fanAlpha = ctx.globalAlpha;
ctx.globalAlpha = fanAlpha * 0.16;
ctx.fillStyle = "rgba(132,190,220,0.34)";
ctx.strokeStyle = "rgba(80,155,205,0.46)";
ctx.lineWidth = 1.4;
ctx.setLineDash([10, 8]);
ctx.beginPath();
ctx.moveTo(windStart, -windHalfStart);
ctx.lineTo(windRange, -windHalfEnd);
ctx.quadraticCurveTo(windRange + r * 0.36, 0, windRange, windHalfEnd);
ctx.lineTo(windStart, windHalfStart);
ctx.quadraticCurveTo(r * 1.28, 0, windStart, -windHalfStart);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.setLineDash([]);
// Wind direction indicator: centerline + arrowheads. The cone shows the
// affected range; the arrows make the actual airflow direction explicit.
ctx.globalAlpha = fanAlpha * 0.52;
ctx.strokeStyle = "rgba(46,132,200,0.70)";
ctx.fillStyle = "rgba(46,132,200,0.62)";
ctx.lineWidth = 2.1;
ctx.setLineDash([16, 10]);
ctx.beginPath();
ctx.moveTo(windStart + r * 0.55, 0);
ctx.lineTo(windRange - r * 0.50, 0);
ctx.stroke();
ctx.setLineDash([]);
for (let i = 0; i < 3; i++) {
const x = windStart + r * 2.0 + i * ((windRange - windStart - r * 3.2) / 2);
const wobbleY = Math.sin(t * 2.4 + i * 1.7) * r * 0.035;
const arrow = Math.max(9, r * 0.42);
ctx.beginPath();
ctx.moveTo(x + arrow * 0.55, wobbleY);
ctx.lineTo(x - arrow * 0.55, wobbleY - arrow * 0.48);
ctx.lineTo(x - arrow * 0.30, wobbleY);
ctx.lineTo(x - arrow * 0.55, wobbleY + arrow * 0.48);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = fanAlpha * 0.34;
ctx.strokeStyle = "rgba(132,190,220,0.42)";
ctx.lineWidth = 2.0;
for (let i = -2; i <= 2; i++) {
const y = i * r * 0.28 + Math.sin(t * 3 + i) * r * 0.08;
ctx.beginPath();
ctx.moveTo(r * 0.95, y);
ctx.bezierCurveTo(r * 1.55, y - r * 0.22, r * 2.30, y + r * 0.20, r * 3.05, y - r * 0.08);
ctx.stroke();
}
ctx.globalAlpha = fanAlpha;
ctx.restore();
ctx.save();
ctx.rotate(angle);
if (itemShadowsEnabled()) {
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(52,40,26,0.16)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 2;
}
ctx.strokeStyle = "rgba(75,92,105,0.88)";
ctx.fillStyle = "rgba(221,231,235,0.96)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, r * 0.82, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowColor = "transparent";
ctx.strokeStyle = "rgba(96,117,130,0.42)";
ctx.lineWidth = 1.0;
for (let i = 0; i < 8; i++) {
const a = i * Math.PI / 4;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * r * 0.76, Math.sin(a) * r * 0.76);
ctx.stroke();
}
ctx.save();
ctx.rotate(spin);
ctx.fillStyle = "rgba(126,170,196,0.88)";
ctx.strokeStyle = "rgba(62,82,96,0.44)";
ctx.lineWidth = 1.1;
for (let i = 0; i < 3; i++) {
ctx.rotate(Math.PI * 2 / 3);
ctx.beginPath();
ctx.ellipse(r * 0.30, 0, r * 0.38, r * 0.13, 0.10, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.restore();
ctx.fillStyle = "rgba(65,78,86,0.94)";
ctx.beginPath();
ctx.arc(0, 0, r * 0.16, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(62,82,96,0.74)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-r * 0.22, r * 0.84);
ctx.lineTo(-r * 0.22, r * 1.35);
ctx.stroke();
ctx.fillStyle = "rgba(91,104,112,0.88)";
ctx.beginPath();
ctx.ellipse(-r * 0.22, r * 1.42, r * 0.62, r * 0.16, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(65,78,86,0.74)";
ctx.stroke();
ctx.fillStyle = "rgba(92,116,132,0.96)";
ctx.beginPath();
ctx.moveTo(r * 0.82, 0);
ctx.lineTo(r * 1.08, -r * 0.14);
ctx.lineTo(r * 1.08, r * 0.14);
ctx.closePath();
ctx.fill();
ctx.restore();
} 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);
if (itemShadowsEnabled()) {
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)";
ctx.shadowBlur = 2 + moving * 3;
ctx.shadowOffsetY = 2;
}
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)";
ctx.shadowBlur = 2 + moving * 3;
ctx.shadowOffsetY = 2;
// 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, 0);
ctx.fillText("⚽", 0, 0);
if (moving > 0.55) {
ctx.globalAlpha *= 0.10 + moving * 0.10;
ctx.shadowColor = "transparent";
@ -888,53 +690,33 @@ draw(ctx, t, lighting = null) {
ctx.fill();
}
ctx.restore();
} else if (this.type === "fire") {
const r = this.r || 14;
const phase = t * 7.0 + this.seed;
ctx.save();
ctx.globalAlpha *= 0.90;
if (typeof drawSimpleFireShape === "function") {
drawSimpleFireShape(ctx, 0, 0, r * 1.12, { phase, shadow: itemShadowsEnabled() });
} else {
ctx.fillStyle = "rgba(236,46,24,0.88)";
ctx.beginPath();
ctx.ellipse(0, -r * 0.18, r * 0.48, r * 0.96, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "firecracker" || this.type === "flame_firecracker") {
const flameMode = this.type === "flame_firecracker";
} else if (this.type === "firecracker") {
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;
const timerText = String(Math.max(0, Math.ceil(this.fuseTimer ?? 5)));
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
ctx.fillStyle = flash > 0.65 ? "#ffed77" : (flameMode ? "#d8241f" : "#5a4744");
ctx.strokeStyle = flameMode ? "rgba(96,18,18,0.88)" : "rgba(35,30,29,0.88)";
ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43";
ctx.strokeStyle = "rgba(96,48,34,0.78)";
ctx.lineWidth = 2;
roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
ctx.fill();
ctx.stroke();
ctx.fillStyle = flameMode ? "#ff6b32" : "#897065";
ctx.fillStyle = "#f4d15b";
ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
ctx.strokeStyle = "rgba(48,38,31,0.86)";
ctx.strokeStyle = "rgba(74,55,39,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(0, -this.r * 0.88);
ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
ctx.stroke();
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : (flameMode ? "rgba(255,80,40,0.78)" : "rgba(188,148,88,0.62)");
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)";
ctx.beginPath();
ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,246,214,0.92)";
ctx.strokeStyle = flameMode ? "rgba(84,18,14,0.92)" : "rgba(24,22,21,0.92)";
ctx.lineWidth = 2.6;
ctx.font = `bold ${Math.max(10, this.r * 0.72)}px ui-rounded, sans-serif`;
ctx.fillStyle = "rgba(42,36,29,0.66)";
ctx.font = "bold 10px ui-rounded, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.strokeText(timerText, 0, this.r * 0.02);
ctx.fillText(timerText, 0, this.r * 0.02);
ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
} else if (this.type === "fence_v" || this.type === "fence_h" || this.type === "glass_wall" || this.type === "bounce_fence" || this.type === "bounce_fence_v" || this.type === "gate_fence") {
const bounce = this.type === "bounce_fence" || this.type === "bounce_fence_v";
const gate = this.type === "gate_fence";
@ -1082,8 +864,8 @@ draw(ctx, t, lighting = null) {
strokeSegments(ctx, this, segments, "body");
ctx.setLineDash([]);
ctx.restore();
// v39.15.53: \u6BD2\u30D6\u30ED\u30C3\u30AF\u306F\u7269\u7406\u8F2A\u90ED\u3060\u3051\u3092\u5B89\u5B9A\u63CF\u753B\u3059\u308B\u3002
// \u4E2D\u592E\u306E\u5857\u308A\u3064\u3076\u3057/\u6587\u5B57\u306F\u30AD\u30E3\u30C3\u30B7\u30E5\u66F4\u65B0\u6642\u306B\u660E\u6EC5\u3057\u3066\u898B\u3048\u308B\u305F\u3081\u63CF\u753B\u3057\u306A\u3044\u3002
// v39.15.53: 毒ブロックは物理輪郭だけを安定描画する。
// 中央の塗りつぶし/文字はキャッシュ更新時に明滅して見えるため描画しない。
} else if (this.type === "reciprocator") {
const bodyAngle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem;
@ -1125,10 +907,8 @@ draw(ctx, t, lighting = null) {
ctx.globalAlpha *= powered ? 1 : 0.72;
ctx.strokeStyle = styleNight ? "#4f7192" : "#6da6cf";
ctx.lineWidth = thick;
if (itemShadowsEnabled()) {
ctx.shadowColor = powered ? (styleNight ? "rgba(86,130,180,0.34)" : "rgba(80,150,205,0.28)") : "rgba(74,82,90,0.20)";
ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.16) : Math.max(4, thick * 0.28));
}
ctx.shadowColor = powered ? (styleNight ? "rgba(86,130,180,0.34)" : "rgba(80,150,205,0.28)") : "rgba(74,82,90,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") {
@ -1184,12 +964,26 @@ draw(ctx, t, lighting = null) {
ctx.strokeStyle = styleNight ? "rgba(180,150,116,0.88)" : "rgba(132,94,54,0.92)";
ctx.lineWidth = 4.2;
ctx.beginPath();
drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by);
if (particles) {
drawRopeParticlePath(ctx, particles, this.x, this.y);
} else {
const mx = Number.isFinite(Number(pb?.linkScalar?.(this, "midX", NaN))) ? Number(pb.linkScalar(this, "midX", 0)) - this.x : (ax + bx) * 0.5;
const my = Number.isFinite(Number(pb?.linkScalar?.(this, "midY", NaN))) ? Number(pb.linkScalar(this, "midY", 0)) - this.y : (ay + by) * 0.5;
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(mx, my, bx, by);
}
ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(236,214,180,0.56)" : "rgba(255,236,190,0.52)";
ctx.lineWidth = 1.5;
ctx.beginPath();
drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by);
if (particles) {
drawRopeParticlePath(ctx, particles, this.x, this.y);
} else {
const mx = Number.isFinite(Number(pb?.linkScalar?.(this, "midX", NaN))) ? Number(pb.linkScalar(this, "midX", 0)) - this.x : (ax + bx) * 0.5;
const my = Number.isFinite(Number(pb?.linkScalar?.(this, "midY", NaN))) ? Number(pb.linkScalar(this, "midY", 0)) - this.y : (ay + by) * 0.5;
ctx.moveTo(ax, ay);
ctx.quadraticCurveTo(mx, my, bx, by);
}
ctx.stroke();
}
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)");

View file

@ -1,6 +1,6 @@
"use strict";
// Tool definitions and basic type helpers.
// 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" },
@ -13,7 +13,7 @@ const TOOL_DEFINITIONS = Object.freeze({
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\u3084\u706b\u3092\u304d\u308c\u3044\u306b\u3059\u308b\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" },
@ -33,25 +33,20 @@ const TOOL_DEFINITIONS = Object.freeze({
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" },
slave_chain: { id: "slave_chain", itemType: "slave_chain", label: "\u5974\u96B7\u306E\u9396", placeable: true, scalable: true, radius: 15, amount: 1, iconText: "\u26D3", tooltip: "\u89E6\u308C\u305F\u305F\u308A\u306A\u3044\u3092\u5F37\u5236\u7684\u306B\u305A\u3093\u3061\u3069\u308C\u3044\u306B\u3059\u308B\u3002" },
golden_crown: { id: "golden_crown", itemType: "golden_crown", label: "\u9EC4\u91D1\u306E\u51A0", placeable: true, scalable: true, radius: 16, amount: 1, iconText: "\u265B", tooltip: "\u89E6\u308C\u305F\u305F\u308A\u306A\u3044\u3092\u5F37\u5236\u7684\u306B\u305F\u308A\u306A\u3044\u738B\u306B\u3059\u308B\u3002" },
slave_chain: { id: "slave_chain", itemType: "slave_chain", label: "奴隷の鎖", placeable: true, scalable: true, radius: 15, amount: 1, iconText: "⛓", tooltip: "触れたたりないを強制的にずんちどれいにする。" },
golden_crown: { id: "golden_crown", itemType: "golden_crown", label: "黄金の冠", placeable: true, scalable: true, radius: 16, amount: 1, iconText: "♛", tooltip: "触れたたりないを強制的にたりない王にする。" },
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" },
pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u4e2d\u306b\u5165\u3063\u3066\u7720\u308c\u308b\u4e38\u3044\u571f\u7ba1\u3002\u51fa\u308b\u6642\u306f\u30e9\u30f3\u30c0\u30e0\u306a\u571f\u7ba1\u304b\u3089\u51fa\u308b\u3002\u5bdd\u5fc3\u5730\u306f\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u6bb5\u4e0b\u3002" },
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "ドライアイス", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "🧊", collisionShape: "circle", tooltip: "周囲の気温を10℃下げる。効果は重ねられる。" },
stove: { id: "stove", itemType: "stove", label: "ストーブ", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "🔥", collisionShape: "circle", tooltip: "周囲の気温を10℃上げる。効果は重ねられる。" },
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" },
balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "水の入った風船。軽くて風に流される。画鋲・火・げんこつで割れ、水が飛び散って近くのたりないが驚く。" },
fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。水風船やボールを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" },
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\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: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: false, scalable: false, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", simulationOnly: true },
flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u30bf\u30a4\u30de\u30fc\u5f8c\u306b\u7206\u767a\u305b\u305a\u3001\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\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" },
@ -71,21 +66,21 @@ const TOOL_DEFINITIONS = Object.freeze({
});
const TOOL_METADATA = global.TarinaiItemToolMetadata;
if (!TOOL_METADATA) throw new Error("TarinaiItemToolMetadata must be loaded before item_tool_definitions.js");
const {
ITEM_TRAITS,
FENCE_ITEM_TYPES,
MECHANICAL_ITEM_TYPES,
PHYSICS_TOOL_IDS,
ROTATABLE_ITEM_TYPES,
PIN_BEHAVIORS,
TOOL_CATEGORIES,
TOOL_SIZE_ORDER,
TOOL_SIZE_LABELS,
TOOL_SIZE_SCALES,
} = TOOL_METADATA;
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "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", "slave_chain", "golden_crown", "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", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
});
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence"]));
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "glass_wall", "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 isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); }
function defaultItemAngle(type = "") {
@ -110,7 +105,6 @@ function itemHasTrait(type = "", trait = "") {
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isNestContainerType(type = "") { const id = String(type || ""); return id === "nest_box" || id === "pipe"; }
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;
@ -120,12 +114,24 @@ function lodgedPinFor(tarinai = null, worldRef = 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;
}
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", "slave_chain", "golden_crown"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "dry_ice", "stove", "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));
@ -157,6 +163,9 @@ 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";
@ -184,7 +193,6 @@ function toolTipsFromDefinitions() {
itemHasTrait,
isPinType,
isSleepFurnitureType,
isNestContainerType,
lodgedPinFor,
hasLodgedPinEffect,
pinBehaviorFor,

View file

@ -1,56 +0,0 @@
"use strict";
// Static item/tool metadata shared by tool definitions and UI helpers.
(function (global) {
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "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", "slave_chain", "golden_crown", "sleep_drug"]),
hazard: new Set(["firecracker", "flame_firecracker", "fire", "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", "balloon", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]),
draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
});
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence"]));
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]);
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"]));
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 },
});
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", "slave_chain", "golden_crown"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "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", "flame_firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] },
]);
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 });
global.TarinaiItemToolMetadata = Object.freeze({
ITEM_TRAITS,
FENCE_ITEM_TYPES,
MECHANICAL_ITEM_TYPES,
PHYSICS_TOOL_IDS,
ROTATABLE_ITEM_TYPES,
PIN_BEHAVIORS,
TOOL_CATEGORIES,
TOOL_SIZE_ORDER,
TOOL_SIZE_LABELS,
TOOL_SIZE_SCALES,
});
Object.assign(global, {
TOOL_SIZE_ORDER,
TOOL_SIZE_LABELS,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -7,8 +7,8 @@ function initializeItemTypeState(item, type, x, y) {
if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true;
if (type === "water" || type === "zunda_juice") item.roles.drink = true;
if (["sweet", "water", "zunda_juice", "slave_chain", "golden_crown"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true;
if (type === "bed" || type === "nest_box" || type === "pipe") item.roles.sleepPlace = true;
if (["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true;
if (type === "bed" || type === "nest_box") item.roles.sleepPlace = 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));
@ -26,12 +26,9 @@ function initializeItemTypeState(item, type, x, y) {
item.comfort = rand(0.86, 1.14);
item.wear = 0;
}
if (type === "pipe") {
item.comfort = rand(0.66, 0.82);
item.wear = 0;
}
if (type === "signboard") {
item.text = "";
item.textEditedAt = -999;
}
if (type === "duplicator") {
item.storedFoodType = "";
@ -55,36 +52,18 @@ function initializeItemTypeState(item, type, x, y) {
item.robotCleanerHighSpeed = false;
item.robotCleanerSpeed = globalThis.TarinaiItemDynamicToolSystem?.robotCleanerMoveSpeed ?? 60;
}
if (type === "ball" || type === "balloon") {
const light = type === "balloon";
item.vx = rand(light ? -18 : -5, light ? 18 : 5);
item.vy = rand(light ? -14 : -5, light ? 14 : 5);
if (type === "ball") {
item.vx = rand(-5, 5);
item.vy = rand(-5, 5);
item.prevX = x;
item.prevY = y;
item.spin = rand(0, Math.PI * 2);
item.spinVelocity = rand(light ? -0.8 : -1.6, light ? 0.8 : 1.6);
item.spinVelocity = rand(-1.6, 1.6);
item.lastKickedAt = -999;
item.lastKickerId = "";
item.lastPokedAt = -999;
item.pokeCombo = 0;
item.lastPokeAngle = rand(0, Math.PI * 2);
if (light) {
item.balloonWobble = rand(0, Math.PI * 2);
item.balloonPoppedAt = -999;
}
}
if (type === "fan") {
item.amount = 999;
item.angle = defaultItemAngle(type);
item.fanSpin = rand(0, Math.PI * 2);
item.fanPulse = rand(0, Math.PI * 2);
item.lastFanEffectAt = -999;
item.lastFanLogAt = -999;
item.fanSwingOn = false;
item.fanSwingCenter = item.angle;
item.fanSwingRange = 35 * Math.PI / 180;
item.fanSwingSpeed = 48 * Math.PI / 180;
item.fanSwingPhase = 0;
}
if (type === "zunchi") {
item.amount = Math.max(Number(item.amount || 0) || 0, 240);
@ -105,20 +84,13 @@ function initializeItemTypeState(item, type, x, y) {
}
if (type === "ant_corpse") {
item.amount = 24;
item.workerSprite = "ant_worker";
item.decayTimer = 0;
}
if (type === "firecracker" || type === "flame_firecracker") {
if (type === "firecracker") {
item.fuseTimer = 5;
item.fuseMax = 5;
}
if (type === "fire") {
item.amount = Math.max(Number(item.amount || 0) || 0, 120);
item.r = Math.max(Number(item.r || 0) || 0, 14);
item.flameLife = Math.max(Number(item.flameLife || 0) || 0, 10.0);
item.flameSpreadCooldown = 0;
item.flamePanicCooldown = 0;
item.roles.danger = true;
}
if (type === "genkotsu") {
item.amount = 100;
item.dropMax = 0;
@ -135,7 +107,14 @@ function initializeItemTypeState(item, type, x, y) {
item.prevY = y;
item.spin = rand(-0.35, 0.35);
item.spinVelocity = rand(-5.4, 5.4);
globalThis.TarinaiPinAttachmentSystem?.resetLooseState?.(item);
item.pinState = "loose";
item.pinTargetId = "";
item.pinAttachAngle = 0;
item.pinAttachDistance = 0;
item.pinOffsetY = 0;
item.pinDamageTick = 0;
item.pinFallCheckTimer = 0;
item.pinLogAt = -999;
item.deletable = true;
}
@ -145,6 +124,7 @@ function initializeItemTypeState(item, type, x, y) {
}
if (type === "gate_fence") {
item.gateOpen = false;
item.gateLastToggleAt = -999;
}
if (type === "poison_block") {
@ -183,6 +163,7 @@ function initializeItemTypeState(item, type, x, y) {
hazard: null,
sleep: { awakeUntil: 0 },
};
item.rotatorEditorOpenAt = -999;
item.amount = 999;
}
if (type === "rope" || type === "rod") {
@ -227,7 +208,6 @@ function initializeItemTypeState(item, type, x, y) {
item.foodServingsRemaining = item.foodServingsMax;
item.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval());
item.amount = item.foodServingsRemaining;
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
}
return item;
}

View file

@ -5,13 +5,13 @@
// helpers and the item scheduler both read this single policy instead of
// duplicating item-type timing rules.
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]);
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "robot_cleaner"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = Object.freeze([
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo",
"ball", "genkotsu", "firecracker", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod",
]);
function mobilePerfHint() {
@ -29,12 +29,6 @@
if (PIN_ITEM_TYPES.has(item.type)) return 0;
if (item.isStructure && item.type === "plushie") return 0;
if (item.isStructure && item.type === "grass_bed") return 3.0;
if (item.type === "fire") {
const fireCount = item.world?.itemsOfType?.("fire")?.length || 0;
if (fireCount > 64) return mobile ? 0.55 : 0.35;
if (fireCount > 36) return mobile ? 0.42 : 0.25;
return mobile ? 0.30 : 0.18;
}
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi") return 2.4;
if (item.type === "grass") return Infinity;

View file

@ -55,25 +55,19 @@
].join(":");
}
function scheduleDelay(item, interval, initial = false) {
const key = item?.id || item?._schedulerToken || item?.type || "item";
const salt = initial ? "item-scheduler-initial" : `item-scheduler:${Math.floor((item?._schedulerLastAt || 0) * 3)}`;
const unit = typeof stableUnit === "function" ? stableUnit(key, salt) : Math.random();
const spread = initial ? (0.42 + unit * 1.18) : (0.88 + unit * 0.24);
return interval * spread;
}
function scheduleItem(state, item, now, interval = itemUpdateInterval(item)) {
if (!item || item.dead || !Number.isFinite(interval)) return false;
if (interval <= 0) {
state.realtime.push(item);
item._schedulerRealtime = true;
return true;
}
const token = (item._schedulerToken || 0) + 1;
item._schedulerToken = token;
const initial = !Number.isFinite(item._schedulerLastAt);
if (initial) item._schedulerLastAt = now;
heapPush(state.heap, { time: now + scheduleDelay(item, interval, initial), item, token });
item._schedulerRealtime = false;
if (!Number.isFinite(item._schedulerLastAt)) item._schedulerLastAt = now;
heapPush(state.heap, { time: now + interval, item, token });
return true;
}
@ -110,7 +104,8 @@
if (!item || item.dead) continue;
const interval = itemUpdateInterval(item);
if (!Number.isFinite(interval)) {
continue;
item._schedulerRealtime = false;
continue;
}
if (interval <= 0) {
global.TarinaiItemRuntime.updateOne(item, dt, worldRef);
@ -140,7 +135,8 @@
if (!Number.isFinite(interval)) continue;
if (interval <= 0) {
state.realtime.push(item);
continue;
item._schedulerRealtime = true;
continue;
}
const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval)));
global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef);
@ -153,9 +149,19 @@
}
function physicsBudgetFor(worldRef) {
const tier = global.TarinaiPerf.renderQualityTier();
const itemCount = (worldRef?.items || []).length || 0;
const activeHint = worldRef?._itemUpdateRealtime?.length || 0;
const mobile = Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
if (mobile && tier !== "high") return { maxPairs: 112, maxLinks: 72, postMaxPairs: 28, maxSubsteps: 2 };
if (mobile) return { maxPairs: 150, maxLinks: 96, postMaxPairs: 40, maxSubsteps: 2 };
if (tier === "low") return { maxPairs: 140, maxLinks: 90, postMaxPairs: 36, maxSubsteps: 2 };
if (tier === "medium" || tier === "mid") {
const crowded = itemCount > 64 && activeHint <= 2;
return crowded
? { maxPairs: 150, maxLinks: 105, postMaxPairs: 44, maxSubsteps: 2 }
: { maxPairs: 190, maxLinks: 130, postMaxPairs: 60, maxSubsteps: 3 };
}
return itemCount > 80
? { maxPairs: 240, maxLinks: 170, postMaxPairs: 78, maxSubsteps: 4 }
: { maxPairs: 300, maxLinks: 210, postMaxPairs: 100, maxSubsteps: 5 };

View file

@ -111,24 +111,6 @@
"fieldScale": 1,
"accessibleShapeTraits": ["round", "soccer ball"],
"emojiFallback": "\u26bd"
},
"balloon": {
"renderer": "vector_balloon",
"shape": "round_water_balloon_with_string",
"palette": { "fill": "#58bde8", "stroke": "rgba(38,83,123,0.76)", "highlight": "rgba(255,255,255,0.72)" },
"scale": 1.05,
"uiPreviewScale": 1.06,
"fieldScale": 1.05,
"accessibleShapeTraits": ["round", "water balloon", "string", "blue"]
},
"fan": {
"renderer": "vector_fan",
"shape": "directional_fan",
"palette": { "fill": "#d7e2ea", "stroke": "rgba(58,74,84,0.74)", "accent": "rgba(92,142,174,0.88)" },
"scale": 1.08,
"uiPreviewScale": 1.08,
"fieldScale": 1.08,
"accessibleShapeTraits": ["fan", "directional", "wind"]
}
}`;

View file

@ -14,29 +14,6 @@ function foodServingsForSize(size = "medium") {
function isServingFoodType(type = "") {
return FOOD_SERVING_TYPES.has(type);
}
function foodServingSizeForRemaining(remaining = FOOD_SERVINGS_BY_SIZE.medium) {
const n = Math.max(0, Number(remaining) || 0);
if (n <= FOOD_SERVINGS_BY_SIZE.small + 0.05) return "small";
if (n <= FOOD_SERVINGS_BY_SIZE.medium + 0.05) return "medium";
return "large";
}
function foodServingVisualScaleFor(item) {
if (!item || !isServingFoodType(item.type)) return 1;
const remaining = Math.max(0, Number(item.foodServingsRemaining ?? item.amount ?? FOOD_SERVINGS_BY_SIZE.medium) || 0);
const ratio = remaining / FOOD_SERVINGS_BY_SIZE.medium;
return clamp(Math.sqrt(Math.max(0.04, ratio)), 0.72, 1.35);
}
function updateServingFoodVisualSize(item) {
if (!item || !isServingFoodType(item.type)) return false;
const baseRadius = itemRadiusFor(item.type, 12);
const scale = foodServingVisualScaleFor(item);
const nextR = Math.max(5, baseRadius * scale);
const changed = Math.abs((Number(item.r) || 0) - nextR) > 0.05;
item.r = nextR;
item.foodServingScale = scale;
item.foodServingVisualSize = foodServingSizeForRemaining(item.foodServingsRemaining ?? item.amount);
return changed;
}
const PASSIVE_FOOD_DECAY_PER_SECOND = 0.004;
const GRASS_STAGE_COUNT = 5;
@ -271,7 +248,7 @@ function drawBulletSprite(ctx, r) {
function isConsumableSpriteType(type = "") {
const key = String(type || "");
return ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "water", "zunda_juice", "mercury"].includes(key)
return ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(key)
|| isParamEffectItemType(key);
}
@ -341,10 +318,6 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {})
ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.46, -localR * 0.05, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.72);
ctx.stroke();
}
} else if (["water", "zunda_juice", "mercury"].includes(key)) {
const visual = itemVisualFor(key);
const palette = visual?.palette || {};
drawOvalWaterDropSprite(ctx, localR * (visual?.fieldScale || visual?.scale || 1), { fill: palette.fill, stroke: palette.stroke, highlight: palette.highlight || palette.accent });
} else if (key === "sleep_drug") {
drawSleepTabletSprite(ctx, localR * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug"));
} else if (isParamEffectItemType(key)) {
@ -455,7 +428,6 @@ function previewItemRadiusFor(type = "") {
if (type === "rotator") return 25;
if (type === "reciprocator") return 26;
if (type === "nest_box") return 26;
if (type === "pipe") return 24;
if (type === "duplicator") return 24;
if (type === "signboard") return 22;
if (type === "bed") return 22;
@ -581,7 +553,6 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
genkotsu: 0.52,
bed: 0.80,
nest_box: 0.78,
pipe: 0.84,
signboard: 0.76,
duplicator: 0.82,
fence_v: 0.70,
@ -593,21 +564,17 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
reciprocator: 0.76,
ant_nest: 0.92,
ball: 0.92,
balloon: 0.86,
fan: 0.82,
pushpin: 0.92,
oshibyo: 0.62,
firecracker: 0.90,
flame_firecracker: 0.90,
fire: 1.05,
giant_drug: 1.22,
dwarf_drug: 0.56,
};
let scale = scaleMap[type] || 0.92;
if (isConsumableSpriteType(type)) scale *= 1.95;
else if (["grass", "water", "zunda_juice", "mercury", "fire"].includes(type)) scale *= 1.85;
else if (["grass", "water", "zunda_juice"].includes(type)) scale *= 1.85;
else if (type === "duplicator") scale *= 1.55;
else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "fire", "genkotsu"].includes(type)) scale *= 1.08;
else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "genkotsu"].includes(type)) scale *= 1.08;
else if (isParamEffectItemType(type)) scale *= 1.35;
if (watermark) scale *= 1.42;
ctx.scale(scale, scale);
@ -627,9 +594,6 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
if (typeof window !== "undefined") {
window.isConsumableSpriteType = isConsumableSpriteType;
window.foodServingSizeForRemaining = foodServingSizeForRemaining;
window.foodServingVisualScaleFor = foodServingVisualScaleFor;
window.updateServingFoodVisualSize = updateServingFoodVisualSize;
window.drawConsumableFieldSprite = drawConsumableFieldSprite;
window.drawStoredConsumableSprite = drawStoredConsumableSprite;
window.drawToolItemPreview = drawToolItemPreview;

View file

@ -98,7 +98,7 @@ function loop() {
const endRender = window.TarinaiPerf.begin("render.total");
render();
if (endRender) endRender();
window.TarinaiPerf.endFrame();
window.TarinaiPerf.endFrame(rawDt, window.__tarinaiFps || 0);
if (window.TarinaiPerf.consumeResizeRequest() && typeof resizeCanvas === "function") resizeCanvas();
statsTimer += dt;
const statsInterval = 2.5;
@ -136,7 +136,8 @@ loadImages((done, total) => {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js";
const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.20.16";
navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined);
});
}

View file

@ -3,27 +3,85 @@
// Layer: physics/mechanical-body
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
(function (global) {
const footprints = global.TarinaiCollisionFootprints;
function num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function rectCorners(rect, padding = 0) {
return footprints?.rectCorners?.(rect, padding) || [];
const pad = Math.max(0, num(padding));
if (!rect) return [];
if (!rect.oriented) {
return [
{ x: num(rect.left) - pad, y: num(rect.top) - pad },
{ x: num(rect.right) + pad, y: num(rect.top) - pad },
{ x: num(rect.right) + pad, y: num(rect.bottom) + pad },
{ x: num(rect.left) - pad, y: num(rect.bottom) + pad },
];
}
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
const hw = num(rect.halfW) + pad;
const hh = num(rect.halfH) + pad;
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
x: num(rect.cx) + lx * c - ly * ss,
y: num(rect.cy) + lx * ss + ly * c,
}));
}
function rectAxes(rect) {
return footprints?.rectAxes?.(rect) || [{ x: 1, y: 0 }, { x: 0, y: 1 }];
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
return [{ x: c, y: ss }, { x: -ss, y: c }];
}
function projectPoints(points, axis) {
return footprints?.projectPoints?.(points, axis) || { min: Infinity, max: -Infinity };
let min = Infinity;
let max = -Infinity;
for (const p of points || []) {
const v = num(p.x) * axis.x + num(p.y) * axis.y;
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
function aabbOverlap(a, b, padding = 0) {
const pad = Math.max(0, num(padding));
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
}
function rectOverlapInfo(a, b, padding = 0) {
return footprints?.rectOverlapInfo?.(a, b, padding) || null;
if (!aabbOverlap(a, b, padding)) return null;
const ptsA = rectCorners(a, padding);
const ptsB = rectCorners(b, padding);
if (!ptsA.length || !ptsB.length) return null;
let bestOverlap = Infinity;
let bestAxis = null;
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
const len = Math.hypot(axis.x, axis.y) || 1;
const n = { x: axis.x / len, y: axis.y / len };
const pa = projectPoints(ptsA, n);
const pb = projectPoints(ptsB, n);
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
if (overlap <= 0) return null;
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
}
if (!bestAxis) return null;
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
return {
overlap: bestOverlap,
nx: bestAxis.x,
ny: bestAxis.y,
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
};
}
function typeOf(itemOrType) { return typeof itemOrType === "string" ? itemOrType : String(itemOrType?.type || ""); }
function isMechanicalType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" || t === "poison_block" || t === "reciprocator"; }
@ -40,16 +98,6 @@
}
function bodyOf(item) { return ensureBody(item, { syncFromLegacy: false }); }
function usingStepScratch(item) { return item?._physicsStepScratch === true; }
function runMechanicalStep(item, commitReason, fn) {
const prevScratch = item._physicsStepScratch === true;
item._physicsStepScratch = true;
try {
return fn();
} finally {
item._physicsStepScratch = prevScratch;
commitBody(item, commitReason);
}
}
function bodyPose(item) { return usingStepScratch(item) ? null : (bodyOf(item)?.pose || null); }
function bodyVelocity(item) { return bodyOf(item)?.velocity || null; }
function bodyMotor(item) { return bodyOf(item)?.motor || null; }
@ -263,7 +311,10 @@
}
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
return global.TarinaiGeometry.orientedRectAabb(cx, cy, halfW, halfH, angle);
const c = Math.cos(angle), s = Math.sin(angle);
const ex = Math.abs(c) * halfW + Math.abs(s) * halfH;
const ey = Math.abs(s) * halfW + Math.abs(c) * halfH;
return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s };
}
function worldSegments(item) {
@ -617,6 +668,7 @@
obj.impulseVy = clamp(num(obj.impulseVy) + mvy * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320));
}
applyPassiveReciprocatorImpulse(rect.item, rect.axisX || 1, rect.axisY || 0, nX, nY, vx, vy, num(opts.passiveImpulseScale, 1));
reverseReciprocatorOnCircleContact(rect.item, obj, nX, nY, 0.016, obj.world || rect.item?.world || null, "circle");
applied = true;
} else if (rect.poisonBlock) {
const omega = clamp(num(rect.angularVelocity), -5.5, 5.5);
@ -780,7 +832,7 @@
return true;
}
function reverseReciprocatorOnContact(item, nx, ny, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
function reverseReciprocatorOnMechanicalContact(item, nx, ny, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
// Only physical body contacts should interrupt the rail. Static fences and
@ -814,7 +866,7 @@
return true;
}
function brakeRotatorOnContact(item, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
function brakePoweredRotatorOnMechanicalContact(item, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
if (!item || item.dead || item.type !== "rotator" || !isPowered(item)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
if ((item._rotatorContactBrakeAt || -999) + 0.045 > now) return false;
@ -842,7 +894,7 @@
const t = String(obj.type || "");
// Tarinai also collide as circles, but a walking creature should not flip a
// powered rail. Keep contact reversal to item-like physical circles.
if (t === "ball" || t === "stone" || t === "genkotsu" || t === "firecracker" || t === "pushpin" || t === "oshibyo" || t === "zunchi") return true;
if (t === "ball" || t === "stone" || t === "genkotsu" || t === "firecracker" || t === "pushpin" || t === "oshibyo") return true;
return Boolean(obj.physicsBody && !isMechanicalType(t));
}
@ -900,69 +952,48 @@
}
function resolveReciprocatorPhysicalContacts(item, dt = 0.016, worldRef = null) {
// Circle contacts are solved as push-away contacts in resolveMechanicalCircleContacts().
// A powered reciprocator must reverse at rail limits or mechanical blockers,
// not merely because it touched Tarinai, a ball, or a pin-like item.
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item) || !worldRef?.items) return false;
return resolveMechanicalCircleContacts(item, dt, worldRef, reach(item) + 96);
}
function resolveMechanicalCircleContacts(item, dt = 0.016, worldRef = null, radius = null) {
if (!item || item.dead || !isMechanicalType(item) || !worldRef?.items) return false;
const rects = obstacleRects(item);
if (!rects.length) return false;
const queryRadius = radius || reach(item) + 96;
const source = worldRef.nearbyItems?.(item.x, item.y, queryRadius, true) || worldRef.nearbyObstacles?.(item.x, item.y, queryRadius, false) || worldRef.items || [];
let changed = false;
let contacts = 0;
const radius = reach(item) + 90;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items || [];
const a = axis(item);
const dir = Math.sign(ps(item, "railDir", 1)) || 1;
const driveX = a.x * dir;
const driveY = a.y * dir;
let best = null;
for (const other of source) {
if (!other || other === item || other.dead || !isPhysicalCircleContactObject(other)) continue;
if (typeof isPinType === "function" && isPinType(other.type) && other.pinState === "lodged") continue;
const rr = itemCircleRadius(other);
if (Math.hypot(num(other.x) - num(item.x), num(other.y) - num(item.y)) > queryRadius + rr + 12) continue;
let best = null;
for (const rect of rects) {
const info = orientedRectCircleContactInfo(rect, other, rr, 2.75);
const info = orientedRectCircleContactInfo(rect, other, rr, 1.25);
if (!info) continue;
if (!best || info.overlap > best.overlap) best = info;
const aheadNormal = driveX * info.nx + driveY * info.ny;
const aheadCenter = (num(other.x) - num(item.x)) * driveX + (num(other.y) - num(item.y)) * driveY;
// A reciprocator is a moving bar. When a circle is centered on the
// bar stroke, the geometric normal often points sideways, so use the
// rail-direction center test as the authoritative "hit the front" test.
if (aheadNormal < 0.16 && aheadCenter < -rr * 0.35) continue;
const contactInfo = aheadNormal >= 0.16 ? info : { ...info, nx: driveX, ny: driveY };
const score = Math.max(aheadNormal, 0) * 1000 + Math.max(0, aheadCenter) + num(info.overlap);
if (!best || score > best.score) best = { other, info: contactInfo, score };
}
if (!best) continue;
const nx = Number.isFinite(best.nx) ? best.nx : 1;
const ny = Number.isFinite(best.ny) ? best.ny : 0;
const sep = Math.max(0.5, Math.min(28, best.overlap + 1.4));
if (Number.isFinite(other.x)) other.x += nx * sep;
if (Number.isFinite(other.y)) other.y += ny * sep;
const v = pointVelocity(item, best.x, best.y);
const closing = Math.max(0, v.x * nx + v.y * ny);
const baseImpulse = clamp(best.overlap * 7.2 + closing * 0.24 + 8.0, 5.5, item.type === "reciprocator" ? 150 : 210);
if (Number.isFinite(other.vx)) other.vx = clamp((other.vx || 0) + nx * baseImpulse, -1180, 1180);
if (Number.isFinite(other.vy)) other.vy = clamp((other.vy || 0) + ny * baseImpulse, -1180, 1180);
if (other.type === "ball") {
other.prevX = Number.isFinite(other.prevX) ? other.prevX : other.x - nx * sep;
other.prevY = Number.isFinite(other.prevY) ? other.prevY : other.y - ny * sep;
other.spinVelocity = clamp((other.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * (5.8 + Math.min(10, closing * 0.02)), -48, 48);
other.lastKickedAt = worldRef.time || 0;
other.lastKickerId = item.id || "mechanical";
}
if (item.type === "reciprocator") {
applyRailCorrection(item, -nx, -ny, best.overlap, 0.18);
} else {
applyImpulse(item, best.x, best.y, -nx * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), -ny * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), item.type === "poison_block" ? 0.58 : 0.34);
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "physical-circle");
}
contacts += 1;
changed = true;
if (contacts >= 18) break;
}
if (!best) return false;
const changed = reverseReciprocatorOnMechanicalContact(item, best.info.nx, best.info.ny, dt, worldRef, "physical-item");
if (changed) {
commitBody(item, "mechanical-circle-contact");
worldRef.markSpatialDirty?.("mechanical-circle-contact");
item._reciprocatorBlockedTimer = 0;
worldRef.markSpatialDirty?.("reciprocator-physical-contact");
worldRef.drawListDirty = true;
}
return changed;
}
function reverseReciprocatorOnCircleContact(item, obj, nx, ny, dt = 0.016, worldRef = null, reason = "circle-contact") {
if (!isPhysicalCircleContactObject(obj)) return false;
return reverseReciprocatorOnMechanicalContact(item, nx, ny, dt, worldRef, reason);
}
function strongestRectContact(rectsA, rectsB, padding = 0) {
let best = null;
for (const ra of rectsA || []) {
@ -1017,10 +1048,10 @@
applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, separated ? 0.78 : 1.0);
applyRailCorrection(a, info.nx, info.ny, info.overlap, b.type === "reciprocator" ? 0.42 : 0.72);
applyRailCorrection(b, -info.nx, -info.ny, info.overlap, a.type === "reciprocator" ? 0.42 : 0.72);
if (a.type === "reciprocator") reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
if (b.type === "reciprocator") reverseReciprocatorOnContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical");
if (a.type === "rotator") brakeRotatorOnContact(a, dt, worldRef, "mechanical");
if (b.type === "rotator") brakeRotatorOnContact(b, dt, worldRef, "mechanical");
if (a.type === "reciprocator") reverseReciprocatorOnMechanicalContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
if (b.type === "reciprocator") reverseReciprocatorOnMechanicalContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical");
if (a.type === "rotator") brakePoweredRotatorOnMechanicalContact(a, dt, worldRef, "mechanical");
if (b.type === "rotator") brakePoweredRotatorOnMechanicalContact(b, dt, worldRef, "mechanical");
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact"); }
return true;
@ -1029,9 +1060,9 @@
function fenceRectsNear(item, worldRef, radius) {
const out = [];
if (!item || !worldRef?.items) return out;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items;
for (const other of source) {
if (!other || other === item || other.dead || isMechanicalType(other)) continue;
if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue;
if (other.type === "gate_fence" && other.gateOpen) continue;
for (const rect of worldRef.solidObstacleRects?.(other) || []) out.push(rect);
}
@ -1051,6 +1082,9 @@
function resolveFenceContacts(item, dt, worldRef, radius = null) {
if (!item || !worldRef || !isMechanicalType(item)) return false;
// \u5f80\u5fa9\u4f53\u306e\u505c\u6b62\u30fb\u53cd\u8ee2\u5bfe\u8c61\u306f\u7269\u7406\u30a2\u30a4\u30c6\u30e0\u306b\u9650\u5b9a\u3059\u308b\u3002
// \u9759\u7684\u306a\u67f5/\u969c\u5bb3\u7269\u306f\u30ec\u30fc\u30eb\u99c6\u52d5\u3092\u6bba\u3057\u3084\u3059\u3044\u305f\u3081\u3001\u5f80\u5fa9\u4f53\u5074\u3067\u306f\u3053\u3053\u3067\u6271\u308f\u306a\u3044\u3002
if (item.type === "reciprocator") return false;
const rects = obstacleRects(item);
const fences = fenceRectsNear(item, worldRef, radius || reach(item) + 80);
let changed = false;
@ -1069,8 +1103,7 @@
const impulse = clamp(closing * 0.18 + info.overlap * 6.6 + 4.5, 3.5, separated ? 58 : 92);
applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.92 : 1.10);
applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.82);
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "fence");
if (item.type === "reciprocator") reverseReciprocatorOnContact(item, info.nx, info.ny, dt, worldRef, "solid-obstacle");
if (item.type === "rotator") brakePoweredRotatorOnMechanicalContact(item, dt, worldRef, "fence");
changed = true;
}
}
@ -1081,11 +1114,14 @@
function hasInteractionCandidates(item, worldRef, radius = null) {
if (!item || !worldRef) return false;
const r = radius || reach(item) + 80;
const source = worldRef.nearbyItems?.(item.x, item.y, r, true) || worldRef.nearbyObstacles?.(item.x, item.y, r, false) || worldRef.items || [];
const source = worldRef.nearbyObstacles?.(item.x, item.y, r, false) || worldRef.nearbyItems?.(item.x, item.y, r, true) || worldRef.items || [];
for (const other of source) {
if (!other || other === item || other.dead) continue;
if (isMechanicalType(other) || worldRef.isFenceType?.(other.type) || isPhysicalCircleContactObject(other)) return true;
if (worldRef.solidObstacleRects?.(other)?.length) return true;
if (item.type === "reciprocator") {
if (isMechanicalType(other)) return true;
continue;
}
if (isMechanicalType(other) || worldRef.isFenceType?.(other.type)) return true;
}
return false;
}
@ -1094,12 +1130,11 @@
if (!item || !worldRef?.items || !isMechanicalType(item)) return false;
let changed = false;
const radius = reach(item) + 80;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
const source = worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items;
for (const other of source) {
if (!other || other === item || other.dead || !isMechanicalType(other)) continue;
changed = resolvePair(item, other, dt, worldRef) || changed;
}
changed = resolveMechanicalCircleContacts(item, dt, worldRef, radius) || changed;
changed = resolveFenceContacts(item, dt, worldRef, radius) || changed;
return changed;
}
@ -1107,7 +1142,9 @@
function updateRotator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "rotator") return false;
applyBodyState(item);
return runMechanicalStep(item, "rotator-step", () => {
const prevScratch = item._physicsStepScratch === true;
item._physicsStepScratch = true;
try {
item.amount = 999;
if (item.playerHeld || item._heldByPlayer) return false;
const powered = isPowered(item);
@ -1139,7 +1176,10 @@
item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt;
if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin"); }
return true;
});
} finally {
item._physicsStepScratch = prevScratch;
commitBody(item, "rotator-step");
}
}
function integrateReciprocatorPhase(item, total, stepDt, powered, reason = "rail-integrate") {
@ -1212,7 +1252,9 @@
function updateReciprocator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "reciprocator") return false;
applyBodyState(item);
return runMechanicalStep(item, "reciprocator-step", () => {
const prevScratch = item._physicsStepScratch === true;
item._physicsStepScratch = true;
try {
item.amount = 999;
resetAnchor(item);
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
@ -1253,7 +1295,10 @@
return true;
}
return false;
});
} finally {
item._physicsStepScratch = prevScratch;
commitBody(item, "reciprocator-step");
}
}
function constrainPassiveBlockInsideWorld(item, worldRef) {
@ -1324,7 +1369,9 @@
function updatePoisonBlock(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "poison_block") return false;
applyBodyState(item);
return runMechanicalStep(item, "poison-block-step", () => {
const prevScratch = item._physicsStepScratch === true;
item._physicsStepScratch = true;
try {
item.amount = 999;
item.r = Math.max(item.r || 64, Math.min(460, extent(item)));
if (item.playerHeld || item._heldByPlayer) {
@ -1370,7 +1417,10 @@
}
if (changed && worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("poison-block-motion"); }
return changed;
});
} finally {
item._physicsStepScratch = prevScratch;
commitBody(item, "poison-block-step");
}
}
@ -1436,8 +1486,8 @@
};
}
function bodyAabbOverlap(a, b, pad = 0) {
return footprints?.aabbOverlap?.(a, b, pad) ?? false;
function aabbOverlap(a, b, pad = 0) {
return Boolean(a && b && a.left <= b.right + pad && a.right >= b.left - pad && a.top <= b.bottom + pad && a.bottom >= b.top - pad);
}
function pairKey(a, b) {
@ -1619,7 +1669,7 @@
const key = pairKey(a.item, b.item);
if (seen[key]) continue;
seen[key] = true;
if (!bodyAabbOverlap(a.aabb, b.aabb, 8)) continue;
if (!aabbOverlap(a.aabb, b.aabb, 8)) continue;
const score = contactCandidateScore(a, b);
if (!Number.isFinite(score)) continue;
pairs += 1;
@ -1815,14 +1865,8 @@
isMechanicalType,
isPowered,
sanitizeSegments,
worldSegments,
obstacleRects,
placementRects,
poisonHazardRects,
boundsAabb,
extent,
reach,
resetAnchor,
axis,
railAxisAngle,
applySurfaceVelocityToCircle,
@ -1834,6 +1878,7 @@
updatePoisonBlock,
applyPokeImpulse,
hitTest,
reach,
invalidateGeometry,
wakeItem,
passiveItemAwake,

View file

@ -16,15 +16,10 @@
const metrics = new Map();
let frameOpen = null;
let frameCount = 0;
let lastSmoothAt = 0;
const storageKey = "tarinai_visual_settings_v1";
const allowed = {
effects: new Set(["off", "low", "mid", "high"]),
shadows: new Set(["off", "low", "mid", "high"]),
details: new Set(["low", "mid", "high"]),
};
const defaults = Object.freeze({ effects: "high", shadows: "high", details: "high" });
let visualSettings = loadVisualSettings();
let lastQualityCheckAt = 0;
const mobilePerfHint = () => Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
let tier = mobilePerfHint() ? "mid" : "high";
let dprScale = mobilePerfHint() ? 0.82 : 1;
let resizeRequested = false;
function bucketFor(label) {
@ -79,52 +74,17 @@
}
}
function normalizeVisualSetting(key, value) {
const raw = String(value || "");
if (key === "effects" || key === "shadows") {
if (raw === "off") return "off";
if (raw === "on") return "high";
if (raw === "low" || raw === "mid" || raw === "high") return raw;
}
if (key === "details") {
if (raw === "low") return "low";
if (raw === "mid" || raw === "high") return raw;
}
return defaults[key];
function qualityFromFps(fps) {
const f = Number(fps) || 0;
const mobile = mobilePerfHint();
const maxDpr = mobile ? 0.86 : 1;
if (f > 55) return { tier: mobile ? "mid" : "high", dpr: maxDpr };
if (f < 32) return { tier: "low", dpr: mobile ? 0.64 : 0.72 };
if (f < 45) return { tier: "mid", dpr: mobile ? 0.74 : 0.86 };
return { tier: tier === "low" ? "mid" : tier, dpr: Math.min(maxDpr, tier === "low" ? 0.82 : dprScale) };
}
function sanitizeVisualSettings(value) {
const src = value && typeof value === "object" ? value : {};
const next = { ...defaults };
for (const key of Object.keys(defaults)) {
const normalized = normalizeVisualSetting(key, src[key]);
if (allowed[key]?.has(normalized)) next[key] = normalized;
}
return next;
}
function loadVisualSettings() {
try {
const raw = global.localStorage?.getItem(storageKey);
return sanitizeVisualSettings(raw ? JSON.parse(raw) : null);
} catch (_) {
return { ...defaults };
}
}
function saveVisualSettings() {
try {
global.localStorage?.setItem(storageKey, JSON.stringify(visualSettings));
} catch (_) {}
}
function dprScaleForDetails(details) {
if (details === "low") return 0.72;
if (details === "mid") return 0.86;
return 1;
}
function endFrame() {
function endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) {
frameCount += 1;
if (frameOpen != null && debugEnabled) {
const elapsed = Math.max(0, nowMs() - frameOpen);
@ -139,34 +99,21 @@
frameOpen = null;
if (debugEnabled) stack.length = 0;
const t = nowMs();
if (t - lastSmoothAt >= 750) {
lastSmoothAt = t;
if (t - lastQualityCheckAt >= 750) {
lastQualityCheckAt = t;
smoothBuckets();
const next = qualityFromFps(fps);
const nextDpr = next.dpr;
if (next.tier !== tier || Math.abs(nextDpr - dprScale) > 0.04) {
tier = next.tier;
dprScale = nextDpr;
resizeRequested = true;
}
}
}
function visualLevel(key, fallback = "high") {
return visualSettings[key] || fallback;
}
function visualSettingsValue() {
return { ...visualSettings };
}
function setVisualSetting(key, value) {
if (!Object.prototype.hasOwnProperty.call(defaults, key)) return false;
const nextValue = normalizeVisualSetting(key, value);
if (!allowed[key]?.has(nextValue)) return false;
if (visualSettings[key] === nextValue) return true;
visualSettings = { ...visualSettings, [key]: nextValue };
saveVisualSettings();
if (key === "details") resizeRequested = true;
return true;
}
function renderQualityTier() { return visualSettings.details; }
function shadowQualityTier() { return visualSettings.shadows; }
function dprScaleValue() { return dprScaleForDetails(visualSettings.details); }
function renderQualityTier() { return tier; }
function dprScaleValue() { return dprScale; }
function consumeResizeRequest() {
const v = resizeRequested;
resizeRequested = false;
@ -195,9 +142,8 @@
const topSelf = entries.slice().sort((a, b) => b.selfAvg - a.selfAvg).slice(0, 12);
return {
enabled: debugEnabled,
tier: visualSettings.details,
visual: visualSettingsValue(),
dprScale: dprScaleValue(),
tier,
dprScale,
frameCount,
frameAvg: Number(frameAvg.toFixed(2)),
entries: topInclusive,
@ -212,10 +158,6 @@
beginFrame,
endFrame,
renderQualityTier,
shadowQualityTier,
visualLevel,
visualSettings: visualSettingsValue,
setVisualSetting,
dprScale: dprScaleValue,
consumeResizeRequest,
setMetric,

View file

@ -91,27 +91,6 @@
return true;
}
function applyNormalBounce(obj, nx, ny, vx, vy, restitution, fallbackImpulse, minBounceSpeed, spinKick, spinLimit) {
const toward = vx * nx + vy * ny;
if (toward < 0) {
obj.vx = vx - (1 + restitution) * toward * nx;
obj.vy = vy - (1 + restitution) * toward * ny;
} else {
obj.vx = vx + nx * fallbackImpulse;
obj.vy = vy + ny * fallbackImpulse;
}
if (minBounceSpeed > 0) {
const normalSpeed = (obj.vx || 0) * nx + (obj.vy || 0) * ny;
if (normalSpeed < minBounceSpeed) {
const add = minBounceSpeed - normalSpeed;
obj.vx = (obj.vx || 0) + nx * add;
obj.vy = (obj.vy || 0) + ny * add;
}
}
if (Number.isFinite(obj.spinVelocity) && spinKick) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * spinKick, -spinLimit, spinLimit);
return true;
}
function bounceInsideWorld(obj, worldRef, opts = {}) {
if (!obj || !worldRef) return false;
const p = Math.max(opts.padding ?? 28, CONFIG.worldPadding || 30);
@ -133,9 +112,14 @@
if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts);
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
const minBounceSpeed = Math.max(0, Number(opts.minBounceSpeed ?? rect.minBounceSpeed ?? 0) || 0);
const fallbackImpulse = Math.max(0, Number(opts.fallbackImpulse ?? 36) || 0);
const spinKick = Number(opts.spinKick ?? 7.0);
const spinLimit = Math.max(0, Number(opts.spinLimit ?? 46) || 46);
const ensureMinNormalSpeed = (nx, ny) => {
if (!(minBounceSpeed > 0)) return;
const normalSpeed = (obj.vx || 0) * nx + (obj.vy || 0) * ny;
if (normalSpeed >= minBounceSpeed) return;
const add = minBounceSpeed - normalSpeed;
obj.vx = (obj.vx || 0) + nx * add;
obj.vy = (obj.vy || 0) + ny * add;
};
const cx = clamp(obj.x, rect.left, rect.right);
const cy = clamp(obj.y, rect.top, rect.bottom);
let dx = obj.x - cx;
@ -163,13 +147,23 @@
ny = candidates[0].ny;
} else if (Math.abs(vx) >= Math.abs(vy)) nx = vx >= 0 ? -1 : 1;
else ny = vy >= 0 ? -1 : 1;
const toward = vx * nx + vy * ny;
if (nx < 0) obj.x = expanded.left - 0.5;
else if (nx > 0) obj.x = expanded.right + 0.5;
if (ny < 0) obj.y = expanded.top - 0.5;
else if (ny > 0) obj.y = expanded.bottom + 0.5;
if (nx) obj.y = clamp(obj.y, expanded.top, expanded.bottom);
if (ny) obj.x = clamp(obj.x, expanded.left, expanded.right);
return applyNormalBounce(obj, nx, ny, vx, vy, restitution, fallbackImpulse, minBounceSpeed, spinKick, spinLimit);
if (toward < 0) {
obj.vx = vx - (1 + restitution) * toward * nx;
obj.vy = vy - (1 + restitution) * toward * ny;
} else {
obj.vx = vx + nx * 36;
obj.vy = vy + ny * 36;
}
ensureMinNormalSpeed(nx, ny);
if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
return true;
}
if (d < 0.001) {
const left = Math.abs(obj.x - rect.left);
@ -187,12 +181,22 @@
const ny = dy / d;
const vx = obj.vx || 0;
const vy = obj.vy || 0;
const toward = vx * nx + vy * ny;
obj.x = cx + nx * (hitRadius + 0.5);
obj.y = cy + ny * (hitRadius + 0.5);
return applyNormalBounce(obj, nx, ny, vx, vy, restitution, fallbackImpulse, minBounceSpeed, spinKick, spinLimit);
if (toward < 0) {
obj.vx = vx - (1 + restitution) * toward * nx;
obj.vy = vy - (1 + restitution) * toward * ny;
} else {
obj.vx = vx + nx * 36;
obj.vy = vy + ny * 36;
}
ensureMinNormalSpeed(nx, ny);
if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
return true;
}
function resolveFenceBounce(item, worldRef, dt = 0.016, opts = {}) {
function resolveKinematicBounceFenceCollision(item, worldRef, dt = 0.016, opts = {}) {
if (!item || !worldRef?.nearbySolidObstacleRects) return false;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
const radius = Math.max(4, (item.r || item.radius || 12) + (opts.obstaclePadding ?? 2));
@ -234,7 +238,7 @@
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
bounceInsideWorld(item, worldRef, opts);
resolveFenceBounce(item, worldRef, dt, opts);
resolveKinematicBounceFenceCollision(item, worldRef, dt, opts);
const ice = (worldRef?.groundType || "soil") === "ice";
const baseFriction = ice ? Math.max(Number(opts.frictionBase ?? 0.68), 0.93) : (opts.frictionBase ?? 0.68);
const frictionRate = ice ? Math.min(Number(opts.frictionRate ?? 1), 0.55) : (opts.frictionRate ?? 1);

View file

@ -5,18 +5,18 @@
// segment editors. Low-level editor pointer behavior remains in placement log.
(function (global) {
const MODE_LABELS = Object.freeze({
line: "\uD83D\uDCCF \u76F4\u7DDA",
free: "\u270E \u81EA\u7531\u63CF\u753B",
erase: "\u232B \u6D88\u3057\u30B4\u30E0",
line: "📏 直線",
free: "✎ 自由描画",
erase: "⌫ 消しゴム",
});
const TEMPLATE_LABELS = Object.freeze({
bar: "\u2503 \u68D2",
cross: "\uFF0B \u5341\u5B57",
circle: "\u25CB \u5186",
bar: "┃ 棒",
cross: " 十字",
circle: "○ 円",
});
const ACTION_LABELS = Object.freeze({
undo: "\u21B6 \u4E00\u3064\u623B\u3059",
clear: "\uD83D\uDDD1 \u5168\u6D88\u3057",
undo: "↶ 一つ戻す",
clear: "🗑 全消し",
});
function templateSegments(name) {
if (name === "bar") return [[-130, 0, 130, 0]];
@ -51,252 +51,17 @@
}
function pointSegmentDistance(p, seg) {
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
return global.TarinaiGeometry.pointSegmentDistance(p.x, p.y, seg[0], seg[1], seg[2], seg[3]);
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));
}
function canvasLocalPoint(canvas, e, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) {
if (!canvas || !e) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
}
function cleanSegments(segments = [], opts = {}) {
const limit = Math.max(1, Number(opts.limit || 96) || 96);
const bound = Math.max(1, Number(opts.bound || 420) || 420);
const minLength = Math.max(0, Number(opts.minLength || 4) || 4);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
const out = [];
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = Math.max(-bound, Math.min(bound, Number(seg[0]) || 0));
const y1 = Math.max(-bound, Math.min(bound, Number(seg[1]) || 0));
const x2 = Math.max(-bound, Math.min(bound, Number(seg[2]) || 0));
const y2 = Math.max(-bound, Math.min(bound, Number(seg[3]) || 0));
if (Math.hypot(x2 - x1, y2 - y1) >= minLength) out.push([x1, y1, x2, y2]);
if (out.length >= limit) break;
}
if (!out.length) out.push(fallback.slice(0, 4));
return out;
}
function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) {
if (!ctx || !canvas) return false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.fillStyle = "#f7f3ea";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.translate(cx, cy);
ctx.strokeStyle = "rgba(100,80,60,0.16)";
ctx.lineWidth = 1;
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
ctx.strokeStyle = "rgba(70,60,50,0.38)";
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
return true;
}
function drawSegments(ctx, segments = [], opts = {}) {
if (!ctx) return false;
const lineWidth = Math.max(1, Number(opts.lineWidth || 12) || 12);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = opts.strokeStyle || "rgba(80,150,205,0.92)";
ctx.lineWidth = lineWidth;
ctx.beginPath();
for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
if (opts.highlight !== false) {
ctx.strokeStyle = opts.highlightStyle || "rgba(255,255,255,0.58)";
ctx.lineWidth = Math.max(1.3, lineWidth * 0.20);
ctx.beginPath();
for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
}
return true;
}
function eraseSegmentsAt(segments = [], point, radius) {
const before = segments.length;
const next = (segments || []).filter(seg => pointSegmentDistance(point, seg) > radius);
return { segments: next, changed: next.length !== before };
}
function syncInputPair(a, b, onChange = null) {
if (!a || !b) return false;
const sync = (from, to) => {
to.value = from.value;
if (typeof onChange === "function") onChange();
};
a.oninput = () => sync(a, b);
b.oninput = () => sync(b, a);
return true;
}
function drawDraftSegment(ctx, draft, lineWidth, opts = {}) {
if (!ctx || !draft) return false;
ctx.setLineDash(opts.dash || [7, 5]);
ctx.strokeStyle = opts.strokeStyle || "rgba(60,130,210,0.85)";
ctx.lineWidth = Math.max(2, Number(lineWidth || 12) * 0.45);
ctx.beginPath();
ctx.moveTo(draft[0], draft[1]);
ctx.lineTo(draft[2], draft[3]);
ctx.stroke();
ctx.setLineDash([]);
return true;
}
function drawErasePreview(ctx, cx, cy, point, radius, opts = {}) {
if (!ctx || !point) return false;
ctx.save();
ctx.translate(cx || 0, cy || 0);
ctx.globalAlpha = opts.alpha ?? 0.42;
ctx.strokeStyle = opts.strokeStyle || "rgba(210,70,60,0.86)";
ctx.lineWidth = opts.lineWidth || 1.6;
ctx.setLineDash(opts.dash || [4, 4]);
ctx.beginPath();
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return true;
}
function createSegmentEditorController(opts = {}) {
const dialog = opts.dialog;
const canvas = opts.canvas;
const ctx = opts.ctx || canvas?.getContext?.("2d");
if (!dialog || !canvas || !ctx) return null;
const prefix = opts.prefix || "rotator";
const cx = opts.cx ?? canvas.width / 2;
const cy = opts.cy ?? canvas.height / 2;
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
let mode = "line";
let segments = (opts.segments || []).map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4);
let draft = null;
let drawing = false;
const lineWidth = () => Math.max(1, Number(typeof opts.lineWidth === "function" ? opts.lineWidth() : opts.lineWidth || 12) || 12);
const strokeStyle = () => typeof opts.strokeStyle === "function" ? opts.strokeStyle() : opts.strokeStyle;
const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength }); };
const toLocal = (e) => canvasLocalPoint(canvas, e, cx, cy);
const draw = () => {
drawEditorBase(ctx, canvas, cx, cy);
if (typeof opts.drawBeforeSegments === "function") opts.drawBeforeSegments(ctx, { cx, cy, lineWidth: lineWidth(), segments: segments.slice() });
const lw = lineWidth();
drawSegments(ctx, segments, { lineWidth: lw, strokeStyle: strokeStyle() || "rgba(80,150,205,0.92)" });
drawDraftSegment(ctx, draft, lw, opts.draft || {});
if (typeof opts.drawCenter === "function") opts.drawCenter(ctx, { cx, cy, lineWidth: lw, segments: segments.slice() });
ctx.restore();
return true;
};
const setMode = (next) => {
mode = next === "free" || next === "erase" ? next : "line";
for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) btn.classList.toggle("active", btn.dataset[`${prefix}Mode`] === mode);
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
return mode;
};
const eraseAt = (p) => {
const radius = Math.max(1, typeof opts.eraseRadius === "function" ? opts.eraseRadius(lineWidth()) : Number(opts.eraseRadius || 24) || 24);
const erased = eraseSegmentsAt(segments, p, radius);
segments = erased.segments;
if (erased.changed) { draft = null; draw(); return true; }
draw();
drawErasePreview(ctx, cx, cy, p, radius, opts.erasePreview || {});
return false;
};
const setTemplate = (name) => {
const shared = templateSegments(name);
if (shared) segments = shared.map(seg => seg.slice(0, 4));
clean();
draw();
};
dialog.querySelectorAll(`[data-${prefix}-mode]`).forEach(btn => { btn.onclick = () => setMode(btn.dataset[`${prefix}Mode`]); });
dialog.querySelectorAll(`[data-${prefix}-template]`).forEach(btn => { btn.onclick = () => setTemplate(btn.dataset[`${prefix}Template`]); });
canvas.onpointerdown = (e) => {
canvas.setPointerCapture?.(e.pointerId);
const p = toLocal(e);
drawing = true;
if (mode === "erase") { draft = null; eraseAt(p); return; }
draft = [p.x, p.y, p.x, p.y];
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
draw();
};
canvas.onpointermove = (e) => {
if (!drawing) return;
const p = toLocal(e);
if (mode === "erase") { eraseAt(p); return; }
if (!draft) return;
if (mode === "free") {
const last = segments[segments.length - 1];
if (!last) return;
const lx = last[2], ly = last[3];
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
last[2] = p.x; last[3] = p.y;
segments.push([p.x, p.y, p.x, p.y]);
}
} else {
draft[2] = p.x; draft[3] = p.y;
}
draw();
};
canvas.onpointerup = (e) => {
if (!drawing) return;
drawing = false;
const p = toLocal(e);
if (mode === "erase") { draft = null; draw(); return; }
if (!draft) return;
if (mode === "line") {
const seg = [draft[0], draft[1], p.x, p.y];
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
}
draft = null;
clean();
draw();
};
const undo = dialog.querySelector(`#${prefix}Undo`);
const clear = dialog.querySelector(`#${prefix}Clear`);
if (undo) undo.onclick = () => { segments.pop(); clean(); draw(); };
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
const close = () => {
canvas.style.cursor = "";
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
};
setMode(opts.initialMode || "line");
return {
cleanSegments: clean,
close,
draw,
getSegments: () => segments.map(seg => seg.slice(0, 4)),
setMode,
setSegments(next = []) { segments = next.map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); draft = null; draw(); },
};
}
global.TarinaiPhysicsShapeEditorSystem = Object.freeze({
decorateToolbar,
templateSegments,
pointSegmentDistance,
canvasLocalPoint,
cleanSegments,
drawEditorBase,
drawSegments,
eraseSegmentsAt,
syncInputPair,
drawDraftSegment,
drawErasePreview,
createSegmentEditorController,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -96,19 +96,6 @@
if (worldRef) worldRef.drawListDirty = true;
return anchor;
}
function resetLooseState(item) {
if (!item) return item;
item.pinState = "loose";
item.pinTargetId = "";
item.pinAttachAngle = 0;
item.pinAttachDistance = 0;
item.pinOffsetY = 0;
item.pinDamageTick = 0;
item.pinFallCheckTimer = 0;
item.pinLogAt = -999;
return item;
}
function isAttachedToEntity(pin, entity) {
if (!pin || !entity || !isPin(pin)) return false;
if (entity.type === "ball") return pin.pinState === "ball_lodged" && pin.pinBallId === entity.id;
@ -133,7 +120,6 @@
tarinaiAnchor,
applyTarinaiPose,
applyBallPose,
resetLooseState,
drawAttachedForEntity,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -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);

View file

@ -80,12 +80,6 @@
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 (isServingFoodType(type)) {
item.foodServingsMax = foodServingsForSize(sizeName);
item.foodServingsRemaining = item.foodServingsMax;
item.amount = item.foodServingsRemaining;
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
}
if (isRotatable(type)) item.angle = opts.angle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type));
if (type === "reciprocator") {
const axis = item.angle ?? defaultAngle(type);
@ -128,7 +122,7 @@
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" || item.type === "pipe") && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] };
if (item.type === "nest_box" && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] };
const rects = rectsFor(worldRef, item);
return { rect: null, rects };
}

View file

@ -12,15 +12,6 @@ function toolCursorIconDefinition(world) {
return { tool, def, type: def.itemType || (def.placeable ? tool : "") };
}
function canvasEllipsizeText(ctx, text, maxW) {
let s = String(text || "");
if (!ctx || ctx.measureText(s).width <= maxW) return s;
while (s.length > 1 && ctx.measureText(`${s}\u2026`).width > maxW) s = s.slice(0, -1);
return `${s}\u2026`;
}
TARINAI_RENDER_GLOBAL.canvasEllipsizeText = TARINAI_RENDER_GLOBAL.canvasEllipsizeText || canvasEllipsizeText;
function staticVersionedAssetPath(path) {
const raw = String(path || "");
if (!raw) return "";
@ -55,45 +46,6 @@ function drawToolCursorIconFallback(ctx, icon, size) {
return true;
}
function drawSimpleFireShape(ctx, x = 0, y = 0, size = 12, options = {}) {
if (!ctx) return false;
const s = Math.max(2, Number(size || 0) || 2);
const phase = Number.isFinite(options.phase) ? options.phase : 0;
const alpha = clamp(Number(options.alpha ?? 1) || 0, 0, 1);
if (alpha <= 0.01) return false;
const wobble = Math.sin(phase) * 0.08;
ctx.save();
ctx.translate(x, y);
ctx.globalAlpha *= alpha;
if (options.shadow !== false) {
ctx.shadowColor = options.shadowColor || "rgba(255,72,24,0.34)";
ctx.shadowBlur = Math.max(0, s * 0.34);
}
ctx.fillStyle = options.outer || "rgba(236,46,24,0.88)";
ctx.strokeStyle = options.edge || "rgba(255,198,72,0.70)";
ctx.lineWidth = Math.max(0.8, s * 0.08);
ctx.beginPath();
ctx.moveTo(0, -s * (1.05 + wobble));
ctx.bezierCurveTo(s * (0.58 + wobble), -s * 0.55, s * 0.50, s * 0.28, s * 0.10, s * 0.62);
ctx.bezierCurveTo(s * 0.05, s * 0.40, -s * 0.08, s * 0.22, -s * 0.27, s * 0.10);
ctx.bezierCurveTo(-s * 0.30, s * 0.40, -s * 0.18, s * 0.56, 0, s * 0.66);
ctx.bezierCurveTo(-s * 0.56, s * 0.36, -s * 0.54, -s * 0.44, 0, -s * (1.05 + wobble));
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = options.inner || "rgba(255,224,72,0.88)";
ctx.beginPath();
ctx.moveTo(s * 0.04, -s * 0.58);
ctx.bezierCurveTo(s * 0.32, -s * 0.18, s * 0.24, s * 0.32, s * 0.02, s * 0.48);
ctx.bezierCurveTo(-s * 0.26, s * 0.22, -s * 0.18, -s * 0.24, s * 0.04, -s * 0.58);
ctx.closePath();
ctx.fill();
ctx.restore();
return true;
}
TARINAI_RENDER_GLOBAL.drawSimpleFireShape = drawSimpleFireShape;
function buildToolCursorIconCanvas(tool, def, type, size) {
const key = `${tool}:${type || "-"}:${size}`;
const cached = toolCursorIconCache.get(key);
@ -277,7 +229,7 @@ function drawToolTargetHoverHighlight(ctx, world) {
ctx.setLineDash([]);
drawLabel(cx, top, 14);
} else {
const r = Math.max(14, target.r || 16) * ((target.type === "ball" || target.type === "balloon") ? 1.30 : 1.18);
const r = Math.max(14, target.r || 16) * (target.type === "ball" ? 1.30 : 1.18);
strokeEllipse(target.x || 0, target.y || 0, r, r * 0.76);
}
}
@ -389,10 +341,6 @@ function getLightingState(targetWorld) {
}
function projectedShadowParams(lighting, scale = 1) {
const shadowTier = TARINAI_RENDER_GLOBAL.TarinaiPerf?.shadowQualityTier?.() || "high";
if (shadowTier === "off") {
return { dx: 0, dy: 0, skew: 0, flatness: 0.42, spread: 1, alpha: 0, color: "rgba(78, 65, 43, 0)" };
}
const edgeLight = clamp((1 - lighting.elevation) * 0.60 + lighting.goldenStrength * 0.40 + lighting.nightStrength * 0.55, 0, 1);
const moonLit = lighting.nightStrength > 0.18;
const lightEdge = moonLit
@ -401,15 +349,14 @@ function projectedShadowParams(lighting, scale = 1) {
const castDir = -lightEdge;
const weather = world.weather || "sunny";
const weatherAlpha = weather === "sunny" ? 1.12 : weather === "cloudy" ? 0.72 : 0.38;
const qualityScale = shadowTier === "low" ? 0.42 : shadowTier === "mid" ? 0.72 : 1;
const length = lerp(0.62, 1.82, edgeLight) * scale * qualityScale;
const length = lerp(0.62, 1.82, edgeLight) * scale;
return {
dx: castDir * 12 * length,
dy: 5.2 * length,
skew: castDir * lerp(0.08, 0.34, edgeLight),
flatness: lerp(0.42, 0.24, edgeLight),
spread: lerp(0.98, 1.52, edgeLight),
alpha: clamp((0.16 + edgeLight * 0.09 - lighting.nightStrength * 0.04) * (0.92 + scale * 0.12) * weatherAlpha * qualityScale, 0.02, 0.32),
alpha: clamp((0.16 + edgeLight * 0.09 - lighting.nightStrength * 0.04) * (0.92 + scale * 0.12) * weatherAlpha, 0.05, 0.32),
color: lighting.nightStrength > 0.22 ? "rgba(42, 48, 72, 0.34)" : "rgba(78, 65, 43, 0.31)",
};
}
@ -423,7 +370,6 @@ function imageVisibleBottomFromTop(img, top, drawH) {
function drawImageProjectedShadow(ctx, img, x, groundY, drawW, drawH, params, options = {}) {
if (!img) return false;
const alpha = options.alpha ?? params.alpha;
if (alpha <= 0) return false;
const rx = Math.max(5, drawW * (options.widthScale ?? 1) * 0.30);
const ry = Math.max(2, drawH * (options.heightScale ?? 1) * 0.055);
ctx.save();
@ -437,7 +383,6 @@ function drawImageProjectedShadow(ctx, img, x, groundY, drawW, drawH, params, op
}
function drawProjectedShadow(ctx, x, y, rx, ry, params) {
if (!params || params.alpha <= 0) return false;
ctx.save();
ctx.globalAlpha = clamp(params.alpha || 0.10, 0.035, 0.16);
ctx.fillStyle = params.color || "rgba(78, 65, 43, 0.24)";
@ -445,7 +390,6 @@ function drawProjectedShadow(ctx, x, y, rx, ry, params) {
ctx.ellipse(Math.round(x), Math.round(y), Math.max(3, rx * 0.82), Math.max(1.5, ry * 0.46), 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return true;
}
function drawRadialGlow(ctx, x, y, radius, color, alpha) {
@ -532,7 +476,7 @@ function drawPlacementPreview(ctx, world) {
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
}
}
} else if (type === "stone" || type === "ball" || type === "balloon" || type === "fan" || type === "pipe" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
} else if (type === "stone" || type === "ball" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
ctx.beginPath();
ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
ctx.fill();
@ -887,7 +831,6 @@ function renderRadiusForEntity(entity) {
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "glass_wall" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2);
if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.reach(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem.scalar(entity, "railTravel", 150) || 150)));
if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2);
if (entity.type === "pipe") return Math.max(78, (entity.r || 36) * 2.8);
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
if (entity.kind === "queen") return 42;
if (entity.kind === "worker") return 26;
@ -920,14 +863,13 @@ function syncCarriedPlushieToOwner(it, worldRef) {
function collectVisibleRenderStack(worldRef, visibleRect) {
const end = window.TarinaiPerf.begin("render.stackBuild");
try {
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [], champions: [] };
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;
if (stack.champions) stack.champions.length = 0;
const seen = new Set();
const addItem = (it) => {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
@ -952,7 +894,6 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
seen.add(entity);
stack.layered.push({ entity, y: renderLayerSortY(entity), rank: renderLayerKindRank(entity) });
if (entity.isTarinaiChampion && typeof entity.drawChampionCrown === "function") (stack.champions || (stack.champions = [])).push(entity);
};
worldRef.ensureSpatial?.("render-visible");
if (worldRef.spatial?.nearbyRectInto) {
@ -1424,17 +1365,6 @@ function renderPerfEnd(end) {
if (end) end();
}
function renderVisualLevel(kind, fallback = "high") {
return TARINAI_RENDER_GLOBAL.TarinaiPerf?.visualLevel?.(kind, fallback) || fallback;
}
function visualEffectLimit(level) {
if (level === "off") return 0;
if (level === "low") return 36;
if (level === "mid") return 96;
return Infinity;
}
function fillScreenFallback(ctx, w, h, lighting) {
const light = lighting?.light ?? 0.7;
ctx.save();
@ -1486,9 +1416,7 @@ function render() {
drawGardenBed(sceneW, sceneH, lighting);
}
ctx.restore();
if (renderVisualLevel("effects") !== "off" && renderVisualLevel("details") !== "low") {
drawLightRays(ctx, screenW, screenH, lighting);
}
drawLightRays(ctx, screenW, screenH, lighting);
renderPerfEnd(endBackground);
const visibleRect = visibleWorldRect(world, 180);
@ -1508,7 +1436,7 @@ function render() {
const endBackItems = renderPerfBegin("render.draw.backItems");
for (const it of renderStack.backItems) {
it.draw(ctx, world.time, lighting);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
renderPerfEnd(endBackItems);
@ -1526,14 +1454,14 @@ function render() {
if (isBall) {
for (const it of renderStack.ballLodgedPins || []) {
if (!it || drawnAttachedPinIds.has(it.id) || it.pinBallId !== entity.id) continue;
it.draw(ctx, world.time, lighting);
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;
it.draw(ctx, world.time, lighting);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
drawnAttachedPinIds.add(it.id);
}
}
@ -1541,27 +1469,26 @@ function render() {
const endLayered = renderPerfBegin("render.draw.layered");
for (const entry of renderStack.layered) {
entry.entity.draw(ctx, world.time, lighting);
drawAttachedPinsForEntity(entry.entity);
if (isEntityVisibleInRect(entry.entity, visibleRect)) {
entry.entity.draw(ctx, world.time, lighting);
drawAttachedPinsForEntity(entry.entity);
}
}
renderPerfEnd(endLayered);
const endAttachments = renderPerfBegin("render.draw.attachments");
for (const it of renderStack.carriedPlushies) {
syncCarriedPlushieToOwner(it, world);
it.draw(ctx, world.time, lighting);
}
for (const t of renderStack.champions || []) {
t.drawChampionCrown(ctx, world.time, lighting);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const it of renderStack.ballLodgedPins || []) {
if (!it || drawnAttachedPinIds.has(it.id)) continue;
it.draw(ctx, world.time, lighting);
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;
it.draw(ctx, world.time, lighting);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
drawnAttachedPinIds.add(it.id);
}
renderPerfEnd(endAttachments);
@ -1569,23 +1496,16 @@ function render() {
drawToolTargetHoverHighlight(ctx, world);
const endEffects = renderPerfBegin("render.draw.effects");
const effectLevel = renderVisualLevel("effects");
let drawnEffects = 0;
const maxEffects = visualEffectLimit(effectLevel);
for (const ef of world.effects) {
if (drawnEffects >= maxEffects) break;
if (isEntityVisibleInRect(ef, visibleRect)) {
ef.draw(ctx);
drawnEffects += 1;
}
if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
}
renderPerfEnd(endEffects);
ctx.restore();
// Light affecting the whole visible scene, including characters and ground.
const endLighting = renderPerfBegin("render.lightingWeather");
if (renderVisualLevel("details") !== "low") drawAtmosphericLighting(ctx, screenW, screenH, lighting);
if (effectLevel !== "off" && world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
drawAtmosphericLighting(ctx, screenW, screenH, lighting);
if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
renderPerfEnd(endLighting);
const endUiOverlay = renderPerfBegin("render.uiOverlay");
@ -1610,12 +1530,12 @@ function render() {
ctx.fillStyle = "#2a241d";
ctx.font = "bold 13px ui-rounded, sans-serif";
ctx.textAlign = "left";
const seasonDay = world.seasonDayString?.() || `${world.day}\u65E5\u76EE`;
const seasonDay = world.seasonDayString?.() || `${world.day}日目`;
ctx.fillText(`${seasonDay} ${world.clockString()}`, hudX + 10, hudY + 20);
ctx.font = "11px ui-rounded, sans-serif";
ctx.fillStyle = "rgba(111,101,88,0.86)";
const hudTemp = world.currentTemperature ?? world.updateTemperature?.(0) ?? 15;
ctx.fillText(`${world.phaseName()} ${weatherLabel(world.weather)} ${Math.round(hudTemp)}\u2103`, hudX + 10, hudY + 38);
ctx.fillText(`${world.phaseName()} ${weatherLabel(world.weather)} ${Math.round(hudTemp)}`, hudX + 10, hudY + 38);
ctx.restore();
ctx.save();
@ -1636,7 +1556,12 @@ function drawPointerItemTooltip(ctx, world) {
ctx.save();
ctx.font = "12px Yomogi, sans-serif";
const maxTextW = 238;
const ellipsize = (text, maxW) => canvasEllipsizeText(ctx, text, maxW);
const ellipsize = (text, maxW) => {
let s = String(text || "");
if (ctx.measureText(s).width <= maxW) return s;
while (s.length > 1 && ctx.measureText(`${s}\u2026`).width > maxW) s = s.slice(0, -1);
return `${s}\u2026`;
};
const wrapLine = (text, maxW) => {
const s = String(text || "");
if (ctx.measureText(s).width <= maxW) return [s];

View file

@ -1,343 +0,0 @@
"use strict";
// Runtime behavior and configuration helpers for robot cleaner items.
(function (global) {
const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
const ROBOT_MOVE_SPEED = 60;
const ROBOT_HIGH_SPEED = 120;
const ROBOT_RESCAN_INTERVAL = 2.0;
const ROBOT_TARGET_BITS = Object.freeze({
zunchi: 1 << 0,
ant_corpse: 1 << 1,
tarinai: 1 << 2,
grass: 1 << 3,
grass_bed: 1 << 4,
water: 1 << 5,
});
const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water"]);
function robotCleanerDefaultMask() {
return ROBOT_DEFAULT_MASK;
}
function robotCleanerMask(item) {
const raw = Number(item?.robotCleanerMask);
return Number.isFinite(raw) ? (raw | 0) : ROBOT_DEFAULT_MASK;
}
function setRobotCleanerMask(item, mask = ROBOT_DEFAULT_MASK) {
if (!item || item.type !== "robot_cleaner") return false;
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(mask) | 0));
return true;
}
function robotCleanerHighSpeed(item) {
return Boolean(item?.robotCleanerHighSpeed);
}
function setRobotCleanerHighSpeed(item, enabled = false) {
if (!item || item.type !== "robot_cleaner") return false;
item.robotCleanerHighSpeed = Boolean(enabled);
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
return true;
}
function robotCleanerEffectiveSpeed(item) {
return robotCleanerHighSpeed(item) ? ROBOT_HIGH_SPEED : ROBOT_MOVE_SPEED;
}
function robotCleanerHasTarget(item, key = "") {
const bit = ROBOT_TARGET_BITS[String(key || "")];
return Boolean(bit && (robotCleanerMask(item) & bit));
}
function robotTargetLabel(key = "") {
switch (String(key || "")) {
case "zunchi": return "\u305a\u3093\u3061";
case "ant_corpse": return "\u6b7b\u9ab8";
case "trace": return "\u6b7b\u9ab8";
case "splat": return "\u8840\u75d5";
case "tarinai": return "\u305f\u308a\u306a\u3044";
case "grass": return "\u8349";
case "grass_bed": return "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9";
case "water": return "\u6c34\u6ef4";
default: return "\u5bfe\u8c61";
}
}
function normalizeRobotCleanerState(item) {
if (!item || item.type !== "robot_cleaner") return null;
if (!Number.isFinite(Number(item.robotCleanerMask))) item.robotCleanerMask = ROBOT_DEFAULT_MASK;
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask) | 0));
item.amount = Math.max(Number(item.amount || 0) || 0, 999);
if (!Number.isFinite(Number(item.robotCleanerScanTimer))) item.robotCleanerScanTimer = 0;
if (!Number.isFinite(Number(item.robotCleanerCleanFlash))) item.robotCleanerCleanFlash = 0;
if (!Number.isFinite(Number(item.robotCleanerAngle))) item.robotCleanerAngle = Number(item.angle || 0) || 0;
if (!Number.isFinite(Number(item.robotCleanerWanderAngle))) item.robotCleanerWanderAngle = Number(item.robotCleanerAngle || 0) || 0;
item.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed);
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
return item;
}
function robotTargetAlive(target) {
if (!target) return false;
return !target.dead && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y));
}
function robotTargetBlocked(item, target, worldRef) {
if (!item || !target || !worldRef) return false;
return Boolean(worldRef.targetBlockedByObstacle?.(item, target, Math.max(18, (item.r || 22) * 0.65)));
}
function currentRobotTarget(item, worldRef) {
const key = String(item?.robotCleanerTargetType || "");
const id = String(item?.robotCleanerTargetId || "");
if (!key || !id || !worldRef) return null;
if (key === "tarinai") {
const t = (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead);
return robotTargetAlive(t) && !robotTargetBlocked(item, t, worldRef) ? t : null;
}
const found = (worldRef.items || []).find(it => it && it.id === id && !it.dead && (
it.type === key || (key === "ant_corpse" && (it.type === "trace" || it.type === "splat"))
));
return robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null;
}
function robotTargetCandidates(item, worldRef) {
const out = [];
if (!worldRef || !item) return out;
const mask = robotCleanerMask(item);
worldRef.ensureItemBuckets?.("robot-cleaner-scan");
for (const key of ROBOT_TARGET_ORDER) {
const bit = ROBOT_TARGET_BITS[key];
if (!(mask & bit)) continue;
if (key === "tarinai") {
for (const t of worldRef.tarinai || []) {
if (!t || t.dead) continue;
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (robotTargetBlocked(item, t, worldRef)) continue;
out.push({ target: t, type: key });
}
continue;
}
const buckets = key === "ant_corpse"
? ["ant_corpse", "trace", "splat"].flatMap(type => worldRef.itemsOfType?.(type) || (worldRef.items || []).filter(it => it && it.type === type))
: (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key));
for (const it of buckets || []) {
if (!it || it.dead || it === item) continue;
if (robotTargetBlocked(item, it, worldRef)) continue;
out.push({ target: it, type: key });
}
}
return out;
}
function retargetRobotCleaner(item, worldRef, force = false) {
const current = currentRobotTarget(item, worldRef);
if (current && !force) return current;
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
let best = null;
let bestType = "";
let bestD = Infinity;
for (const rec of robotTargetCandidates(item, worldRef)) {
const target = rec.target;
if (!robotTargetAlive(target)) continue;
const d = distXY(item.x, item.y, target.x, target.y);
if (d < bestD) { best = target; bestType = rec.type; bestD = d; }
}
if (best) {
item.robotCleanerTargetId = best.id || "";
item.robotCleanerTargetType = bestType || best.type || "";
}
return best;
}
function triggerRobotPanic(item, target, worldRef, force = false) {
if (!item || !target || target.dead) return false;
const now = Number(worldRef?.time || target.world?.time || 0) || 0;
if (!force && now < Number(target.robotCleanerPanicCooldownUntil || 0)) return false;
target.robotCleanerPanicCooldownUntil = now + 0.65;
const reason = "\u30ed\u30dc\u6383\u9664\u6a5f\u304b\u3089\u9003\u3052\u3066\u3044\u308b";
if (typeof target.enterPanic === "function") {
target.enterPanic({ threat: item, reason, fear: 0.86, wake: true, cause: "robot_cleaner", surpriseTimer: 0.18, awakeLockTimer: 0.45 });
} else {
target.fearTimer = Math.max(target.fearTimer || 0, 0.86);
target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(item, true) : null, reason, wake: true });
}
return true;
}
function isRobotTarinaiTarget(target) {
return Boolean(target && (target.constructor?.name === "Tarinai" || (target.thought !== undefined && target.name && target.die)));
}
function robotTargetTypeForContact(item, target) {
if (!item || !target || target.dead) return "";
if (isRobotTarinaiTarget(target)) return robotCleanerHasTarget(item, "tarinai") ? "tarinai" : "";
const type = String(target.type || "");
if ((type === "ant_corpse" || type === "trace" || type === "splat") && robotCleanerHasTarget(item, "ant_corpse")) return type;
return robotCleanerHasTarget(item, type) ? type : "";
}
function robotCleanerCaptureRadius(item, target) {
return Math.max(18, (item?.r || 22) * 0.72 + (target?.r || target?.radius || 10) * 0.72);
}
function clearRobotTrackedTargetIfMatched(item, target) {
if (!item || !target) return false;
const id = String(target.id || "");
if (!id || String(item.robotCleanerTargetId || "") !== id) return false;
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
item.robotCleanerScanTimer = 0;
return true;
}
function cleanRobotTarget(item, target, worldRef, typeOverride = "") {
if (!item || !target || !worldRef || target.dead) return false;
const type = String(typeOverride || item.robotCleanerTargetType || target.type || "");
const label = robotTargetLabel(type);
let removed = false;
const cleanReason = "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u5438\u3044\u8fbc\u307e\u308c\u305f";
if (type === "tarinai" || isRobotTarinaiTarget(target)) {
triggerRobotPanic(item, target, worldRef, true);
if (typeof target.die === "function") {
target.die(cleanReason, { lowHealth: true });
removed = true;
} else {
target.dead = true;
target.deathReason = cleanReason;
worldRef.markDead?.(target, cleanReason);
removed = true;
}
} else {
removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, {
reason: "robot-cleaner",
userReason: type === "grass_bed" ? "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u7247\u4ed8\u3051\u3089\u308c\u305f" : cleanReason,
wake: true,
breaker: item,
}));
if (!removed) {
target.amount = 0;
target.hp = 0;
removed = true;
}
}
if (!removed) return false;
clearRobotTrackedTargetIfMatched(item, target);
item.robotCleanerScanTimer = 0;
item.robotCleanerCleanFlash = 0.42;
item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1;
worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }));
worldRef.markItemBucketsDirty?.("robot-cleaner");
worldRef.markSpatialDirty?.("robot-cleaner");
if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner");
worldRef.updateItemCounts?.();
worldRef.drawListDirty = true;
if ((worldRef.time || 0) - Number(item.robotCleanerLastLogAt || -999) > 1.2) {
worldRef.log?.(`\u30ed\u30dc\u6383\u9664\u6a5f\u304c${label}\u3092\u7247\u4ed8\u3051\u305f\u3002`, "observe");
item.robotCleanerLastLogAt = worldRef.time || 0;
}
return true;
}
function cleanRobotContacts(item, worldRef) {
if (!item || !worldRef) return false;
const seen = new Set();
let changed = false;
for (const rec of robotTargetCandidates(item, worldRef)) {
const target = rec?.target;
if (!robotTargetAlive(target)) continue;
const key = String(target.id || `${rec.type}:${target.x}:${target.y}`);
if (seen.has(key)) continue;
seen.add(key);
const type = robotTargetTypeForContact(item, target) || rec.type || "";
if (!type) continue;
const capture = robotCleanerCaptureRadius(item, target);
if (distXY(item.x, item.y, target.x, target.y) <= capture) {
if (cleanRobotTarget(item, target, worldRef, type)) changed = true;
}
}
return changed;
}
function moveRobotCleaner(item, dt, target, worldRef) {
const r = Math.max(14, Number(item.r || 22) || 22);
const speed = robotCleanerEffectiveSpeed(item);
let desiredAngle = Number(item.robotCleanerWanderAngle || 0) || 0;
let desiredSpeed = speed;
if (target && robotTargetAlive(target)) {
desiredAngle = Math.atan2((target.y || 0) - (item.y || 0), (target.x || 0) - (item.x || 0));
desiredSpeed = speed;
} else if ((item.robotCleanerWanderTimer || 0) <= 0) {
desiredAngle = global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2));
item.robotCleanerWanderTimer = global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item);
}
item.robotCleanerWanderTimer = Math.max(0, Number(item.robotCleanerWanderTimer || 0) - dt);
item.robotCleanerWanderAngle = desiredAngle;
const vx = Math.cos(desiredAngle) * desiredSpeed;
const vy = Math.sin(desiredAngle) * desiredSpeed;
const blend = Math.min(1, dt * 6.2);
item.vx = (Number(item.vx || 0) || 0) + (vx - (Number(item.vx || 0) || 0)) * blend;
item.vy = (Number(item.vy || 0) || 0) + (vy - (Number(item.vy || 0) || 0)) * blend;
item.prevX = item.x;
item.prevY = item.y;
item.x += item.vx * dt;
item.y += item.vy * dt;
const minX = CONFIG.worldPadding + r * 0.5;
const minY = CONFIG.worldPadding + r * 0.5;
const maxX = Math.max(minX, (worldRef.w || 0) - CONFIG.worldPadding - r * 0.5);
const maxY = Math.max(minY, (worldRef.h || 0) - CONFIG.worldPadding - r * 0.5);
let bounced = false;
if (item.x < minX) { item.x = minX; item.vx = Math.abs(item.vx) * 0.35; bounced = true; }
if (item.x > maxX) { item.x = maxX; item.vx = -Math.abs(item.vx) * 0.35; bounced = true; }
if (item.y < minY) { item.y = minY; item.vy = Math.abs(item.vy) * 0.35; bounced = true; }
if (item.y > maxY) { item.y = maxY; item.vy = -Math.abs(item.vy) * 0.35; bounced = true; }
if (bounced) {
item.robotCleanerWanderAngle = Math.atan2(item.vy || 0, item.vx || 0) + global.deterministicRange(worldRef, "robot-cleaner-bounce", -0.8, 0.8, item);
item.robotCleanerWanderTimer = 0.4;
}
if (Math.hypot(item.vx || 0, item.vy || 0) > 2) item.robotCleanerAngle = Math.atan2(item.vy || 0, item.vx || 0);
worldRef.markSpatialDirty?.("robot-cleaner-move");
worldRef.drawListDirty = true;
}
function updateRobotCleaner(item, dt, worldRef) {
if (!item || item.dead || !worldRef) return false;
normalizeRobotCleanerState(item);
dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0));
item.robotCleanerCleanFlash = Math.max(0, Number(item.robotCleanerCleanFlash || 0) - dt * 2.8);
item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt);
let target = currentRobotTarget(item, worldRef);
if (!target || item.robotCleanerScanTimer <= 0) {
target = retargetRobotCleaner(item, worldRef, true);
item.robotCleanerScanTimer = ROBOT_RESCAN_INTERVAL;
}
moveRobotCleaner(item, dt, target, worldRef);
if (target && robotTargetAlive(target)) {
const isTarinaiTarget = String(item.robotCleanerTargetType || "") === "tarinai" || isRobotTarinaiTarget(target);
const d = distXY(item.x, item.y, target.x, target.y);
if (isTarinaiTarget && d <= Math.max(96, (item.r || 22) + (target.radius || target.r || 12) + 70)) triggerRobotPanic(item, target, worldRef);
}
cleanRobotContacts(item, worldRef);
return true;
}
global.TarinaiRobotCleanerSystem = Object.freeze({
updateRobotCleaner,
robotCleanerDefaultMask,
robotCleanerMask,
setRobotCleanerMask,
robotCleanerHasTarget,
robotCleanerHighSpeed,
setRobotCleanerHighSpeed,
robotCleanerEffectiveSpeed,
robotTargetLabel,
robotCleanerTargetBits: ROBOT_TARGET_BITS,
robotCleanerTargetOrder: ROBOT_TARGET_ORDER,
robotCleanerMoveSpeed: ROBOT_MOVE_SPEED,
robotCleanerHighMoveSpeed: ROBOT_HIGH_SPEED,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -5,13 +5,9 @@
const SaveSchema = global.TarinaiSaveSchema;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for save_codec.js");
const EXPORT_PREFIX = "\u305f";
const RAW_CODEC = "raw";
const DEFLATE_CODEC = "deflate";
const DICT_CODEC = "dict";
const DICT_DEFLATE_CODEC = "dict-deflate";
const SAVE_HEADER_CHARS = "\u3089\u308A\u308B\u308C\u308D\u308F\u3092\u3093\u3083\u3085\u3087\u3063";
const SAVE_HEADER_CODECS = Object.freeze([RAW_CODEC, DEFLATE_CODEC, DICT_CODEC, DICT_DEFLATE_CODEC]);
const EXPORT_PREFIX = "\u305f\u308a";
const RAW_CODEC = "\u751f";
const DEFLATE_CODEC = "\u7e2e";
const {
BINARY_SCHEMA_VERSION,
FIELD_IDS,
@ -20,17 +16,12 @@
PIN_TYPE_IDS,
ROTATABLE_ITEM_TYPE_IDS,
JSON_EXTRA_ITEM_TYPE_IDS,
ITEM_TYPE_IDS,
} = SaveSchema;
const FIRE_TYPE_ID = ITEM_TYPE_IDS.indexOf("fire");
const FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("firecracker");
const FLAME_FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("flame_firecracker");
const BALLOON_TYPE_ID = ITEM_TYPE_IDS.indexOf("balloon");
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const SAVE_TEXT_BITS = 12;
const SAVE_TEXT_BITS = 11;
const SAVE_TEXT_BASE = 1 << SAVE_TEXT_BITS;
const SAVE_TEXT_ALPHABET = (() => {
const chars = [];
@ -49,14 +40,38 @@
for (const ch of SAVE_TEXT_ALPHABET) map.set(ch, index++);
return map;
})();
if ([...SAVE_TEXT_ALPHABET].length !== SAVE_TEXT_BASE) throw new Error("save alphabet must contain 4096 Japanese chars");
if ([...SAVE_TEXT_ALPHABET].length !== SAVE_TEXT_BASE) throw new Error("save alphabet must contain 2048 Japanese chars");
function jp4096Encode(bytes) {
function writeVaruintToArray(out, value = 0) {
let n = toSafeUInt(value);
while (n >= 128) {
out.push((n % 128) | 128);
n = Math.floor(n / 128);
}
out.push(n & 127);
}
function readVaruintFromArray(bytes, start = 0) {
let n = 0;
let mul = 1;
for (let i = 0; i < 10; i++) {
const byte = bytes[start + i];
if (byte == null) throw new Error("truncated Japanese save length");
n += (byte & 127) * mul;
if (!(byte & 128)) return { value: n, next: start + i + 1 };
mul *= 128;
}
throw new Error("invalid Japanese save length");
}
function jp2048Encode(bytes) {
const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const packet = [];
writeVaruintToArray(packet, source.length);
for (const byte of source) packet.push(byte & 255);
let b = 0;
let n = 0;
let out = "";
for (const byte of source) {
for (const byte of packet) {
b |= (byte & 255) << n;
n += 8;
while (n >= SAVE_TEXT_BITS) {
@ -69,90 +84,24 @@
return out;
}
function jp4096Decode(text, remainder = 0) {
function jp2048Decode(text) {
let b = 0;
let n = 0;
const out = [];
const packet = [];
for (const ch of String(text || "").trim()) {
const value = SAVE_TEXT_REVERSE.get(ch);
if (value == null) continue;
b |= value << n;
n += SAVE_TEXT_BITS;
while (n >= 8) {
out.push(b & 255);
packet.push(b & 255);
b >>= 8;
n -= 8;
}
}
if ((Number(remainder) || 0) === 2 && out.length) out.pop();
return new Uint8Array(out);
}
function saveHeader(codec = RAW_CODEC, byteLength = 0) {
const codecIndex = Math.max(0, SAVE_HEADER_CODECS.indexOf(codec));
const remainder = ((Number(byteLength) || 0) % 3 + 3) % 3;
return SAVE_HEADER_CHARS.charAt(codecIndex * 3 + remainder);
}
function parseSaveHeader(ch = "") {
const index = SAVE_HEADER_CHARS.indexOf(String(ch || "").charAt(0));
if (index < 0) throw new Error("unsupported Japanese save header");
return {
codec: SAVE_HEADER_CODECS[Math.floor(index / 3)] || RAW_CODEC,
remainder: index % 3,
};
}
const BYTE_DICTIONARY = Object.freeze([
[0, 0, 0],
[0, 0, 0, 0],
[100, 100, 100, 100],
[0, 0, 100],
[0, 0, 0, 100],
[1, 0, 0],
[0, 1, 0],
].map(row => Uint8Array.from(row)));
const DICT_ESCAPE = 255;
function dictionaryTransform(bytes) {
const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const out = [];
for (let i = 0; i < source.length;) {
let match = -1;
for (let d = BYTE_DICTIONARY.length - 1; d >= 0; d--) {
const entry = BYTE_DICTIONARY[d];
if (i + entry.length > source.length) continue;
let ok = true;
for (let j = 0; j < entry.length; j++) {
if (source[i + j] !== entry[j]) { ok = false; break; }
}
if (ok) { match = d; break; }
}
if (match >= 0) {
out.push(DICT_ESCAPE, match + 1);
i += BYTE_DICTIONARY[match].length;
} else {
const byte = source[i++];
if (byte === DICT_ESCAPE) out.push(DICT_ESCAPE, 0);
else out.push(byte);
}
}
return Uint8Array.from(out);
}
function dictionaryRestore(bytes) {
const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
const out = [];
for (let i = 0; i < source.length; i++) {
const byte = source[i];
if (byte !== DICT_ESCAPE) { out.push(byte); continue; }
const code = source[++i];
if (code == null) throw new Error("truncated Japanese save dictionary payload");
if (code === 0) out.push(DICT_ESCAPE);
else {
const entry = BYTE_DICTIONARY[code - 1];
if (!entry) throw new Error("invalid Japanese save dictionary token");
for (const v of entry) out.push(v);
}
}
return Uint8Array.from(out);
const { value: length, next } = readVaruintFromArray(packet, 0);
if (packet.length < next + length) throw new Error("truncated Japanese save payload");
return new Uint8Array(packet.slice(next, next + length));
}
function toSafeUInt(value = 0) {
@ -220,62 +169,6 @@
}
}
let activeStringWriteMap = null;
let activeStringReadList = null;
function utf8Length(value = "") {
return textEncoder.encode(String(value || "")).length;
}
function collectStringCounts(snapshot) {
const counts = new Map();
const add = value => {
const s = String(value || "");
if (s.length < 2 || utf8Length(s) < 4) return;
counts.set(s, (counts.get(s) || 0) + 1);
};
add(snapshot?.w?.[8]);
for (const row of snapshot?.t || []) add(row?.[1]);
for (const row of snapshot?.i || []) {
const typeId = row?.[0];
const extra = row?.[4];
if (typeId === 22) add(extra?.[0]);
else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) add(JSON.stringify(extra || []));
}
return [...counts.entries()]
.filter(([, count]) => count > 1)
.sort((a, b) => (b[0].length * b[1]) - (a[0].length * a[1]))
.map(([value]) => value)
.slice(0, 63);
}
function writeStringTable(writer, snapshot) {
const list = collectStringCounts(snapshot);
writer.u(list.length);
activeStringWriteMap = list.length ? new Map(list.map((value, i) => [value, i])) : null;
for (const value of list) writer.str(value);
}
function readStringTable(reader) {
const count = reader.u();
const list = [];
for (let i = 0; i < count; i++) list.push(reader.str());
activeStringReadList = list.length ? list : null;
}
function writeSaveString(writer, value = "") {
const s = String(value || "");
if (!activeStringWriteMap) return writer.str(s);
const index = activeStringWriteMap.get(s);
if (index == null) {
writer.u(0);
writer.str(s);
} else {
writer.u(index + 1);
}
}
function readSaveString(reader) {
if (!activeStringReadList) return reader.str();
const index = reader.u();
if (!index) return reader.str();
return activeStringReadList[index - 1] || "";
}
function clampToBits(value = 0, bits = 8) {
const max = (2 ** Math.max(0, bits)) - 1;
return Math.max(0, Math.min(max, toSafeUInt(value)));
@ -395,14 +288,8 @@
return out;
}
function valuesDiffer(a, b) { return (Number(a) || 0) !== (Number(b) || 0); }
function arrayHasValues(arr = [], defaults = []) {
for (let i = 0; i < Math.max(arr?.length || 0, defaults.length); i++) {
if (valuesDiffer(arr?.[i], defaults?.[i] || 0)) return true;
}
return false;
}
function writeTimers(writer, timers = []) {
function valuesDiffer(a, b) { return (Number(a) || 0) !== (Number(b) || 0); }
function writeTimers(writer, timers = []) {
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
let mask = 0;
for (let i = 0; i < defaults.length; i++) if (valuesDiffer(timers?.[i], defaults[i])) mask |= (1 << i);
@ -427,16 +314,6 @@
if (SERVING_FOOD_TYPE_IDS.has(typeId)) return false;
return true;
}
function defaultStructureHp10(typeId) {
if (ITEM_TYPE_IDS[typeId] === "grass_bed") return 1000;
if (ITEM_TYPE_IDS[typeId] === "plushie") return 10;
return 10;
}
function defaultNeedsFromVitals(hunger = 0, energy = 0, sleepPressure = 0) {
const food = Math.max(0, Math.min(100, Math.round(Number(hunger) || 0)));
const sleep = Math.max(0, Math.min(100, Math.round(Math.max(Number(sleepPressure) || 0, 100 - (Number(energy) || 0)))));
return [food, sleep, 0, 0, 0, 0];
}
function writeRelationships(writer, rows = []) {
const list = Array.isArray(rows) ? rows : [];
writer.u(list.length);
@ -457,28 +334,15 @@
function writeWorld(writer, w = []) {
writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 3, 4, 3]);
writer.u(w[3] || 0); // worldTick10
let mask = 0;
if (valuesDiffer(w[5], -9990)) mask |= 1;
if (valuesDiffer(w[6], 1)) mask |= 2;
if (valuesDiffer(w[7], 0)) mask |= 4;
if (valuesDiffer(w[9], 0)) mask |= 8;
writeBitFields(writer, [mask], [4]);
if (mask & 1) writer.s(w[5] || 0); // lastBirthAt10 can be negative
if (mask & 2) writer.u(w[6] || 1); // generation
if (mask & 4) writer.u(w[7] || 0); // dead count
writeSaveString(writer, w[8] || ""); // worldSeed
if (mask & 8) writer.u(w[9] || 0); // birthSerial
writer.s(w[5] || 0); // lastBirthAt10 can be negative
writer.u(w[6] || 1); // generation
writer.u(w[7] || 0); // dead count
writer.str(w[8] || ""); // worldSeed
writer.u(w[9] || 0); // birthSerial
}
function readWorld(reader) {
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
const tick = reader.u();
const [mask] = readBitFields(reader, [4]);
const lastBirthAt = (mask & 1) ? reader.s() : -9990;
const generation = (mask & 2) ? reader.u() : 1;
const deadCount = (mask & 4) ? reader.u() : 0;
const seed = readSaveString(reader);
const birthSerial = (mask & 8) ? reader.u() : 0;
return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial];
return [field, ground, mood, reader.u(), weather, reader.s(), reader.u(), reader.u(), reader.str(), reader.u()];
}
function seedPersonalityArray(seed = "", flags = 0) {
@ -486,29 +350,6 @@
if ((Number(flags) || 0) & (1 << 5)) return [0, 0, 0, 0];
return global.TarinaiSeedFactory.personalityArray(seed) || [100, 100, 100, 100];
}
function seedGeneticsArray(seed = "", flags = 0) {
const zunchiSlave = Boolean((Number(flags) || 0) & ((1 << 5) | (1 << 6)));
const genetics = global.TarinaiSeedFactory?.birthProfile?.(seed, { isZunchiSlave: zunchiSlave, zunchiSlaveLocked: zunchiSlave })?.genetics || {};
return [
Math.round((Number(genetics.lifeSpanMul) || 1) * 100),
Math.round((Number(genetics.attackMul) || 1) * 100),
Math.round((Number(genetics.speedMul) || 1) * 100),
Math.round((Number(genetics.sizeMul) || 1) * 100),
Math.round((Number(genetics.temperatureOffset) || 0) * 10),
];
}
function writeSignedDiffList(writer, base = [], current = [], count = 0) {
let mask = 0;
for (let i = 0; i < count; i++) if (valuesDiffer(current?.[i], base?.[i])) mask |= (1 << i);
writeBitFields(writer, [mask], [count]);
for (let i = 0; i < count; i++) if (mask & (1 << i)) writer.s((current?.[i] || 0) - (base?.[i] || 0));
}
function readSignedDiffList(reader, base = [], count = 0) {
const [mask] = readBitFields(reader, [count]);
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = (base?.[i] || 0) + reader.s();
return out;
}
function writeTarinai(writer, row = [], coordState = null) {
const pinIdx = row[15] ?? -1;
@ -518,207 +359,113 @@
const genetics = Array.isArray(row[18]) ? row[18] : [];
const fightStats = Array.isArray(row[19]) ? row[19] : [];
const birthSeed = row[1] || "";
const flags = row[0] || 0;
writeBitFields(writer, [row[0] || 0], [10]);
writeSaveString(writer, birthSeed);
writer.str(birthSeed);
const x = toSafeInt(row[2] || 0);
const y = toSafeInt(row[3] || 0);
const age = toSafeInt(row[4] || 0);
const generation = toSafeInt(row[5] || 1);
writer.s(x - (coordState?.x || 0));
writer.s(y - (coordState?.y || 0));
writer.s(age - (coordState?.age || 0));
writer.s(generation - (coordState?.generation || 1));
if (coordState) { coordState.x = x; coordState.y = y; coordState.age = age; coordState.generation = generation; }
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]);
const defaultPersonality = seedPersonalityArray(birthSeed, flags);
const defaultTimers = [0, 0, 0, 0, 0, 0, 0, 0, 100];
let bodyMask = 0;
if (arrayHasValues(row[10], defaultPersonality)) bodyMask |= 1;
if ((row[11] || []).length) bodyMask |= 2;
if ((row[12] || []).length) bodyMask |= 4;
if (arrayHasValues(row[13], defaultTimers)) bodyMask |= 8;
if (arrayHasValues(row[14], [0, 0, 0])) bodyMask |= 16;
writeBitFields(writer, [bodyMask], [5]);
if (bodyMask & 1) writeFixedDiffPacked(writer, defaultPersonality, row[10], 4, 8);
if (bodyMask & 2) writeIndexList(writer, row[11]);
if (bodyMask & 4) writeRelationships(writer, row[12]);
if (bodyMask & 8) writeTimers(writer, row[13]);
if (bodyMask & 16) writeModes(writer, row[14]);
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 (arrayHasValues(genetics, seedGeneticsArray(birthSeed, flags))) tailMask |= 8;
if (arrayHasValues(fightStats, [0, 0, 0])) tailMask |= 16;
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], [1]); writer.s(stateTarget); }
if (tailMask & 8) writeSignedDiffList(writer, seedGeneticsArray(birthSeed, flags), genetics, 5);
if (tailMask & 16) writeSignedDiffList(writer, [0, 0, 0], fightStats, 3);
if (tailMask & 8) for (let i = 0; i < 5; i++) writer.s(genetics[i] ?? (i === 4 ? 0 : 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];
const birthSeed = readSaveString(reader);
const birthSeed = reader.str();
const x = (coordState?.x || 0) + reader.s();
const y = (coordState?.y || 0) + reader.s();
const age = (coordState?.age || 0) + reader.s();
const generation = (coordState?.generation || 1) + reader.s();
if (coordState) { coordState.x = x; coordState.y = y; coordState.age = age; coordState.generation = generation; }
if (coordState) { coordState.x = x; coordState.y = y; }
const age = reader.u();
const generation = reader.u();
const [hunger, energy, sleepPressure] = readBitFields(reader, [8, 8, 8]);
const [bodyMask] = readBitFields(reader, [5]);
const needs = defaultNeedsFromVitals(hunger, energy, sleepPressure);
const current = (bodyMask & 1) ? readFixedDiffPacked(reader, seedPersonalityArray(birthSeed, flags), 4, 8) : seedPersonalityArray(birthSeed, flags);
const parents = (bodyMask & 2) ? readIndexList(reader) : [];
const relationships = (bodyMask & 4) ? readRelationships(reader) : [];
const timers = (bodyMask & 8) ? readTimers(reader) : [0, 0, 0, 0, 0, 0, 0, 0, 100];
const modes = (bodyMask & 16) ? readModes(reader) : [0, 0, 0];
const needs = readFixedPacked(reader, 6, 6, 2);
const current = readFixedDiffPacked(reader, seedPersonalityArray(birthSeed, flags), 4, 8);
const parents = readIndexList(reader);
const relationships = readRelationships(reader);
const timers = readTimers(reader);
const modes = readModes(reader);
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, [1])[0], reader.s()] : [0, -1];
const genetics = (tailMask & 8) ? readSignedDiffList(reader, seedGeneticsArray(birthSeed, flags), 5) : [];
const fightStats = (tailMask & 16) ? readSignedDiffList(reader, [0, 0, 0], 3) : [];
const genetics = (tailMask & 8) ? [reader.s(), 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];
}
function writeItemExtra(writer, typeId, extra = []) {
if (STRUCTURE_TYPE_IDS.has(typeId)) {
const defaultHp = defaultStructureHp10(typeId);
const hp = toSafeInt(extra[0] || 0);
const maxHp = toSafeInt(extra[1] || 0);
let mask = 0;
if (valuesDiffer(hp, defaultHp)) mask |= 1;
if (valuesDiffer(maxHp, defaultHp)) mask |= 2;
if ((extra[2] ?? -1) >= 0) mask |= 4;
if ((extra[3] ?? -1) >= 0) mask |= 8;
if (extra[4]) mask |= 16;
if ((extra[5] ?? -1) >= 0) mask |= 32;
if (valuesDiffer(extra[6], 0)) mask |= 64;
writeBitFields(writer, [mask], [7]);
if (mask & 1) writer.s(hp);
if (mask & 2) writer.s(maxHp);
if (mask & 4) writer.u(extra[2]);
if (mask & 8) writer.u(extra[3]);
if (mask & 32) writer.u(extra[5]);
if (mask & 64) writer.s(extra[6] || 0);
writer.s(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] ?? -1); writer.s(extra[3] ?? -1); writer.u(extra[4] || 0); writer.s(extra[5] ?? -1);
} else if (typeId === 0) { // grass
writer.s(extra[0] || 0);
let mask = 0;
if (valuesDiffer(extra[1], 100)) mask |= 1;
if (valuesDiffer(extra[2], 0)) mask |= 2;
if (extra[3]) mask |= 4;
writeBitFields(writer, [mask], [3]);
if (mask & 1) writer.s(extra[1] || 0);
if (mask & 2) writer.s(extra[2] || 0);
writer.s(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] || 0); writer.u(extra[3] || 0);
} else if (typeId === 1) { // zunchi
writeBitFields(writer, [extra[0] || 0], [2]);
let mask = 0;
if (valuesDiffer(extra[1], 100)) mask |= 1;
if (valuesDiffer(extra[2], 0)) mask |= 2;
writeBitFields(writer, [mask], [2]);
if (mask & 1) writer.s(extra[1] || 0);
if (mask & 2) writer.s(extra[2] || 0);
writer.u(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] || 0);
} else if (typeId === 22) { // signboard
writeSaveString(writer, extra[0] || "");
writer.str(extra[0] || "");
} else if (typeId === 6) { // duplicator
writer.s(extra[0] ?? -1);
} else if (typeId === 42) { // robot_cleaner
writeBitFields(writer, [Math.max(0, Math.min(0x3f, Number(extra[0] ?? 3) | 0)), extra[1] ? 1 : 0], [6, 1]);
writer.u(Math.max(0, Math.min(0x3f, Number(extra[0] ?? 3) | 0)));
writer.u(extra[1] ? 1 : 0);
} else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
writeSaveString(writer, JSON.stringify(extra || []));
} else if (typeId === 21 || typeId === BALLOON_TYPE_ID) { // ball/balloon
writer.str(JSON.stringify(extra || []));
} else if (typeId === 21) { // ball
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
} else if (typeId === 23) { // ant_nest
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
} else if (typeId === 24) { // ant_corpse
writer.s(extra[0] || 0);
} else if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) { // firecracker/flame/fire
} else if (typeId === 25) { // firecracker
writer.s(extra[0] || 0);
} else if (PIN_TYPE_IDS.has(typeId)) {
const target = extra[1] ?? -1;
let mask = 0;
if (target >= 0) mask |= 1;
if (valuesDiffer(extra[2], 0)) mask |= 2;
if (valuesDiffer(extra[3], 0)) mask |= 4;
if (valuesDiffer(extra[4], 0)) mask |= 8;
writeBitFields(writer, [extra[0] || 0, mask], [2, 4]);
if (mask & 1) writer.u(target);
if (mask & 2) writer.s(extra[2] || 0);
if (mask & 4) writer.s(extra[3] || 0);
if (mask & 8) writer.s(extra[4] || 0);
writer.u(extra[0] || 0); writer.s(extra[1] ?? -1); writer.s(extra[2] || 0); writer.s(extra[3] || 0); writer.s(extra[4] || 0);
} else if (ROTATABLE_ITEM_TYPE_IDS.has(typeId)) {
let mask = 0;
if (valuesDiffer(extra[0], 0)) mask |= 1;
if (extra[1]) mask |= 2;
writeBitFields(writer, [mask], [2]);
if (mask & 1) writer.s(extra[0] || 0);
writer.s(extra[0] || 0); writer.u(extra[1] || 0);
} else if (SERVING_FOOD_TYPE_IDS.has(typeId)) {
writer.s(extra[0] || 0);
if (valuesDiffer(extra[1], extra[0])) { writer.u(1); writer.s(extra[1] || 0); }
else writer.u(0);
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
}
}
function readItemExtra(reader, typeId) {
if (STRUCTURE_TYPE_IDS.has(typeId)) {
const defaultHp = defaultStructureHp10(typeId);
const [mask] = readBitFields(reader, [7]);
const hp = (mask & 1) ? reader.s() : defaultHp;
const maxHp = (mask & 2) ? reader.s() : defaultHp;
return [
hp,
maxHp,
(mask & 4) ? reader.u() : -1,
(mask & 8) ? reader.u() : -1,
(mask & 16) ? 1 : 0,
(mask & 32) ? reader.u() : -1,
(mask & 64) ? reader.s() : 0,
];
}
if (typeId === 0) {
const growth = reader.s();
const [mask] = readBitFields(reader, [3]);
return [growth, (mask & 1) ? reader.s() : 100, (mask & 2) ? reader.s() : 0, (mask & 4) ? 1 : 0];
}
if (typeId === 1) {
const [stage] = readBitFields(reader, [2]);
const [mask] = readBitFields(reader, [2]);
return [stage, (mask & 1) ? reader.s() : 100, (mask & 2) ? reader.s() : 0];
}
if (typeId === 22) return [readSaveString(reader)];
if (STRUCTURE_TYPE_IDS.has(typeId)) return [reader.s(), reader.s(), reader.s(), reader.s(), reader.u(), reader.s()];
if (typeId === 0) return [reader.s(), reader.s(), reader.s(), reader.u()];
if (typeId === 1) return [reader.u(), reader.s(), reader.s()];
if (typeId === 22) return [reader.str()];
if (typeId === 6) return [reader.s()];
if (typeId === 42) return readBitFields(reader, [6, 1]);
if (typeId === 42) return [reader.u(), reader.u()];
if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
try {
const parsed = JSON.parse(readSaveString(reader) || "[]");
const parsed = JSON.parse(reader.str() || "[]");
return (Array.isArray(parsed) || (parsed && typeof parsed === "object")) ? parsed : [];
} catch (_) {
return [];
}
}
if (typeId === 21 || typeId === BALLOON_TYPE_ID) return [reader.s(), reader.s()];
if (typeId === 21) return [reader.s(), reader.s()];
if (typeId === 23) return [reader.s(), reader.s()];
if (typeId === 24) return [reader.s()];
if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) return [reader.s()];
if (PIN_TYPE_IDS.has(typeId)) {
const [state, mask] = readBitFields(reader, [2, 4]);
return [
state,
(mask & 1) ? reader.u() : -1,
(mask & 2) ? reader.s() : 0,
(mask & 4) ? reader.s() : 0,
(mask & 8) ? reader.s() : 0,
];
}
if (ROTATABLE_ITEM_TYPE_IDS.has(typeId)) {
const [mask] = readBitFields(reader, [2]);
return [(mask & 1) ? reader.s() : 0, (mask & 2) ? 1 : 0];
}
if (SERVING_FOOD_TYPE_IDS.has(typeId)) {
const remaining = reader.s();
return [remaining, reader.u() ? reader.s() : remaining];
}
if (typeId === 25) return [reader.s()];
if (PIN_TYPE_IDS.has(typeId)) return [reader.u(), reader.s(), reader.s(), reader.s(), reader.s()];
if (ROTATABLE_ITEM_TYPE_IDS.has(typeId)) return [reader.s(), reader.u()];
if (SERVING_FOOD_TYPE_IDS.has(typeId)) return [reader.s(), reader.s()];
return [];
}
function itemCoordKey(row = []) {
@ -799,76 +546,57 @@
function encodeBinarySnapshot(snapshot) {
if (!snapshot || snapshot.v !== Snapshot.version || snapshot.a !== "tj1") throw new Error("snapshot version mismatch");
const writer = new BinaryWriter();
activeStringWriteMap = null;
try {
writer.b(BINARY_SCHEMA_VERSION);
writer.u(Snapshot.version);
writeStringTable(writer, snapshot);
writeWorld(writer, snapshot.w || []);
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
writer.u(tarinaiRows.length);
const tarCoordState = { x: 0, y: 0, age: 0, generation: 1 };
for (const row of tarinaiRows) writeTarinai(writer, row || [], tarCoordState);
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
writeItemBlocks(writer, itemRows);
return writer.out();
} finally {
activeStringWriteMap = null;
}
writer.b(BINARY_SCHEMA_VERSION);
writer.u(Snapshot.version);
writeWorld(writer, snapshot.w || []);
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
writer.u(tarinaiRows.length);
const tarCoordState = { x: 0, y: 0 };
for (const row of tarinaiRows) writeTarinai(writer, row || [], tarCoordState);
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
writeItemBlocks(writer, itemRows);
return writer.out();
}
function decodeBinarySnapshot(bytes) {
const reader = new BinaryReader(bytes);
activeStringReadList = null;
try {
const schema = reader.b();
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");
readStringTable(reader);
const w = readWorld(reader);
const tCount = reader.u();
const t = [];
const tarCoordState = { x: 0, y: 0, age: 0, generation: 1 };
for (let i = 0; i < tCount; i++) t.push(readTarinai(reader, tarCoordState));
const items = readItemBlocks(reader);
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items };
} finally {
activeStringReadList = null;
}
const schema = reader.b();
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);
const tCount = reader.u();
const t = [];
const tarCoordState = { x: 0, y: 0 };
for (let i = 0; i < tCount; i++) t.push(readTarinai(reader, tarCoordState));
const items = readItemBlocks(reader);
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items };
}
async function packBytesForSave(bytes) {
const candidates = [{ codec: RAW_CODEC, packed: bytes }];
const dict = dictionaryTransform(bytes);
if (dict.length < bytes.length) candidates.push({ codec: DICT_CODEC, packed: dict });
let codec = RAW_CODEC;
let packed = bytes;
try {
const compressed = await compressBytes(bytes);
if (compressed.length < bytes.length) candidates.push({ codec: DEFLATE_CODEC, packed: compressed });
if (dict.length < bytes.length) {
const dictCompressed = await compressBytes(dict);
if (dictCompressed.length < bytes.length) candidates.push({ codec: DICT_DEFLATE_CODEC, packed: dictCompressed });
}
if (compressed.length < bytes.length) { codec = DEFLATE_CODEC; packed = compressed; }
} catch (error) { console.warn("save compression fallback", error); }
return candidates.sort((a, b) => a.packed.length - b.packed.length)[0];
return { codec, packed };
}
async function encodeSnapshot(snapshot) {
const bytes = encodeBinarySnapshot(snapshot);
const { codec, packed } = await packBytesForSave(bytes);
return `${EXPORT_PREFIX}${saveHeader(codec, packed.length)}${jp4096Encode(packed)}`;
return `${EXPORT_PREFIX}${codec}${jp2048Encode(packed)}`;
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported Japanese save text");
const header = parseSaveHeader(raw.charAt(EXPORT_PREFIX.length));
const codec = raw.charAt(EXPORT_PREFIX.length) || RAW_CODEC;
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = jp4096Decode(payload, header.remainder);
const packedCodec = header.codec === DICT_DEFLATE_CODEC ? DEFLATE_CODEC : (header.codec === DICT_CODEC ? RAW_CODEC : header.codec);
let bytes = await decompressBytes(packed, packedCodec);
if (header.codec === DICT_CODEC || header.codec === DICT_DEFLATE_CODEC) bytes = dictionaryRestore(bytes);
const packed = jp2048Decode(payload);
const bytes = await decompressBytes(packed, codec);
return decodeBinarySnapshot(bytes);
}

View file

@ -4,8 +4,8 @@
// Owns stable save identifiers shared by snapshot_system.js and save_codec.js.
// Current-format save identifiers only.
(function (global) {
const SNAPSHOT_VERSION = 19;
const BINARY_SCHEMA_VERSION = 24;
const SNAPSHOT_VERSION = 16;
const BINARY_SCHEMA_VERSION = 20;
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
@ -21,7 +21,7 @@
"grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi",
"sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h",
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker", "balloon", "fan", "pipe"
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown"
]);
function typeIds(names) {
@ -32,7 +32,7 @@
const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]);
const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]);
const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator", "rope", "rod"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "fan"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]);
function enumIndex(list, value, fallback = 0) {
const i = list.indexOf(String(value ?? ""));

View file

@ -8,7 +8,7 @@
function metaKey(slot) { return `${META_PREFIX}${slot}`; }
function writeSlot(slot, hashText, meta = null) {
const hash = String(hashText || "").trim();
if (!hash.startsWith("\u305f")) throw new Error("unsupported Japanese hash slot value");
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() }));
}

View file

@ -96,7 +96,8 @@ function normalizeGenetics(source = null, seed = "") {
const fallback = randomGenetics(seed);
const out = {};
for (const key of GENETIC_KEYS) {
out[key] = clampGeneticValue(key, source?.[key] ?? fallback[key]);
const legacyKey = key === "attackMul" ? "fightMul" : key;
out[key] = clampGeneticValue(key, source?.[key] ?? source?.[legacyKey] ?? fallback[key]);
}
return out;
}
@ -454,15 +455,6 @@ class Effect {
ctx.beginPath();
ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2);
ctx.stroke();
} else if (this.type === "flame") {
const fn = globalThis.drawSimpleFireShape;
if (typeof fn === "function") fn(ctx, 0, 0, this.size * (0.74 + (1 - alpha) * 0.20), { phase: this.seed + (1 - alpha) * 4.8, alpha: alpha * 0.92, shadow: false });
else {
ctx.fillStyle = this.color || "rgba(236,46,24,0.82)";
ctx.beginPath();
ctx.ellipse(0, -this.size * 0.2, this.size * 0.34, this.size * 0.72, 0, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "shoot_impact") {
const s = this.size * (0.65 + (1 - alpha) * 0.55);
ctx.lineCap = "round";
@ -573,7 +565,12 @@ class Effect {
ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
const maxTextW = 118;
const lineH = 14;
const ellipsize = (text, maxW) => globalThis.canvasEllipsizeText(ctx, text, maxW);
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);
@ -745,7 +742,7 @@ class SpatialGrid {
isDynamicItem(it) {
if (!it || it.dead) return false;
const type = it.type || "";
if (type === "ball" || type === "balloon" || type === "fan" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if (type === "rope" || type === "rod") return true;
const globalRef = (typeof window !== "undefined" ? window : globalThis);
const physics = globalRef.TarinaiPhysicsBodySystem;

View file

@ -16,7 +16,6 @@
const h = helpers;
const end = global.TarinaiPerf.begin("update.tarinai.individual") || null;
const visibleRect = h.updateVisibleWorldRect(worldRef, 160);
const nearVisibleRect = h.updateVisibleWorldRect(worldRef, 520);
const now = worldRef.time || 0;
const stats = {
total: 0,
@ -49,7 +48,7 @@
stats.total += 1;
const behaviorId = updatePolicy.behaviorId(t);
inc(stats.states, behaviorId);
const cadence = updatePolicy.updateCadence(worldRef, t, visibleRect, nearVisibleRect);
const cadence = updatePolicy.updateCadence(worldRef, t, visibleRect);
const interval = Number.isFinite(cadence.interval) ? cadence.interval : Infinity;
inc(stats.lanes, cadence.lane || "unknown");
if (worldRef.selected === t) stats.selected += 1;

View file

@ -3,45 +3,8 @@
// Layer: simulation/system-effects
// Owns frame updates for transient visual/effect entities.
(function (global) {
function updateEffect(effect, dt, frameId = 0) {
if (!effect || effect.dead) return false;
const runDt = Math.min(0.24, Math.max(0, Number(effect._effectUpdateAccum || 0) + dt));
effect._effectUpdateAccum = 0;
effect._effectUpdatedFrame = frameId;
effect.update(runDt);
return true;
}
function updateEffects(worldRef, dt) {
const effects = worldRef.effects || [];
const total = effects.length;
const frameId = (worldRef._effectUpdateFrameId || 0) + 1;
worldRef._effectUpdateFrameId = frameId;
if (total <= 56) {
for (const ef of effects) updateEffect(ef, dt, frameId);
worldRef._effectUpdateStats = { total, updated: total, skipped: 0, batched: false };
return;
}
const budget = Math.max(40, Math.ceil(total * 0.42));
let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % total;
let updated = 0;
let scanned = 0;
while (updated < budget && scanned < total) {
const ef = effects[cursor];
cursor = (cursor + 1) % total;
scanned += 1;
if (!ef || ef.dead) continue;
updateEffect(ef, dt, frameId);
updated += 1;
}
for (let i = 0; i < total; i += 1) {
const ef = effects[i];
if (!ef || ef.dead || ef._effectUpdatedFrame === frameId) continue;
if (Number.isFinite(ef._effectUpdateAccum)) ef._effectUpdateAccum = Math.min(0.24, ef._effectUpdateAccum + dt);
else ef._effectUpdateAccum = dt;
}
worldRef._effectUpdateCursor = cursor;
worldRef._effectUpdateStats = { total, updated, skipped: Math.max(0, total - updated), batched: true };
for (const ef of worldRef.effects) ef.update(dt);
}
global.TarinaiEffectsSimulationSystem = Object.freeze({

View file

@ -38,11 +38,7 @@
worldRef.updateWeather(dt);
worldRef.updateTemperature?.(dt);
worldRef.updateTerrainDirtyState?.(dt);
const now = worldRef.time || 0;
if (now >= (worldRef.nextColonyMoodEvaluateAt || 0)) {
worldRef.nextColonyMoodEvaluateAt = now + 0.65;
worldRef.evaluateColonyMood?.();
}
worldRef.evaluateColonyMood?.();
}
function prepareSpatialFrame(worldRef) {

View file

@ -17,15 +17,10 @@
}
worldRef.countsTimer += dt;
const countsInterval = 2.75;
const itemCountChanged = worldRef.items.length !== itemCountBefore || worldRef.items.length !== (worldRef._lastMaintenanceItemCount ?? worldRef.items.length);
const effectCountChanged = worldRef.effects.length !== (worldRef._lastMaintenanceEffectCount ?? worldRef.effects.length);
const countsDue = worldRef.countsTimer >= countsInterval;
if (countsDue || itemsCompacted || itemCountChanged || effectCountChanged || worldRef.itemBucketsDirty) {
if (countsDue || itemsCompacted || itemCountChanged || worldRef.itemBucketsDirty) worldRef.updateItemCounts();
if (countsDue || itemsCompacted || effectCountChanged) worldRef.updateEffectCounts();
worldRef._lastMaintenanceItemCount = worldRef.items.length;
worldRef._lastMaintenanceEffectCount = worldRef.effects.length;
const countsInterval = 2.0;
if (worldRef.countsTimer >= countsInterval || itemsCompacted || worldRef.items.length !== itemCountBefore) {
worldRef.updateItemCounts();
worldRef.updateEffectCounts();
worldRef.countsTimer = 0;
}
if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) {
@ -37,18 +32,10 @@
}
}
if ((worldRef.time || 0) >= (worldRef.nextFireCompactAt || 0)) {
worldRef.nextFireCompactAt = (worldRef.time || 0) + 0.8;
const compactedFires = global.TarinaiItemDynamicToolSystem?.compactWorldFires?.(worldRef) || 0;
if (compactedFires > 0) {
itemsCompacted = true;
worldRef.updateItemCounts();
}
}
if ((worldRef.time || 0) >= (worldRef.nextGrassBedExpiryCheckAt || 0)) {
worldRef.nextGrassBedExpiryCheckAt = (worldRef.time || 0) + 5.0;
const lifespan = Math.max(1, CONFIG.dayLength || 120); // \u30B2\u30FC\u30E0\u518524\u6642\u9593
const lifespan = Math.max(1, CONFIG.dayLength || 120); // ゲーム内24時間
let removedBeds = 0;
for (const it of worldRef.items || []) {
if (!it || it.dead || it.type !== "grass_bed") continue;
@ -56,7 +43,7 @@
if ((worldRef.time || 0) - Number(it.createdAt || 0) < lifespan) continue;
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, it, {
reason: "grass-bed-expired",
userReason: "\u304B\u3093\u305F\u3093\u30D9\u30C3\u30C9\u304C\u53E4\u304F\u306A\u3063\u3066\u6D88\u3048\u305F",
userReason: "かんたんベッドが古くなって消えた",
wake: false,
panicOwner: false,
});
@ -76,8 +63,7 @@
}
worldRef.drawSortTimer += dt;
const visibleMoving = Boolean(worldRef.spatialTarinaiDirty || worldRef.spatialDynamicItemsDirty || worldRef.spatialAntDirty);
const sortInterval = visibleMoving ? 0.25 : 0.55;
const sortInterval = 0.25;
if (worldRef.drawSortTimer >= sortInterval) {
worldRef.drawListDirty = true;
worldRef.drawSortTimer = 0;

View file

@ -13,30 +13,29 @@
return global.TarinaiItemRuntime.updateScheduled(worldRef, dt);
}
function trackSpatialEntityMoved(entity, thresholdSq) {
if (!entity || entity.dead) return false;
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
return true;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) return false;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
return true;
}
function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) {
if (!worldRef || !Array.isArray(list) || !list.length) return false;
const thresholdSq = threshold * threshold;
let moved = false;
for (const entity of list) moved = trackSpatialEntityMoved(entity, thresholdSq) || moved;
for (const entity of list) {
if (!entity || entity.dead) continue;
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
continue;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) continue;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
@ -47,9 +46,29 @@
let moved = false;
const tracked = global.TarinaiItemRuntime.trackedDynamicItems(worldRef);
const source = tracked && tracked.length ? tracked : null;
const visit = (entity) => { moved = trackSpatialEntityMoved(entity, thresholdSq) || moved; };
if (source) for (const entity of source) visit(entity);
else forScheduledItems(worldRef, visit);
const visit = (entity) => {
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
return;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) return;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
};
if (source) {
for (const entity of source) if (entity && !entity.dead) visit(entity);
} else {
forScheduledItems(worldRef, visit);
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
@ -71,7 +90,6 @@
updateItemsScheduled,
markSpatialDirtyIfMoved,
markScheduledItemsMoved,
trackSpatialEntityMoved,
updateVisibleWorldRect,
pointInRect,
});

View file

@ -237,7 +237,7 @@
id: `st${index}`,
liveToken: 1,
currentPersonality: arrayToPersonality(row[10]),
genetics: Array.isArray(row[18]) && row[18].length ? arrayToGenetics(row[18]) : profile.genetics,
genetics: Array.isArray(row[18]) ? arrayToGenetics(row[18]) : profile.genetics,
x: u(row[2], 1), y: u(row[3], 1), vx: 0, vy: 0,
age, birthTime: (worldRef?.time || 0) - age, generation: Math.max(1, u(row[5], 1, 1)),
hunger: u(row[6], 1), loneliness: Number(needs.social) || 0, energy: u(row[7], 1), circadianSleepPressure: u(row[8], 1),
@ -291,20 +291,11 @@
}
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) return { pf: 2, missing: true };
if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)];
if (type === "ball" || type === "balloon") return [q(item.vx, 10), q(item.vy, 10)];
if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)];
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
if (type === "ant_corpse") return [q(item.decayTimer, 10)];
if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)];
if (type === "fire") return [q(item.flameLife, 10)];
if (type === "firecracker") return [q(item.fuseTimer, 10)];
if (typeof global.isPinType === "function" && global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
if (type === "fan") return [
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
item.fanSwingOn ? 1 : 0,
q(item.fanSwingCenter ?? item.angle ?? 0, 1000),
q(item.fanSwingRange ?? (35 * Math.PI / 180), 1000),
q(item.fanSwingSpeed ?? (48 * Math.PI / 180), 1000),
q(item.fanSwingPhase || 0, 1000),
];
if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) return [q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0];
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
return [];
@ -327,6 +318,7 @@
item.singleUseConsumedById = tarinaiList[extra[5]]?.id || "";
item.createdAt = Number.isFinite(Number(extra[6])) ? u(extra[6], 10, item.createdAt || NaN) : (Number.isFinite(Number(item.createdAt)) ? item.createdAt : NaN);
if (type === "plushie") {
item.plushieSpriteId = "plushie_bear";
item.onHead = Boolean(item.carriedById);
}
const def = global.StructureRegistry?.get?.(type);
@ -359,33 +351,23 @@
global.TarinaiPhysicsBodySystem.invalidateItem(item, "physics-extra-missing");
return;
} else if (type === "duplicator") {
item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.toolLabel ? global.toolLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType);
} else if (type === "ball" || type === "balloon") {
item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.foodLabel ? global.foodLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType);
} else if (type === "ball") {
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10);
} else if (type === "ant_nest") {
item.antCount = u(extra[0], 1); item.antWorkers = []; item.queenSpawnAt = u(extra[1], 10);
} else if (type === "ant_corpse") {
item.decayTimer = u(extra[0], 10);
} else if (type === "firecracker" || type === "flame_firecracker") {
item.workerSprite = "ant_worker"; item.decayTimer = u(extra[0], 10);
} else if (type === "firecracker") {
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
} else if (type === "fire") {
item.flameLife = u(extra[0], 10, item.flameLife || 6);
} else if (typeof global.isPinType === "function" && global.isPinType(type)) {
item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10);
} else if (type === "fan") {
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback);
item.fanSwingOn = Boolean(extra[1]);
item.fanSwingCenter = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[2], 1000, item.angle), item.angle) : u(extra[2], 1000, item.angle);
item.fanSwingRange = Math.max(0, Math.min(150 * Math.PI / 180, u(extra[3], 1000, 35 * Math.PI / 180)));
item.fanSwingSpeed = Math.max(0, Math.min(360 * Math.PI / 180, u(extra[4], 1000, 48 * Math.PI / 180)));
item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0);
} else if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) {
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback);
if (type === "gate_fence") item.gateOpen = !!extra[1];
} else if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) {
item.foodServingsRemaining = u(extra[0], 10, item.amount || 1); item.foodServingsMax = u(extra[1], 10, item.foodServingsRemaining || item.amount || 1); item.amount = item.foodServingsRemaining; (typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
item.foodServingsRemaining = u(extra[0], 10, item.amount || 1); item.foodServingsMax = u(extra[1], 10, item.foodServingsRemaining || item.amount || 1); item.amount = item.foodServingsRemaining;
}
}
function createSnapshot(worldRef = global.world) {
@ -498,9 +480,6 @@
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships; delete t.__savedStateInfo;
}
if (typeof global.updateNeeds === "function") {
for (const t of worldRef.tarinai) global.updateNeeds(t, worldRef, 0);
}
worldRef.liveIdNext = Math.max(1, worldRef.tarinai.length + 1);
worldRef.liveIdSerial = Math.max(1, worldRef.tarinai.length + 1);
worldRef.birthSerial = Math.max(Number(worldRef.birthSerial) || 0, ...worldRef.tarinai.map(t => Number(t.birthSerial) || 0));

View file

@ -25,6 +25,7 @@
item.grassStage = -1;
item.seedTimer = Math.max(Number(item.seedTimer || 0), 2.5);
}
item.removedReason = String(reason || "item-removed");
return true;
}
@ -33,13 +34,14 @@
structure.dead = true;
structure.hp = 0;
structure.amount = 0;
structure.removedReason = String(reason || "structure-removed");
return true;
}
function ownedStructureDestroyedLabel(structure) {
if (structure?.type === "plushie") return "\u306C\u3044\u3050\u308B\u307F";
if (structure?.type === "grass_bed") return "\u304B\u3093\u305F\u3093\u30D9\u30C3\u30C9";
return structure?.label || "\u81EA\u5206\u306E\u3082\u306E";
if (structure?.type === "plushie") return "ぬいぐるみ";
if (structure?.type === "grass_bed") return "かんたんベッド";
return structure?.label || "自分のもの";
}
function shouldPanicOwnerOnStructureGone(structure, reason = "") {
@ -54,8 +56,8 @@
const owner = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId);
if (!owner) return false;
const label = ownedStructureDestroyedLabel(structure);
const detail = `\u81EA\u5206\u306E${label}\u304C\u58CA\u3055\u308C\u305F`;
const panicReason = `\u81EA\u5206\u306E${label}\u3092\u58CA\u3055\u308C\u3066\u30D1\u30CB\u30C3\u30AF\u306B\u306A\u3063\u3066\u3044\u308B`;
const detail = `自分の${label}が壊された`;
const panicReason = `自分の${label}を壊されてパニックになっている`;
const threat = breaker || {
id: `${structure.id || "structure"}:destroyed`,
x: Number(structure.x) || owner.x,
@ -87,7 +89,7 @@
global.setBehaviorText(owner, {
need: "safety",
actionId: "panic_escape",
actionLabel: "\u30D1\u30CB\u30C3\u30AF\u306B\u306A\u3063\u3066\u3044\u308B",
actionLabel: "パニックになっている",
reasonText: panicReason,
causeText: detail,
target: threat,
@ -97,8 +99,8 @@
}
owner.thought = panicReason;
owner.behaviorLockTimer = Math.max(owner.behaviorLockTimer || 0, 1.2);
worldRef.spawnBubble?.(owner.x, owner.y - (owner.radius || 20) * 1.20, "\uFF01\uFF1F", "rgba(188,66,76,0.88)");
worldRef.log?.(`${owner.name || "\u305F\u308A\u306A\u3044"}\u306F\u81EA\u5206\u306E${label}\u3092\u58CA\u3055\u308C\u3066\u30D1\u30CB\u30C3\u30AF\u306B\u306A\u3063\u305F\u3002`, "danger");
worldRef.spawnBubble?.(owner.x, owner.y - (owner.radius || 20) * 1.20, "", "rgba(188,66,76,0.88)");
worldRef.log?.(`${owner.name || "たりない"}は自分の${label}を壊されてパニックになった。`, "danger");
worldRef.drawListDirty = true;
return true;
}
@ -112,7 +114,7 @@
if (!t || t.dead) continue;
const targetMatch = t.target === structure;
const sleepTargetMatch = t.sleepSession?.targetId && t.sleepSession.targetId === structure.id;
const nestMatch = (structure.type === "nest_box" || structure.type === "pipe") && t.insideNestBoxId === structure.id;
const nestMatch = structure.type === "nest_box" && t.insideNestBoxId === structure.id;
if (!targetMatch && !sleepTargetMatch && !nestMatch) continue;
if (nestMatch) {
@ -131,37 +133,6 @@
return released;
}
function removeDependentLinks(worldRef, target, reason = "support-removed") {
if (!worldRef || !target || !target.id) return 0;
const pb = global.TarinaiPhysicsBodySystem || null;
if (!pb?.endpoint) return 0;
let removed = 0;
const removedIds = new Set([target.id]);
let changed = true;
while (changed) {
changed = false;
for (const link of worldRef.items || []) {
if (!link || link.dead || (link.type !== "rope" && link.type !== "rod")) continue;
const a = pb.endpoint(link, 0);
const b = pb.endpoint(link, 1);
const hit = (a && a.kind === "item" && removedIds.has(a.id)) || (b && b.kind === "item" && removedIds.has(b.id));
if (!hit) continue;
link.amount = 0;
link.hp = 0;
removedIds.add(link.id);
removed += 1;
changed = true;
}
}
if (removed) {
worldRef.drawListDirty = true;
worldRef.markItemBucketsDirty?.("dependent-links-removed");
worldRef.markSpatialDirty?.("dependent-links-removed");
}
return removed;
}
function deleteStructure(worldRef, structure, opts = {}) {
if (!structure || structure.dead) return false;
const reason = opts.reason || "structure-deleted";
@ -169,7 +140,6 @@
const ownerPanic = shouldPanicOwnerOnStructureGone(structure, reason);
const changed = markStructureGone(structure, reason);
if (!changed) return false;
removeDependentLinks(worldRef, structure, reason);
if (ownerPanic && opts.panicOwner !== false) triggerOwnedStructureDestroyedPanic(worldRef, structure, opts.breaker || null, reason);
markWorldAfterStructureChange(worldRef, reason);
worldRef?.emit?.("structure:deleted", { structure, type: structure.type, reason });
@ -179,6 +149,7 @@
function consumeGrassBedOnWake(worldRef, bed, user = null, reason = "wake") {
if (!bed || bed.dead || bed.type !== "grass_bed") return false;
bed.singleUsePending = false;
bed.singleUseWakeReason = String(reason || "wake");
bed.singleUseConsumedById = user?.id || bed.singleUseConsumedById || null;
if (user?.target === bed) user.target = null;
const changed = markStructureGone(bed, "grass-bed-wake-used");
@ -194,7 +165,6 @@
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;
removeDependentLinks(worldRef, item, reason);
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;

View file

@ -31,6 +31,7 @@
this.carriedById = definition.type === "plushie" ? this.ownerId : "";
this.onHead = definition.type === "plushie";
if (definition.type === "plushie") {
this.plushieSpriteId = "plushie_bear";
this.plushieFlingTimer = 0;
this.plushieTrailTimer = 0;
this.spin = 0;
@ -151,15 +152,6 @@
let dx = (Number(this.x || 0) || 0) - (Number(sourceX || 0) || 0);
let dy = (Number(this.y || 0) || 0) - (Number(sourceY || 0) || 0);
let len = Math.hypot(dx, dy);
if (options.source === "poke" && owner) {
const fromOwnerX = (Number(this.x || owner.x || 0) || 0) - (Number(owner.x || 0) || 0);
const pointerSide = Math.sign((Number(this.x || 0) || 0) - (Number(sourceX || 0) || 0)) || Math.sign(fromOwnerX) || Number(owner.facing || owner.faceDir || 1) || 1;
if (len < 18 || Math.abs(dx) < Math.abs(dy) * 0.42) {
dx = pointerSide;
dy = -0.42;
len = Math.hypot(dx, dy);
}
}
if (len < 0.001 && owner) {
dx = Number(owner.facing || owner.faceDir || 1) || 1;
dy = -0.34;
@ -170,14 +162,9 @@
dy = -0.24;
len = Math.hypot(dx, dy);
}
const base = Math.max(520, Math.min(980, Number(options.speed || 650) || 650));
const base = Math.max(520, Math.min(900, Number(options.speed || 650) || 650));
vx = dx / len * base;
vy = dy / len * base - Math.max(80, base * 0.18);
if (options.source === "poke") {
const side = Math.sign(vx) || Math.sign(dx) || Number(owner?.facing || owner?.faceDir || 1) || 1;
vx = side * Math.max(Math.abs(vx), base * 0.58);
vy = Math.min(vy, -base * 0.26);
}
speed = Math.hypot(vx, vy);
} else if (speed < 520) {
const scale = 520 / Math.max(1, speed);
@ -185,11 +172,8 @@
vy *= scale;
speed = Math.hypot(vx, vy);
}
this.vx = clamp(vx, -1100, 1100);
this.vy = clamp(vy, -1100, 980);
this.prevX = this.x;
this.prevY = this.y;
this.detachedUntil = world ? Math.max(this.detachedUntil || 0, (Number(world.time || 0) || 0) + 0.35) : (this.detachedUntil || 0);
this.vx = clamp(vx, -980, 980);
this.vy = clamp(vy, -980, 980);
this.spin = Number(this.spin || 0) || 0;
const spinSign = Math.sign(this.vx) || (Math.random() < 0.5 ? -1 : 1);
const autoSpin = spinSign * (11 + Math.min(14, speed / 80));
@ -202,7 +186,7 @@
this._heldByPlayer = false;
if (owner) {
owner.target = null;
owner.thought = "\u306C\u3044\u3050\u308B\u307F\u304C\u98DB\u3093\u3067\u3044\u3063\u305F";
owner.thought = "ぬいぐるみが飛んでいった";
owner.pinchThoughtTimer = Math.max(owner.pinchThoughtTimer || 0, 1.1);
owner.surpriseTimer = Math.max(owner.surpriseTimer || 0, 0.45);
if (typeof applyNeedShock === "function") applyNeedShock(owner, { fulfill: 12, safety: 4 }, this);
@ -214,7 +198,7 @@
world?.markItemBucketsDirty?.("plushie-fling");
world?.markSpatialDirty?.("plushie-fling");
if (world) world.drawListDirty = true;
if (options.log !== false) world?.log?.("\u306C\u3044\u3050\u308B\u307F\u304C\u56DE\u8EE2\u3057\u306A\u304C\u3089\u98DB\u3093\u3067\u3044\u3063\u305F\u3002", "observe", { eventType: "plushie_fling", participants: owner ? [owner] : [] });
if (options.log !== false) world?.log?.("ぬいぐるみが回転しながら飛んでいった。", "observe", { eventType: "plushie_fling", participants: owner ? [owner] : [] });
return true;
}
@ -260,6 +244,7 @@
ctx.fill();
} else if (this.type === "plushie") {
const spriteId = "plushie_bear";
this.plushieSpriteId = spriteId;
const img = getRenderableImage(spriteId, spriteId);
ctx.rotate((Number(this.spin || 0) || 0) + Math.sin(time * 2 + this.seed) * 0.05);
if (img) {
@ -358,6 +343,7 @@
structure.unownedDecayTimer = 0;
structure.singleUsePending = true;
structure.singleUseConsumedById = user?.id || "";
structure.singleUseWakeReason = "sleep";
structure.amount = Math.max(0.001, structure.hp || structure.amount || 1);
worldRef?.markItemBucketsDirty?.("grass-bed-sleep-reserved");
worldRef?.markTerrainDirty?.("grass-bed-sleep-reserved");

View file

@ -15,13 +15,6 @@ function tarinaiRoundRectPath(ctx, x, y, w, h, r) {
ctx.quadraticCurveTo(x, y, x + rr, y);
}
function tarinaiChampionName(name = "") {
const map = { "\u306F": "\u8987", "\u3046": "\u5B87", "\u3045": "\u30A5", "\u3063": "\uFF01" };
return String(name || "").replace(/[\u306F\u3046\u3045\u3063]/g, ch => map[ch] || ch);
}
if (typeof window !== "undefined") window.tarinaiChampionName = tarinaiChampionName;
if (typeof globalThis !== "undefined") globalThis.tarinaiChampionName = tarinaiChampionName;
class Tarinai { constructor(world, opts = {}) {
if (world && !opts.birthSeed && !opts.familyKey && !opts.id && globalThis.TarinaiSeedFactory.prepareAddOptions) {
opts = globalThis.TarinaiSeedFactory.prepareAddOptions(world, opts) || opts;
@ -137,7 +130,6 @@ class Tarinai { constructor(world, opts = {}) {
this.intimidateTimer = opts.intimidateTimer ?? 0;
this.intimidateTargetId = opts.intimidateTargetId || null;
this.intimidatedTimer = opts.intimidatedTimer ?? 0;
this.intimidationCooldown = opts.intimidationCooldown ?? 0;
this.birthRitualTimer = opts.birthRitualTimer ?? 0;
this.birthRitualMax = opts.birthRitualMax ?? 0;
this.birthPartnerId = opts.birthPartnerId || null;
@ -176,7 +168,6 @@ class Tarinai { constructor(world, opts = {}) {
applyZunchiSlavePersonality(this, { record: false, birth: false });
this.visibleSpriteId = "zunchi_slave";
}
if (this.isTarinaiChampion) this.name = tarinaiChampionName(this.name);
this.tempComfort = opts.tempComfort ?? (CONFIG.standardTemperature ?? 15);
this.feltTemperature = opts.feltTemperature ?? this.tempComfort;
this.temperatureStatus = null;

View file

@ -71,8 +71,7 @@ function createSleepInBedActionSpec() {
if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label);
const isEasyBed = bed.type === "grass_bed";
bed.use?.(t, world, isEasyBed ? { purpose: "sleep" } : undefined);
const sleepText = bed.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b"));
t.startSleeping?.(bed, sleepText);
t.startSleeping?.(bed, bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b"));
return true;
},
tick(t) {
@ -208,12 +207,12 @@ function createTemperatureComfortActionSpec() {
need: "health",
subNeed: "temperature",
state: "seek_temperature",
label: "\u5FEB\u9069\u306A\u6E29\u5EA6\u5E2F\u3078\u79FB\u52D5\u3057\u3066\u3044\u308B",
phrase: "\u5FEB\u9069\u306A\u6E29\u5EA6\u5E2F\u3092\u63A2\u3057\u3066\u3044\u308B",
label: "快適な温度帯へ移動している",
phrase: "快適な温度帯を探している",
text(t, world) {
const felt = world?.feltTemperatureFor?.(t) ?? world?.temperatureAt?.(t?.x, t?.y, t) ?? t?.feltTemperature ?? t?.tempComfort ?? (CONFIG.standardTemperature ?? 15);
const status = world?.temperatureStatusFor?.(felt, t) || t?.temperatureStatus || null;
return status?.direction === "cold" ? "\u5BD2\u3044\u306E\u3067\u6E29\u307E\u308D\u3046\u3068\u3057\u3066\u3044\u308B\u3002" : "\u6691\u3044\u306E\u3067\u6DBC\u3082\u3046\u3068\u3057\u3066\u3044\u308B\u3002";
return status?.direction === "cold" ? "寒いので温まろうとしている。" : "暑いので涼もうとしている。";
},
weight: 42,
selectTarget(t, world, ctx = {}) {
@ -227,7 +226,7 @@ function createTemperatureComfortActionSpec() {
},
start(t, world, ctx = {}) {
const status = world?.temperatureStatusFor?.(world?.feltTemperatureFor?.(t) ?? world?.temperatureAt?.(t.x, t.y, t) ?? t?.feltTemperature ?? (CONFIG.standardTemperature ?? 15), t);
const label = status?.direction === "cold" ? "\u3042\u305F\u305F\u304B\u3044\u5834\u6240\u3078\u79FB\u52D5\u3057\u3066\u3044\u308B" : (status?.direction === "hot" ? "\u6DBC\u3057\u3044\u5834\u6240\u3078\u79FB\u52D5\u3057\u3066\u3044\u308B" : this.label);
const label = status?.direction === "cold" ? "あたたかい場所へ移動している" : (status?.direction === "hot" ? "涼しい場所へ移動している" : this.label);
const target = ctx.target ?? this.selectTarget(t, world, ctx);
return moveToOrUse(t, target, "seek_temperature", label);
},
@ -236,7 +235,7 @@ function createTemperatureComfortActionSpec() {
const status = world?.temperatureStatusFor?.(felt, t);
if (status?.comfortable || (status?.discomfort || 0) <= 1.5) {
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { health: 10, safety: 8 }, "temperature");
t.goIdle?.("\u5FEB\u9069\u306A\u6E29\u5EA6\u306B\u306A\u3063\u305F");
t.goIdle?.("快適な温度になった");
return "finished";
}
const target = t.target && !t.target.dead ? t.target : (ctx.target ?? this.selectTarget(t, world, { force: true, requireComfort: true }));
@ -427,17 +426,13 @@ function createIntimidateEnemyActionSpec() {
timerBacked: true,
selectTarget(t) { return t.recentFightRival?.(80, 180) || null; },
canStart(t, world, ctx = {}) {
// \u5A01\u5687\u306F\u901A\u5E38\u306E\u6B32\u6C42\u884C\u52D5\u3068\u3057\u3066\u306F\u9078\u3070\u305A\u3001\u55A7\u5629\u5224\u5B9A\u6642\u306E30%\u5206\u5C90\u3067\u5B9F\u884C\u3059\u308B\u3002
// 威嚇は通常の欲求行動としては選ばず、喧嘩判定時の30%分岐で実行する。
if (!isCurrentBehaviorForced(t)) return false;
if ((t.intimidateTimer || 0) > 0.04 || (t.intimidationCooldown || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false;
const target = ctx.target ?? this.selectTarget(t, world, ctx);
if (!isLiveTarinaiEntity(target) || target === t) return false;
if (target?.isTarinaiChampion || (target?.intimidationCooldown || 0) > 0.04) return false;
return basicCanStartWithTarget.call(this, t, world, { ...ctx, target });
if ((t.intimidateTimer || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false;
return basicCanStartWithTarget.call(this, t, world, ctx);
},
start(t, world, ctx = {}) {
const rival = ctx.target ?? this.selectTarget(t, world, ctx);
if (!isLiveTarinaiEntity(rival) || rival === t || rival.isTarinaiChampion) return false;
t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false });
t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85);
return true;
@ -445,8 +440,8 @@ function createIntimidateEnemyActionSpec() {
tick(t, world) {
if ((t.intimidateTimer || 0) > 0.04) return true;
const rival = currentBehaviorTarget(t) || t.recentFightRival?.(80, 180);
if (!isLiveTarinaiEntity(rival) || rival === t || rival.isTarinaiChampion) return "finished";
return world?.startIntimidation?.(t, rival) ? "finished" : false;
if (!rival) return false;
return world?.startIntimidation?.(t, rival) || true;
},
};
}
@ -528,7 +523,7 @@ const TARINAI_ACTION_DEFINITIONS = [
label: "\u904a\u3093\u3067\u3044\u308b",
phrase: "\u904a\u3093\u3060",
weight: 24,
selectTarget(t, world) { return world.nearest?.(t, ["ball", "balloon"], 520) || null; },
selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; },
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("play_ball", "\u904a\u3093\u3067\u3044\u308b"),
},

View file

@ -114,17 +114,9 @@
this.pendingGrassBedWakeId = target.id || this.pendingGrassBedWakeId || "";
target.singleUsePending = true;
target.singleUseConsumedById = this.id || target.singleUseConsumedById || "";
target.singleUseWakeReason = "sleep";
}
const targetIsNestContainer = Boolean(target && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe")));
if (target && !targetIsNestContainer) {
this._sleepExternalMotionUntil = 0;
this._sleepExternalWakeSuppressUntil = Math.max(Number(this._sleepExternalWakeSuppressUntil || 0) || 0, now + 2.4);
this.vx = 0;
this.vy = 0;
this.impulseVx = 0;
this.impulseVy = 0;
}
const text = reason || (target?.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"));
const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b");
setBehaviorText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" });
return this.setActionState("sleep", { target, reason: text, sleeping: true });
},
@ -147,9 +139,9 @@
const sleptFor = Number.isFinite(started) ? Math.max(0, now - started) : 0;
const wasActuallySleeping = Boolean((this.state === "sleep" || this.sleeping || session) && session?.consumesGrassBedOnWake);
// Reserve-on-use must not consume the simple bed. It is consumed only by
// a real wake after sleep has had time to settle; immediate collision or
// movement wakeups just release the reservation and leave the bed intact.
if (!wasActuallySleeping || sleptFor < 2) {
// a real wake after sleep has started; pre-sleep wake flags just release
// the reservation and leave the bed intact.
if (!wasActuallySleeping || sleptFor < 0.75) {
if (bed.singleUseConsumedById === this.id) bed.singleUseConsumedById = "";
if (bed.singleUsePending && (!bed.singleUseConsumedById || bed.singleUseConsumedById === this.id)) bed.singleUsePending = false;
this.pendingGrassBedWakeId = "";
@ -160,10 +152,18 @@
return window.TarinaiStructureLifecycle.consumeGrassBedOnWake(worldRef, bed, this, reason) || false;
},
seekTarget(state, target = null, reason = "") {
return this.setActionState(state, { target, reason, sleeping: false });
},
forceBehavior(actionId = "", opts = {}) {
return queueForcedTarinaiBehavior(this, actionId, opts);
},
currentBehaviorText() {
return behaviorText(this);
},
enterPanic(opts = {}) {
if (this.dead) return false;
const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 };

View file

@ -83,7 +83,7 @@
}
function isEffectCauseText(text = "") {
return /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
return /の効果$/.test(String(text || "").trim().replace(/[。!?]+$/, ""));
}
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
@ -237,6 +237,5 @@
clearTarinaiForcedBehavior: function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); },
isTarinaiBehaviorForced: function isTarinaiBehaviorForced(tarinai) { return isBehaviorForcedValue(ensureBehavior(tarinai)); },
isTarinaiBehaviorValueForced: isBehaviorForcedValue,
isTarinaiEffectCauseText: isEffectCauseText,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -49,9 +49,7 @@ function actionTextForReason(action = null, tarinai = null) {
}
function isEffectCauseText(text = "") {
return typeof globalThis.isTarinaiEffectCauseText === "function"
? globalThis.isTarinaiEffectCauseText(text)
: /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
return /の効果$/.test(String(text || "").trim().replace(/[。!?]+$/, ""));
}
function forcedCausePhrase(source = "", fallback = "") {
@ -103,7 +101,7 @@ function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world =
if (action?.id === "seek_comfort_temperature") {
const felt = tarinai?.world?.feltTemperatureFor?.(tarinai) ?? world?.temperatureAt?.(tarinai?.x, tarinai?.y, tarinai) ?? tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15);
const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null;
return status?.direction === "cold" ? "\u5BD2\u3044\u306E\u3067\u6E29\u307E\u308D\u3046\u3068\u3057\u3066\u3044\u308B\u3002" : "\u6691\u3044\u306E\u3067\u6DBC\u3082\u3046\u3068\u3057\u3066\u3044\u308B\u3002";
return status?.direction === "cold" ? "寒いので温まろうとしている。" : "暑いので涼もうとしている。";
}
const actionText = actionTextForReason(action, tarinai);
const cause = causePhraseForReason(chosenNeed, action, tarinai, options);
@ -146,8 +144,6 @@ function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {
}
function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) {
if (tarinai && (tarinai.burning || (tarinai.burnTimer || 0) > 0.02)) return globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
if (tarinai && ((tarinai.firePanicTimer || 0) > 0.02 || (tarinai.state === "panic" && tarinai.lastPanicCause === "fire_seen"))) return globalThis.TarinaiItemDynamicToolSystem?.fireSeenText || "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002";
const b = behavior || (currentTarinaiBehavior(tarinai)) || null;
if (!b) return "";
const stored = String(b.text || b.reason || "").trim();
@ -188,3 +184,4 @@ function refreshBehaviorReasonText(tarinai, action = null) {

View file

@ -108,7 +108,7 @@ function grassMaterialAvailableStages(material) {
return Math.max(0, Math.floor(Number(stage) || 0) + 1);
}
function reservedBuildGrassStages(world, material, type, self = null) {
function reservedGrassStagesForBuildMaterial(world, material, type, self = null) {
if (!world || !material) return 0;
const id = material.id || "";
if (!id) return 0;
@ -140,7 +140,7 @@ function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) {
if (world?.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65))) continue;
const available = grassMaterialAvailableStages(item);
if (available < required) continue;
const reserved = reservedBuildGrassStages(world, item, type, tarinai);
const reserved = reservedGrassStagesForBuildMaterial(world, item, type, tarinai);
if (available - reserved < required) continue;
const d = dist(tarinai, item);
if (d > maxDist) continue;
@ -192,7 +192,7 @@ function continueBuildPlan(t, world, dt) {
? { 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, "\u4F5C\u308B\u5834\u6240\u304C\u898B\u3064\u304B\u3089\u306A\u3044");
return stopFailedBuildPlan(t, world, plan.type, "作る場所が見つからない");
}
consumeGrassMaterial(material, structureGrassStageCost(plan.type));
const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world);

View file

@ -116,7 +116,10 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) {
if (target.type === "zunchi" && (target.amount || 0) > 0) {
const foodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0;
if (zunchiEatingBlocked(t, target, world)) return false;
if (!t.isZunchiSlave && hasBetterFoodThanZunchiNearby(t, world, 900)) return false;
if (!t.isZunchiSlave && hasBetterFoodThanZunchiNearby(t, world, 240)) return false;
const emergencyHunger = (t.hunger || 0) >= 104 || foodNeed >= 94;
if (!emergencyHunger && !t.isZunchiSlave && target.stage === "fresh" && (target.age || 0) < 150 && foodNeed < 94 && (t.hunger || 0) < 104) return false;
if (!t.isZunchiSlave && foodNeed < 90 && (t.hunger || 0) < 100) return false;
const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1));
const bite = Math.min(target.amount, (t.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale);
if (!(bite > 0)) return false;

View file

@ -7,28 +7,25 @@
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
const { foodActionActive = false } = ctx;
const ceremonialContact = it.type === "slave_chain" || it.type === "golden_crown";
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, {
role: ceremonialContact ? "medicine" : "food",
activeTargetBonus: it?.type === "duplicator" ? 0 : (ceremonialContact ? 10 : 4),
});
if (!contact) return false;
const d = contact.distance;
if (!it || it.dead) return false;
const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it);
const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0);
if (d > contactReach) return false;
if (ceremonialContact) {
const itemLabel = typeof toolLabel === "function" ? toolLabel(it.type) : (it.type === "slave_chain" ? "\u5974\u96B7\u306E\u9396" : "\u9EC4\u91D1\u306E\u51A0");
if (it.type === "slave_chain" || it.type === "golden_crown") {
const itemLabel = typeof toolLabel === "function" ? toolLabel(it.type) : (it.type === "slave_chain" ? "奴隷の鎖" : "黄金の冠");
if (it.type === "slave_chain") {
tarinai.isTarinaiChampion = false;
tarinai.tarinaiChampionSince = 0;
tarinai.becomeZunchiSlave?.({ locked: true, forcedItem: true });
tarinai.bubble("\u9396", 2.4, "rgba(74,64,56,0.82)");
tarinai.world?.log?.(`${tarinai.name}\u306F${itemLabel}\u306B\u89E6\u308C\u3066\u305A\u3093\u3061\u3069\u308C\u3044\u306B\u306A\u3063\u305F\u3002`, "event", { participants: [tarinai], sound: false });
tarinai.bubble("", 2.4, "rgba(74,64,56,0.82)");
tarinai.world?.log?.(`${tarinai.name}${itemLabel}に触れてずんちどれいになった。`, "event", { participants: [tarinai], sound: false });
} else {
tarinai.becomeTarinaiChampion?.({ force: true, forcePersonality: true, forcedItem: true });
tarinai.bubble("\u738B", 2.4, "rgba(166,112,22,0.84)");
tarinai.world?.log?.(`${tarinai.name}\u306F${itemLabel}\u306B\u89E6\u308C\u3066\u305F\u308A\u306A\u3044\u738B\u306B\u306A\u3063\u305F\u3002`, "event", { participants: [tarinai], sound: false });
tarinai.bubble("", 2.4, "rgba(166,112,22,0.84)");
tarinai.world?.log?.(`${tarinai.name}${itemLabel}に触れてたりない王になった。`, "event", { participants: [tarinai], sound: false });
}
global.TarinaiStructureLifecycle?.deleteItem?.(tarinai.world, it, { reason: `consumed-${it.type}`, userReason: `${itemLabel}\u304C\u4F7F\u308F\u308C\u305F`, wake: false, panicOwner: false });
global.TarinaiStructureLifecycle?.deleteItem?.(tarinai.world, it, { reason: `consumed-${it.type}`, userReason: `${itemLabel}が使われた`, wake: false, panicOwner: false });
tarinai.world?.recordFamily?.(tarinai);
tarinai.world?.markFamilyTreeDirty?.(`consume-${it.type}`);
if (global.uiCache) global.uiCache.selectedSnapshot = "";
@ -60,8 +57,7 @@ const d = contact.distance;
const occ = tarinai.world.bedOccupancy(it);
const comfort = tarinai.world.bedComfort(it);
it.use?.(tarinai, tarinai.world, isEasyBed ? { purpose: "sleep" } : undefined);
const sleepText = it.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
tarinai.startSleeping?.(it, sleepText);
tarinai.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
if (tarinai.world.time > tarinai.nextSleepBubbleAt) {
tarinai.nextSleepBubbleAt = tarinai.world.time + 4.5;
tarinai.world.spawnBubble(tarinai.x, tarinai.y - tarinai.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
@ -72,7 +68,7 @@ const d = contact.distance;
}
}
if (it.type === "ball" || it.type === "balloon") {
if (it.type === "ball") {
const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1);
if (tarinai.state === "play_ball" || tarinai.target === it || tarinai.shouldApplyPersonalityBehavior("openness", 1)) {
tarinai.goodMode = "smile";

View file

@ -4,13 +4,6 @@
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_disease_nest.js");
function isNestContainerItem(item) {
return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
}
function containerLabel(item) { return item?.type === "pipe" ? "\u571f\u7ba1" : "\u5de3\u7bb1"; }
function containerInsideSleepText(item, verb = "\u7720\u3063\u3066\u3044\u308b") { return `${containerLabel(item)}\u306e\u4e2d\u3067${verb}`; }
function containerFullText(item) { return `${containerLabel(item)}\u304c\u3044\u3063\u3071\u3044\u3067\u5165\u308c\u306a\u3044`; }
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
infectZunchiDisease(source = null, force = false) {
if (this.dead || this.zunchiDisease) return false;
@ -200,7 +193,7 @@
nestBoxById(id) {
if (!id) return null;
return this.world?.items?.find?.(it => it && !it.dead && it.id === id && isNestContainerItem(it)) || null;
return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null;
},
nestBoxEntryActive() {
@ -210,7 +203,7 @@
return now < (this._nestBoxEntrySmoothUntil || -Infinity) || ((this.nestFade || 0) > 0.01 && (this.nestFade || 0) < 0.985);
},
abortNestBoxEntry(box = null, reason = containerFullText(box)) {
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;
@ -240,15 +233,14 @@
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 0.7);
this._nestBoxSeekStartedAt = NaN;
this._nestBoxSeekTargetId = "";
const finalReason = reason || containerFullText(box);
this.goIdle?.(finalReason);
this.thought = finalReason;
this.goIdle?.(reason);
this.thought = reason;
this.pinchThoughtTimer = Math.max(this.pinchThoughtTimer || 0, 1.0);
return false;
},
wantsNestBox(box = null) {
if (!isNestContainerItem(box)) return false;
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;
@ -256,28 +248,13 @@
return this.target === box || this.insideNestBoxId === box.id;
},
enterNestBox(box, dt = 0.016, options = {}) {
if (!isNestContainerItem(box) || this.dead) return false;
enterNestBox(box, dt = 0.016) {
if (!box || box.dead || box.type !== "nest_box" || this.dead) return false;
const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5;
let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
const alreadyInside = this.insideNestBoxId === box.id;
const now = this.world?.time || 0;
if (!alreadyInside && occupants.length >= capacity) {
if (options?.forceDisplace) {
const victim = occupants.find(t => t && t !== this && !t.dead && !t.isTarinaiChampion)
|| occupants.find(t => t && t !== this && !t.dead);
if (victim) {
victim.leaveNestBox?.(box);
victim.awakeLockTimer = Math.max(victim.awakeLockTimer || 0, 0.8);
victim.fearTimer = Math.max(victim.fearTimer || 0, 0.22);
victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`;
victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65);
victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`);
occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim);
}
}
if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, containerFullText(box)) || false;
}
if (!alreadyInside && occupants.length >= capacity) return this.abortNestBoxEntry?.(box, "巣箱がいっぱいで入れない") || false;
if (!alreadyInside) {
this._nestBoxSeekStartedAt = NaN;
this._nestBoxSeekTargetId = "";
@ -294,7 +271,7 @@
}
this.insideNestBoxId = box.id;
this.target = box;
if (this.state === "seek_bed") this.startSleeping(box, containerInsideSleepText(box, "\u7720\u3063\u3066\u3044\u308b"));
if (this.state === "seek_bed") this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
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);
@ -336,24 +313,25 @@
if (!b || !this.world?.nestBoxEntryPoint) {
this._nestBoxExitSmoothUntil = -Infinity;
this._nestBoxExitDuration = 0;
this._nestBoxExitBoxId = "";
return false;
}
const exitContainer = b.type === "pipe" && this.world?.randomPipeExitFor ? (this.world.randomPipeExitFor(b, this) || b) : b;
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(exitContainer, this) : { x: exitContainer.x, y: exitContainer.y };
const door = this.world.nestBoxEntryPoint(exitContainer);
const exit = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(exitContainer, this) : door;
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) > (exitContainer.r || b.r || 42) * 0.7) {
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 : exitContainer.x;
this._nestBoxExitDoorY = Number.isFinite(door.y) ? door.y : exitContainer.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;
},
@ -392,6 +370,7 @@
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;
@ -413,7 +392,7 @@
}
this.sleeping = false;
this.sleepSession = null;
if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.(`${containerLabel(b)}\u304b\u3089\u51fa\u305f`);
if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.("巣箱から出た");
this.nestBoxStayUntil = -Infinity;
this.nestBoxSlotIndex = -1;
this._nestBoxEntrySmoothUntil = -Infinity;
@ -430,7 +409,7 @@
updateNestBoxPresence(dt = 0) {
if (this.updateNestBoxExitAnimation?.(dt || 0.016)) return null;
const currentBox = this.nestBoxById(this.insideNestBoxId);
const targetBox = isNestContainerItem(this.target) ? this.target : null;
const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null;
const box = currentBox || targetBox;
if (!box) {
this.insideNestBoxId = null;
@ -445,7 +424,7 @@
if (currentBox) {
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) {
this.startSleeping(box, containerInsideSleepText(box, "\u7720\u3063\u3066\u3044\u308b"));
this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
this.sleeping = true;
this.target = currentBox;
}
@ -471,7 +450,7 @@
this._nestBoxSeekTargetId = targetBox.id;
}
if (!this.insideNestBoxId && global.TarinaiNestSleepSystem.sleepPlaceAvailableFor && !global.TarinaiNestSleepSystem.sleepPlaceAvailableFor(this.world, this, targetBox)) {
return this.abortNestBoxEntry?.(targetBox, containerFullText(targetBox || box)) || null;
return this.abortNestBoxEntry?.(targetBox, "巣箱がいっぱいで入れない") || null;
}
const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y };
@ -505,97 +484,6 @@
if (this.explosionDisease) this.recoverExplosionDisease("\u6c34\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
},
igniteFire(source = null, force = false) {
if (this.dead) return false;
const already = Boolean(this.burning || (this.burnTimer || 0) > 0.02);
if (already && !force) return false;
const text = globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
this.burning = true;
this.burnTimer = Math.max(Number(this.burnTimer || 0) || 0, 5.2 + deterministicRange(this.world, "tarinai-burn-duration", 0, 2.8, this, source || null));
this.firePanicTimer = 0;
this.lastPanicCause = "burning";
this.lastPanicDetail = text;
this.thought = text;
this.sleeping = false;
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 0.8);
this.fearTimer = Math.max(this.fearTimer || 0, 2.2);
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65);
this.eatTimer = 0;
this.target = null;
this.burnWanderAngle = Number.isFinite(this.burnWanderAngle) ? this.burnWanderAngle : deterministicAngle(this.world, "tarinai-burn-angle", this, source || null);
if (typeof this.enterPanic === "function") this.enterPanic({ threat: source, target: null, reason: text, fear: 2.2, wake: true, cause: "burning", surpriseTimer: 0.65, awakeLockTimer: 0.8 });
else this.setActionState?.("panic", { target: null, reason: text, wake: true });
this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(26, this.radius * 1.35), life: 0.26, color: "rgba(255,110,36,0.58)" }));
const now = Number(this.world?.time || 0);
if (force || !already || now >= Number(this.nextBurnVoiceAt || -999)) {
this.nextBurnVoiceAt = now + 2.6;
this.bubble?.("\u3042\u3061\u3085\u3044\uff01", 1.35, "rgba(214,82,38,0.90)", { force: true });
}
this.world?.emit?.("fire:ignite", { tarinai: this, source });
this.recordChangeCause?.("\u708e\u4e0a", "\u708e\u4e0a");
return true;
},
extinguishFire(reason = "") {
if (!this.burning && !(this.burnTimer > 0)) return false;
this.burning = false;
this.burnTimer = 0;
this.firePanicTimer = Math.max(this.firePanicTimer || 0, 0.25);
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.35);
if (reason) this.thought = reason;
this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(22, this.radius * 1.2), life: 0.22, color: "rgba(108,192,236,0.60)" }));
this.world?.emit?.("fire:extinguish", { tarinai: this, reason });
return true;
},
updateBurning(dt) {
this.firePanicTimer = Math.max(0, Number(this.firePanicTimer || 0) - dt);
if (!this.burning && !(this.burnTimer > 0)) return;
const text = globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
this.burning = true;
this.burnTimer = Math.max(0, Number(this.burnTimer || 0) - dt);
this.lastPanicCause = "burning";
this.lastPanicDetail = text;
this.thought = text;
this.fearTimer = Math.max(this.fearTimer || 0, 0.8);
this.sleeping = false;
this.eatTimer = 0;
if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 6.2, safety: dt * 3.0 });
if (typeof this.damage === "function") this.damage(dt * 3.8, "\u708e\u4e0a");
else this.energy = clamp((this.energy || 0) - dt * 0.12, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.22);
const water = globalThis.TarinaiItemDynamicToolSystem?.contactWaterDrop?.(this.world, this.x, this.y, Math.max(this.radius || 18, 16));
if (water) {
globalThis.TarinaiItemDynamicToolSystem?.consumeWaterForFire?.(this.world, water, Math.max(10, (this.radius || 18) * 0.55));
this.extinguishFire("\u6c34\u3067\u6d88\u706b\u3055\u308c\u305f");
return;
}
const mul = globalThis.TarinaiItemDynamicToolSystem?.fireTempMultiplier?.(this.world, this.x, this.y, this) || 1;
if (deterministicChance(this.world, "tarinai-fire-extinguish", dt * 0.016 * mul, this)) {
this.extinguishFire("\u81ea\u7136\u306b\u706b\u304c\u6d88\u3048\u305f");
return;
}
for (const viewer of [...(this.world?.nearbyTarinai?.(this.x, this.y, 190, true) || [])]) {
if (!viewer || viewer === this || viewer.dead) continue;
globalThis.TarinaiItemDynamicToolSystem?.panicTarinaiFromFire?.(viewer, this, this.world);
}
const contactRadius = Math.max(20, (this.radius || 18) * 1.1);
for (const other of [...(this.world?.nearbyTarinai?.(this.x, this.y, contactRadius + 28, true) || [])]) {
if (!other || other === this || other.dead) continue;
if (distXY(this.x, this.y, other.x, other.y) <= contactRadius + Math.max(other.radius || 18, 12) * 0.72) other.igniteFire?.(this, false);
}
for (const ant of [...(this.world?.nearbyAnts?.(this.x, this.y, contactRadius + 18, true) || [])]) {
if (!ant || ant.dead) continue;
if (distXY(this.x, this.y, ant.x, ant.y) <= contactRadius + Math.max(ant.r || 4, 4)) globalThis.TarinaiItemDynamicToolSystem?.igniteAntByFire?.(ant, this, this.world);
}
for (const it of [...(this.world?.nearbyItems?.(this.x, this.y, contactRadius + 32, true) || [])]) {
if (!it || it.dead || it.type !== "grass") continue;
if (distXY(this.x, this.y, it.x, it.y) <= contactRadius + Math.max(it.r || 16, 12)) globalThis.TarinaiItemDynamicToolSystem?.igniteGrassByFire?.(it, this, this.world, dt);
}
if (this.burnTimer <= 0.01) this.extinguishFire("\u81ea\u7136\u306b\u706b\u304c\u6d88\u3048\u305f");
},
updateSpecialDiseases(dt) {
this.sleepDiseaseCooldown = Math.max(0, (this.sleepDiseaseCooldown || 0) - dt);
this.fightDiseaseCooldown = Math.max(0, (this.fightDiseaseCooldown || 0) - dt);

View file

@ -7,9 +7,9 @@
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
const { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx;
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, { role: "food" });
if (!contact) return false;
const d = contact.distance;
const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it);
const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive ? (it?.type === "duplicator" ? 18 : 4) : 0);
if (d > contactReach) return false;
const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type;
const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi";

View file

@ -1,62 +0,0 @@
"use strict";
// Layer: tarinai/food-prototype
// Food serving and hunger-relief helpers mixed into Tarinai.prototype.
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) return;
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
isServingFoodItem(it) {
if (it?.type === "duplicator" && it.storedFoodType) return true;
return Boolean(it && isServingFoodType(it.type));
},
foodItemHasServingLeft(it) {
if (!it || it.dead) return false;
if (it.type === "duplicator") return Boolean(it.storedFoodType);
if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2;
this.ensureFoodServings(it);
return (it.foodServingsRemaining || 0) > 0;
},
ensureFoodServings(it) {
if (it?.type === "duplicator") return;
if (!this.isServingFoodItem(it)) return;
const max = foodServingsForSize(it.toolSize || "medium");
if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max;
if (!Number.isFinite(it.foodServingsRemaining)) {
const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, laxative: 58, protein: 58, niteropu: 58, ammo: 48, mystery_drug: 52, mercury: 44, giant_drug: 52, dwarf_drug: 52, zunda_juice: 70 }[it.type] || 62;
const ratio = clamp((it.amount ?? original) / original, 0.05, 1);
it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio));
}
it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax);
it.amount = it.foodServingsRemaining;
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(it) : false);
},
consumeFoodServing(it, nutrition) {
if (it?.type === "duplicator") {
if (!it.storedFoodType) return 0;
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
it.usedCount = (it.usedCount || 0) + 1;
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.storedFoodType, sourceType: "duplicator", nutrition: value });
return value;
}
this.ensureFoodServings(it);
if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0;
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.type, 0) ?? 0);
it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1);
it.amount = it.foodServingsRemaining;
const resized = (typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(it) : false);
if (resized) {
this.world?.markItemBucketsDirty?.("food-serving-resize");
this.world?.markSpatialDirty?.("food-serving-resize");
this.world && (this.world.drawListDirty = true);
}
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value });
return value;
},
foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) {
const relief = window.TarinaiItemRegistry.food?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier;
return (Number(nutrition) || 0) * relief;
},
}));
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -277,6 +277,7 @@ function retryForcedBehavior(tarinai, behavior = null, reason = "retry") {
const entry = forcedBehaviorQueueOf(tarinai).find(e => e && e.uid === forcedId);
if (!entry) return false;
entry.status = "queued";
entry.lastFailedAt = now;
entry.failReason = reason;
entry.activeSince = 0;
if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
@ -303,7 +304,8 @@ function processForcedBehaviorQueue(tarinai, world, needs) {
} else if (entry.status === "active") {
// behavior \u304c\u5916\u90e8\u8981\u56e0\u3067\u6d88\u3048\u305f\u5834\u5408\u306f\u3001\u671f\u9650\u5185\u306a\u3089\u518d\u8a66\u884c\u3078\u623b\u3059\u3002
entry.status = "queued";
}
entry.lastFailedAt = now;
}
}
queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0)));
@ -373,7 +375,7 @@ function processForcedBehaviorQueue(tarinai, world, needs) {
}
function effectCauseTextLooksStale(text = "") {
return /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
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 = "" } = {}) {

View file

@ -145,18 +145,9 @@
becomeTarinaiChampion(options = {}) {
if (!this.tarinaiChampionEligible?.() && !options.force) return false;
if (this.isTarinaiChampion && !options.forcePersonality) {
if (typeof tarinaiChampionName === "function") this.name = tarinaiChampionName(this.name);
return false;
}
if (this.isTarinaiChampion && !options.forcePersonality) return false;
this.isTarinaiChampion = true;
this.tarinaiChampionSince = Number.isFinite(this.tarinaiChampionSince) && this.tarinaiChampionSince > 0 ? this.tarinaiChampionSince : (this.world?.time || 0);
if (typeof tarinaiChampionName === "function") {
this.name = tarinaiChampionName(this.name);
} else {
const map = { "\u306F": "\u8987", "\u3046": "\u5B87", "\u3045": "\u30A5", "\u3063": "\uFF01" };
this.name = String(this.name || "").replace(/[\u306F\u3046\u3045\u3063]/g, ch => map[ch] || ch);
}
ensurePersonality(this);
const boosted = [];
for (const key of ["aggression", "openness", "sociability"]) {
@ -168,7 +159,7 @@
}
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65);
this.focusPulseTimer = Math.max(this.focusPulseTimer || 0, 1.0);
if (boosted.length) this.recordChangeCause?.("\u305F\u308A\u306A\u3044\u738B\u8005", boosted.join("\u30FB"), { value: +1 });
if (boosted.length) this.recordChangeCause?.("たりない王者", boosted.join("・"), { value: +1 });
this.world?.recordFamily?.(this);
return true;
},
@ -382,8 +373,7 @@
},
targetIgnoresFence(target = this.target) {
const targetIsContainer = Boolean(target && (typeof isNestContainerType === "function" ? isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe")));
return Boolean(target?.type === "sweet" || targetIsContainer || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy");
return Boolean(target?.type === "sweet" || target?.type === "nest_box" || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy");
},
shouldAvoidTarget(target) {
@ -464,7 +454,7 @@
},
lowHealthSprite() {
// \u4F53\u529B\u6BD4\u7387\u306B\u3088\u308B\u6BB5\u968E\u8868\u793A: 30%\u4EE5\u4E0B cry\u300120%\u4EE5\u4E0B weak\u300110%\u4EE5\u4E0B stretch\u3002
// 体力比率による段階表示: 30%以下 cry、20%以下 weak、10%以下 stretch。
const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100);
const energyRatio = (Number(this.energy) || 0) / Math.max(1, Number(maxEnergy) || 100);
if (energyRatio <= 0.10) return "stretch";

View file

@ -98,6 +98,10 @@
return this.radius;
},
effectiveMaxEnergy() {
return typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100);
},
energyRatio() {
return typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp((this.energy || 0) / Math.max(1, this.maxEnergy || 100), 0, 1);
},
@ -300,6 +304,7 @@
this.poopCount = (this.poopCount || 0) + 1;
this.digest = Math.max(0, (this.digest || 0) - 3.0);
const now = this.world?.time || 0;
this.nextPoopBubbleAt = now + 2.2;
this.bubble("\u3076\u308a\u3085\u3063", 1.2, effectColor("laxative"), { force: true });
if ((this.world.time || 0) > (this.nextLaxativeLogAt || -999)) {
this.nextLaxativeLogAt = (this.world.time || 0) + 9;

View file

@ -62,22 +62,8 @@
return true;
}
function contactForCandidate(tarinai, ctx, item, opts = {}) {
if (!tarinai || tarinai.dead || !ctx || !item || item.dead) return null;
const role = opts.role || "food";
const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item);
const baseReach = foodInteractionReach(tarinai, item, role);
const activeBonus = item === tarinai.target && ctx.foodActionActive
? Number(opts.activeTargetBonus ?? (item.type === "duplicator" ? 18 : 4))
: 0;
const contactReach = baseReach + activeBonus;
if (d > contactReach) return null;
return { distance: d, reach: contactReach, baseReach };
}
global.TarinaiItemInteractionContext = Object.freeze({
create,
finalize,
contactForCandidate,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -11,7 +11,7 @@ function isDuplicatorFoodSource(item) {
return !!item && item.type === "duplicator";
}
const NORMAL_POOP_MEAL_THRESHOLD = 4;
const NORMAL_POOP_MEAL_THRESHOLD = 3;
function normalPoopUnitsFor(foodType = "", amount = 0, options = {}) {
const type = String(foodType || "");
@ -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" || item.type === "pipe" || item.type === "grass_bed";
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;
@ -54,14 +54,6 @@ function foodInteractionDistance(tarinai, item) {
}
function foodInteractionReach(tarinai, item, role = "food") {
if (item?.type === "slave_chain" || item?.type === "golden_crown") {
// \u738B\u51A0\u30FB\u9396\u306F\u7DDA\u753B/\u6A2A\u9577\u306E\u5916\u89B3\u306A\u306E\u3067\u3001\u901A\u5E38\u306E\u98DF\u7269\u5224\u5B9A\u3088\u308A\u898B\u305F\u76EE\u57FA\u6E96\u3067\u5E83\u304F\u53D6\u308B\u3002
// \u4E2D\u5FC3\u70B9\u304C\u5C11\u3057\u96E2\u308C\u3066\u3044\u3066\u3082\u3001\u305F\u308A\u306A\u3044\u306E\u4F53\u8868\u3068\u30A2\u30A4\u30C6\u30E0\u5916\u89B3\u304C\u89E6\u308C\u305F\u6642\u70B9\u3067\u53D6\u5F97\u3067\u304D\u308B\u3002
const bodyR = Math.max(16, Number(tarinai?.radius || 20) || 20);
const itemR = Math.max(item.type === "slave_chain" ? 15 : 16, Number(item?.r || 0) || 0);
const visualPad = item.type === "slave_chain" ? 14 : 12;
return Math.max(46, bodyR * 0.94 + itemR * 1.28 + visualPad);
}
if (item?.type === "duplicator") return Math.max(30, (tarinai?.radius || 20) * 0.72 + (item?.r || 34) * 0.36);
if (role === "drink") return Math.max(16, (tarinai?.radius || 20) * 0.58 + (item?.r || 12) * 0.40);
return Math.max(13, (tarinai?.radius || 20) * 0.50 + (item?.r || 12) * 0.28);
@ -106,36 +98,42 @@ function foodPriorityScore(tarinai, item, distance = 0) {
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
if (!tarinai || !world) return false;
const source = world.nearbyItems?.(tarinai.x, tarinai.y, maxDist, true) || world.items || [];
for (const it of source) {
if (!it || it.dead) continue;
const type = effectiveFoodTypeForItem(it);
if (!type || type === "zunchi") continue;
if (!roleMatches(it, "food")) continue;
for (const it of world.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world.items || []) {
if (!it || it.dead || it.type === "zunchi") continue;
if (tarinai.shouldAvoidTarget?.(it)) continue;
if (tarinaiTargetBlockedByObstacle(world, tarinai, it, "food")) continue;
if (it.type === "grass" && (it.amount || 0) <= 0) continue;
if (it.type === "duplicator" || isServingFoodType(type) || ["sweet", "food", "zunda_juice"].includes(type)) {
if (typeof tarinai.foodItemHasServingLeft === "function" && !tarinai.foodItemHasServingLeft(it)) continue;
const type = effectiveFoodTypeForItem(it);
if (!type) continue;
if (["sweet", "food", "grass", "water", "zunda_juice", "ant_corpse"].includes(type) || (isServingFoodType(type))) {
if (it.type === "grass" && (it.amount || 0) <= 0) continue;
if (typeof tarinai.foodItemHasServingLeft === "function" && ["sweet", "food", "zunda_juice"].includes(type) && !tarinai.foodItemHasServingLeft(it)) continue;
return true;
}
return true;
}
return false;
}
function zunchiEatingBlocked(tarinai, item, world) {
if (!tarinai || !item || item.type !== "zunchi") return false;
if ((Number(item.amount || 0) || 0) <= 0) return true;
const itemAge = Number(item.age || 0) || 0;
const worldAge = Number.isFinite(item.producedAt) ? Math.max(0, (world?.time || 0) - item.producedAt) : itemAge;
const protectedAge = Math.max(itemAge, worldAge);
const protectedUntil = Number(item.eatProtectedUntil || 0) || 0;
// \u305A\u3093\u3061\u306F\u98DF\u7269\u306E\u6700\u4E0B\u4F4D\u5019\u88DC\u3002\u307B\u304B\u306B\u98DF\u3079\u7269\u304C\u3042\u308B\u9593\u3060\u3051\u4FDD\u8B77\u3057\u3001
// \u307B\u304B\u306B\u98DF\u3079\u7269\u304C\u7121\u3044\u5834\u5408\u306F\u512A\u5148\u9806\u3069\u304A\u308A\u6700\u5F8C\u306E\u98DF\u6599\u3068\u3057\u3066\u98DF\u3079\u3089\u308C\u308B\u3002
if (protectedUntil > 0 && (world?.time || 0) < protectedUntil && hasBetterFoodThanZunchiNearby(tarinai, world, 900)) return true;
if (protectedUntil > 0 && (world?.time || 0) < protectedUntil) return true;
const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
const hunger = Number(tarinai.hunger || 0) || 0;
// \u6392\u6cc4\u76f4\u5f8c\u306e\u305a\u3093\u3061\u306f\u89b3\u5bdf\u30fb\u63a5\u89e6\u5bfe\u8c61\u3067\u3042\u308a\u3001\u901a\u5e38\u98df\u3068\u3057\u3066\u5373\u6d88\u8cbb\u3055\u305b\u306a\u3044\u3002
// \u305a\u3093\u3061\u3069\u308c\u3044\u3082\u6570\u79d2\u306f\u5f85\u3064\u3002\u901a\u5e38\u500b\u4f53\u306f\u6975\u9650\u98e2\u9913\u3067\u3082\u6700\u4f4e90\u79d2\u306f\u98df\u3079\u306a\u3044\u3002
const emergency = hunger >= 104 || foodNeed >= 94;
const grace = tarinai.isZunchiSlave ? (emergency ? 4 : 18) : (emergency ? 20 : 90);
if (protectedAge < grace) return true;
if (!emergency && !tarinai.isZunchiSlave && item.stage === "fresh" && protectedAge < 150 && foodNeed < 94 && hunger < 104) return true;
return false;
}
function tarinaiTargetBlockedByObstacle(world, tarinai, item, role = "") {
if (!world || !tarinai || !item) return false;
// \u305A\u3093\u3060\u9905\u3060\u3051\u306F\u969C\u5BB3\u7269\u8D8A\u3057\u3067\u3082\u98DF\u3079\u306B\u884C\u304F\u3002
// ずんだ餅だけは障害物越しでも食べに行く。
if ((role === "food" || role === "medicine") && effectiveFoodTypeForItem(item) === "sweet") return false;
return Boolean(world.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65)));
}
@ -166,8 +164,8 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) {
if (it && !it.dead && !candidates.includes(it)) candidates.push(it);
};
// \u98DF\u6599\u30FB\u6C34\u30FB\u85AC\u306F\u3001\u901A\u5E38\u6642\u304B\u3089\u7A7A\u9593\u30A4\u30F3\u30C7\u30C3\u30AF\u30B9\u3060\u3051\u306B\u4F9D\u5B58\u3057\u306A\u3044\u3002
// \u751F\u6210\u76F4\u5F8C/\u79FB\u52D5\u76F4\u5F8C\u306E\u30A2\u30A4\u30C6\u30E0\u304C nearbyFood \u306E\u30AD\u30E3\u30C3\u30B7\u30E5\u304B\u3089\u6F0F\u308C\u3066\u3082\u3001\u8DDD\u96E2\u3068 roleMatches \u3067\u6700\u7D42\u5224\u5B9A\u3059\u308B\u3002
// 食料・水・薬は、通常時から空間インデックスだけに依存しない。
// 生成直後/移動直後のアイテムが nearbyFood のキャッシュから漏れても、距離と roleMatches で最終判定する。
if (foodLikeRole) {
if (Array.isArray(world?.items)) for (const it of world.items) addCandidate(it);
if (world?.itemsOfType) {
@ -322,15 +320,31 @@ function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) {
}
function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) {
return globalThis.TarinaiNestSleepSystem.findOwnedStructure(world, tarinai, type, maxDist, {
blocked: (worldRef, actor, item) => tarinaiTargetBlockedByObstacle(worldRef, actor, item, "structure"),
}) || null;
let best = null, bestD = maxDist;
for (const item of world?.items || []) {
if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue;
if (Number(item.unreachableUntil || 0) > Number(world?.time || 0)) continue;
if (type && item.type !== type) continue;
if (tarinaiTargetBlockedByObstacle(world, tarinai, item, "structure")) continue;
const d = dist(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) {
return globalThis.TarinaiNestSleepSystem.findUnownedStructure(world, tarinai, type, maxDist, {
blocked: (worldRef, actor, item) => tarinaiTargetBlockedByObstacle(worldRef, actor, item, "structure"),
}) || null;
let best = null, bestD = maxDist;
for (const item of world?.items || []) {
if (!item?.isStructure || item.dead || item.ownerId) continue;
if (item.abandonedById && item.abandonedById === tarinai?.id && Number(item.abandonedUntil || 0) > Number(world?.time || 0)) continue;
if (Number(item.unreachableUntil || 0) > Number(world?.time || 0)) continue;
if (type && item.type !== type) continue;
if (tarinai?.shouldAvoidTarget?.(item)) continue;
if (tarinaiTargetBlockedByObstacle(world, tarinai, item, "structure")) continue;
const d = dist(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function consumeGrassMaterial(material, stages = 1) {
@ -348,3 +362,4 @@ function moveToOrUse(t, target, state = "seek_food", reason = "") {
t.setActionState?.(state, { target, reason, sleeping: false });
return true;
}

View file

@ -494,7 +494,7 @@ function continueCurrentBehavior(tarinai, world, needs, dt) {
tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {};
const consumableAction = action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine";
if (consumableAction) {
// \u98DF\u6599\u30FB\u6C34\u30FB\u85AC\u306F\u74B0\u5883\u5909\u5316\u304C\u901F\u3044\u306E\u3067\u3001\u5931\u6557\u6642\u3082\u77ED\u3044\u518D\u8A55\u4FA1\u3060\u3051\u306B\u3059\u308B\u3002
// 食料・水・薬は環境変化が速いので、失敗時も短い再評価だけにする。
tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 0.35);
tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 0.45);
tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.18);
@ -514,7 +514,7 @@ function continueCurrentBehavior(tarinai, world, needs, dt) {
source: behavior?.source || "need",
forced: behaviorIsForced(behavior),
priority: behavior?.priority || 0,
causeText: (behaviorIsForced(behavior) || !/\u306E\u52B9\u679C$/.test(String(behavior?.causeText || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""))) ? (behavior?.causeText || "") : "",
causeText: (behaviorIsForced(behavior) || !/の効果$/.test(String(behavior?.causeText || "").trim().replace(/[。!?]+$/, ""))) ? (behavior?.causeText || "") : "",
sourceReasonText: behavior?.sourceReasonText || "",
forcedReasonText: behavior?.sourceReasonText || "",
forcedRequest: behavior?.forcedRequest || null,
@ -554,17 +554,12 @@ function shouldWakeFromSleep(tarinai, world, needs) {
const session = tarinai.sleepSession || { startedAt: now, minDuration: 8, targetEnergy: 74, maxDuration: 28 };
const started = Number(session.startedAt);
const elapsed = Math.max(0, now - (Number.isFinite(started) ? started : now));
const minSleepDuration = Math.max(2, Number(session.minDuration) || 8);
// 明確な妨害だけは最低睡眠時間より優先して起床させる。
if ((tarinai.hurtTimer || 0) > 0.06 || (tarinai.pokeFlashTimer || 0) > 0.08) return true;
if (findNearbyDanger(world, tarinai, 160)) return true;
// 空腹・睡眠欲の回復・体力回復などの通常要因では、最低2時間は寝続ける。
if (elapsed < minSleepDuration) return false;
const foodNeed = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
if ((tarinai.hunger || 0) >= 84 || foodNeed >= needThreshold("food", "start")) return true;
// \u81ea\u7136\u306a\u7761\u7720\u306f\u77ed\u3044\u5b89\u5168\u30ed\u30c3\u30af\u5f8c\u3001\u5341\u5206\u306b\u56de\u5fa9\u3057\u305f\u3089\u8d77\u304d\u308b\u3002
if (elapsed < (Number(session.minDuration) || 8)) return false;
const sleepNeed = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0;
if (sleepNeed <= ((typeof needThreshold === "function" ? needThreshold("sleep", "continue") : 34) + 4)) return true;
if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 74)) return true;
@ -572,7 +567,6 @@ function shouldWakeFromSleep(tarinai, world, needs) {
return false;
}
function protectSleepSession(tarinai, world, needs) {
if (!(tarinai?.state === "sleep" || tarinai?.sleeping)) return false;
tarinai.sleeping = true;
@ -1051,6 +1045,19 @@ function resolveNeedsCore(dt) {
return true;
},
updateSunbath(dt) {
return globalThis.TarinaiSunbathSystem.updateOne(this, dt) || false;
},
applyCursorContactCare(dt) {
return globalThis.TarinaiCursorCareSystem.updateOne(this, dt) || false;
},
applyEnvironment(dt) {
return globalThis.TarinaiLocalEnvironmentSystem.updateOne(this, dt) || false;
},
bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)", options = {}) {
if (!options.force && this.world.time < this.nextBubbleAt) return false;
this.nextBubbleAt = this.world.time + cooldown;
@ -1067,11 +1074,60 @@ function resolveNeedsCore(dt) {
},
isSleepFurniture(target) {
return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box" || target.type === "pipe"))));
return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box"))));
},
isServingFoodItem(it) {
if (it?.type === "duplicator" && it.storedFoodType) return true;
return Boolean(it && isServingFoodType(it.type));
},
foodItemHasServingLeft(it) {
if (!it || it.dead) return false;
if (it.type === "duplicator") return Boolean(it.storedFoodType);
if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2;
this.ensureFoodServings(it);
return (it.foodServingsRemaining || 0) > 0;
},
ensureFoodServings(it) {
if (it?.type === "duplicator") return;
if (!this.isServingFoodItem(it)) return;
const max = foodServingsForSize(it.toolSize || "medium");
if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max;
if (!Number.isFinite(it.foodServingsRemaining)) {
const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, laxative: 58, protein: 58, niteropu: 58, ammo: 48, mystery_drug: 52, mercury: 44, giant_drug: 52, dwarf_drug: 52, zunda_juice: 70 }[it.type] || 62;
const ratio = clamp((it.amount ?? original) / original, 0.05, 1);
it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio));
}
it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax);
it.amount = it.foodServingsRemaining;
},
consumeFoodServing(it, nutrition) {
if (it?.type === "duplicator") {
if (!it.storedFoodType) return 0;
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
it.usedCount = (it.usedCount || 0) + 1;
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.storedFoodType, sourceType: "duplicator", nutrition: value });
return value;
}
this.ensureFoodServings(it);
if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0;
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.type, 0) ?? 0);
it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1);
it.amount = it.foodServingsRemaining;
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value });
return value;
},
foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) {
const relief = window.TarinaiItemRegistry.food?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier;
return (Number(nutrition) || 0) * relief;
},
sleepSpotFor(bed) {
if (!bed) return { x: this.x, y: this.y };
if (bed.type === "nest_box" || bed.type === "pipe") {
if (bed.type === "nest_box") {
if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this);
if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed);
return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) };
@ -1126,7 +1182,7 @@ function resolveNeedsCore(dt) {
},
resolveNeeds(dt) {
return global.TarinaiNeedPlannerSystem.updateOne(this, dt);
return global.TarinaiNeedPlannerSystem.updateOne(this, dt) ?? global.TarinaiNeedPlannerCoreSystem.updateOne(this, dt);
},
makePoop(force = 1) {
@ -1148,6 +1204,7 @@ function resolveNeedsCore(dt) {
const backY = this.y + this.radius * rand(0.25, 0.48);
this.world.spawnZunchi(backX, backY, this);
const now = this.world?.time || 0;
this.nextPoopBubbleAt = now + 2.8;
this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)", { force: true });
if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] });
return true;

View file

@ -1,34 +1,27 @@
"use strict";
// Layer: tarinai/nest-sleep
// Centralizes sleep furniture, nest/pipe occupancy/reservation, and sleep-target
// selection policy so entrance logic and bed targeting do not drift.
// 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 distFn = typeof dist === "function" ? dist : ((a, b) => Math.hypot((a?.x || 0) - (b?.x || 0), (a?.y || 0) - (b?.y || 0)));
const clampFn = typeof clamp === "function" ? clamp : ((v, a, b) => Math.max(a, Math.min(b, v)));
function isNestContainer(item) {
return Boolean(item && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
}
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 === "pipe" || item.type === "grass_bed"))));
return Boolean(item && (item.roles?.sleepPlace || (typeof global.isSleepFurnitureType === "function" ? global.isSleepFurnitureType(item.type) : (item.type === "bed" || item.type === "nest_box" || item.type === "grass_bed"))));
}
function sleepTargetBlocked(worldRef, tarinai, item) {
if (!worldRef || !tarinai || !item) return false;
// Pipe mouths are floor-level entry points. Treat them as reachable even if
// their circular sprite overlaps the tarinai body.
if (item.type === "pipe") return false;
return Boolean(worldRef.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65)));
}
function nestBoxCapacity(worldRef, box = null) {
return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : (box?.type === "pipe" ? 3 : 5);
return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : 5;
}
function nestBoxOccupants(worldRef, box, limit = 5) {
if (!worldRef || !box || box.dead || !isNestContainer(box)) return [];
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;
}
@ -51,7 +44,7 @@
}
function nestBoxReservationCount(worldRef, box) {
if (!worldRef || !box || box.dead || !isNestContainer(box)) return 0;
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;
@ -60,7 +53,7 @@
function bedOccupancy(worldRef, bed) {
if (!worldRef || !isSleepFurniture(bed)) return 0;
if (isNestContainer(bed)) {
if (bed.type === "nest_box") {
return nestBoxOccupants(worldRef, bed, Infinity).length + nestBoxReservationCount(worldRef, bed);
}
let n = 0;
@ -68,7 +61,7 @@
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.target === bed && (t.state === "sleep" || t.state === "seek_bed" || t.sleeping)) n += 1;
if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1;
}
return n;
}
@ -76,8 +69,7 @@
function bedComfort(worldRef, bed) {
if (!isSleepFurniture(bed)) return 0.7;
const occ = bedOccupancy(worldRef, bed);
const base = bed.type === "pipe" ? Math.min(Number(bed.comfort ?? 0.74) || 0.74, 0.82) : (bed.comfort ?? 1);
return clampFn(base - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, bed.type === "pipe" ? 0.46 : 0.42, bed.type === "pipe" ? 0.88 : 1.18);
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) {
@ -87,40 +79,37 @@
if (tarinai.shouldAvoidTarget?.(item)) return false;
if (worldRef?.sleepTargetTemperatureBlocked?.(tarinai, item)) return false;
if (sleepTargetBlocked(worldRef, tarinai, item)) return false;
const capacity = isNestContainer(item) ? nestBoxCapacity(worldRef, item) : 1;
const capacity = item.type === "nest_box" ? nestBoxCapacity(worldRef, item) : 1;
const occupancy = bedOccupancy(worldRef, item);
return occupancy < capacity || tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id);
return occupancy < capacity || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id);
}
function findStructure(worldRef, tarinai, opts = {}) {
const type = String(opts.type || "");
const maxDist = Number.isFinite(Number(opts.maxDist)) ? Number(opts.maxDist) : Infinity;
const owned = opts.owned === true;
const unowned = opts.unowned === true;
const blocked = typeof opts.blocked === "function" ? opts.blocked : sleepTargetBlocked;
function findOwnedStructure(worldRef, tarinai, type = "", maxDist = Infinity) {
let best = null, bestD = maxDist;
for (const item of worldRef?.items || []) {
if (!item?.isStructure || item.dead) continue;
if (owned && item.ownerId !== tarinai?.id) continue;
if (unowned && item.ownerId) continue;
if (!owned && !unowned && opts.ownerId !== undefined && item.ownerId !== opts.ownerId) continue;
if (!item?.isStructure || item.dead || item.ownerId !== tarinai?.id) continue;
if (Number(item.unreachableUntil || 0) > Number(worldRef?.time || 0)) continue;
if (unowned && item.abandonedById && item.abandonedById === tarinai?.id && Number(item.abandonedUntil || 0) > Number(worldRef?.time || 0)) continue;
if (type && item.type !== type) continue;
if (unowned && tarinai?.shouldAvoidTarget?.(item)) continue;
if (blocked(worldRef, tarinai, item)) continue;
if (sleepTargetBlocked(worldRef, tarinai, item)) continue;
const d = distFn(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function findOwnedStructure(worldRef, tarinai, type = "", maxDist = Infinity, opts = {}) {
return findStructure(worldRef, tarinai, { ...opts, owned: true, type, maxDist });
}
function findUnownedStructure(worldRef, tarinai, type = "", maxDist = Infinity, opts = {}) {
return findStructure(worldRef, tarinai, { ...opts, unowned: true, type, maxDist });
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 (item.abandonedById && item.abandonedById === tarinai?.id && Number(item.abandonedUntil || 0) > Number(worldRef?.time || 0)) continue;
if (Number(item.unreachableUntil || 0) > Number(worldRef?.time || 0)) continue;
if (type && item.type !== type) continue;
if (tarinai?.shouldAvoidTarget?.(item)) continue;
if (sleepTargetBlocked(worldRef, tarinai, item)) continue;
const d = distFn(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function nearestSleepPlaceOfType(worldRef, tarinai, type, maxDist = 520) {
@ -143,27 +132,22 @@
return findOwnedStructure(worldRef, tarinai, "grass_bed", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "nest_box", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "bed", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "pipe", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "grass_bed", maxDist)
|| null;
}
function findNearestSleepPlace(worldRef, tarinai, maxDist = 520) {
return findPrioritySleepPlaceBeforeGrassBedBuild(worldRef, tarinai, maxDist)
|| findUnownedStructure(worldRef, tarinai, "grass_bed", maxDist)
|| null;
}
global.TarinaiNestSleepSystem = Object.freeze({
isNestContainer,
isSleepFurniture,
isTarinaiHiddenInNestBox,
nestBoxOccupants,
bedOccupancy,
bedComfort,
sleepPlaceAvailableFor,
findStructure,
findOwnedStructure,
findUnownedStructure,
nearestSleepPlaceOfType,
findPrioritySleepPlaceBeforeGrassBedBuild,
findNearestSleepPlace,

View file

@ -18,63 +18,7 @@
return true;
}
function tarinaiShadowsEnabled() {
return (global.TarinaiPerf?.shadowQualityTier?.() || "high") !== "off";
}
function drawTarinaiChampionCrown(ctx, tarinai, drawH, nestHideAlpha = 1, showDecor = true) {
if (!ctx || !tarinai?.isTarinaiChampion || tarinai.dead) return false;
const alpha = clamp(Number(nestHideAlpha ?? 1) || 0, 0, 1);
if (alpha <= 0.01) return false;
ctx.save();
ctx.globalAlpha = alpha;
const crownW = Math.max(24, tarinai.radius * 1.34);
const crownH = Math.max(14, tarinai.radius * 0.58);
const cx = tarinai.x;
const cy = tarinai.y - drawH * 0.64 - crownH * 0.55;
if (showDecor) {
ctx.shadowColor = "rgba(244, 184, 42, 0.78)";
ctx.shadowBlur = 12;
}
ctx.fillStyle = "rgba(248, 201, 55, 0.96)";
ctx.strokeStyle = "rgba(116, 78, 18, 0.92)";
ctx.lineWidth = Math.max(1.4, crownH * 0.10);
ctx.beginPath();
ctx.moveTo(cx - crownW * 0.48, cy + crownH * 0.26);
ctx.lineTo(cx - crownW * 0.38, cy - crownH * 0.22);
ctx.lineTo(cx - crownW * 0.17, cy + crownH * 0.05);
ctx.lineTo(cx, cy - crownH * 0.42);
ctx.lineTo(cx + crownW * 0.17, cy + crownH * 0.05);
ctx.lineTo(cx + crownW * 0.38, cy - crownH * 0.22);
ctx.lineTo(cx + crownW * 0.48, cy + crownH * 0.26);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(255, 243, 154, 0.92)";
ctx.fillRect(cx - crownW * 0.38, cy + crownH * 0.16, crownW * 0.76, crownH * 0.16);
ctx.fillStyle = "rgba(151, 88, 38, 0.82)";
for (const px of [-0.28, 0, 0.28]) {
ctx.beginPath();
ctx.arc(cx + crownW * px, cy + crownH * 0.02, Math.max(1.4, crownH * 0.075), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
return true;
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
drawChampionCrown(ctx, t = 0, lighting = null) {
if (!this.isTarinaiChampion || this.dead) return false;
const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1;
if (nestHideAlpha <= 0.01) return false;
const sprite = this.visibleSpriteId || this.goodMode || this.type || "smile";
const metrics = getImageMetrics(sprite) || getImageMetrics("smile") || {};
const visualScale = this.effectiveScale ? this.effectiveScale() : this.scale;
const drawH = (metrics?.h || 512) * visualScale * 0.30;
return drawTarinaiChampionCrown(ctx, this, drawH, nestHideAlpha, shouldShowTarinaiDecor(this));
},
drawSunbathPose(ctx, t, lighting = null) {
if (this.dead) return true;
const active = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04;
@ -523,40 +467,56 @@
const zImg = getRenderableImage("zunchi", "zunchi");
if (zImg) {
ctx.save();
if (tarinaiShadowsEnabled()) {
ctx.shadowColor = "rgba(46, 36, 24, 0.28)";
ctx.shadowBlur = 5;
ctx.shadowOffsetY = Math.max(1, drawH * 0.012);
}
ctx.shadowColor = "rgba(46, 36, 24, 0.28)";
ctx.shadowBlur = 5;
ctx.shadowOffsetY = Math.max(1, drawH * 0.012);
const zw = Math.max(16, drawW * 0.42);
const zh = zw * 0.74;
ctx.drawImage(zImg, -zw * 0.78, -dh * 0.50 - zh * 0.28, zw, zh);
ctx.restore();
}
}
if (this.burning || (this.burnTimer || 0) > 0.02) {
ctx.save();
const phase = t * 8.0 + this.age * 0.37 + this.seed;
const fireAlpha = 0.82 * nestHideAlpha;
const drawFire = typeof drawSimpleFireShape === "function" ? drawSimpleFireShape : null;
if (drawFire) {
drawFire(ctx, -dw * 0.20, -dh * 0.18, Math.max(10, dw * 0.17), { phase, alpha: fireAlpha });
drawFire(ctx, dw * 0.16, -dh * 0.04, Math.max(8, dw * 0.13), { phase: phase + 1.7, alpha: fireAlpha * 0.86, shadow: false });
drawFire(ctx, 0, -dh * 0.34, Math.max(9, dw * 0.14), { phase: phase + 3.1, alpha: fireAlpha * 0.76, shadow: false });
} else {
ctx.fillStyle = "rgba(236,46,24,0.78)";
ctx.beginPath();
ctx.ellipse(0, -dh * 0.20, Math.max(8, dw * 0.15), Math.max(12, dh * 0.22), 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (faceRight) ctx.scale(-1, 1);
ctx.restore();
// \u305F\u308A\u306A\u3044\u738B\u306E\u738B\u51A0\u306F\u3001\u643A\u884C\u306C\u3044\u3050\u308B\u307F\u3088\u308A\u524D\u9762\u306B\u51FA\u3059\u305F\u3081 render.js \u306E\u5F8C\u6BB5\u3067\u63CF\u753B\u3059\u308B\u3002
if (this.isTarinaiChampion) {
ctx.save();
ctx.globalAlpha = nestHideAlpha;
const crownW = Math.max(24, this.radius * 1.34);
const crownH = Math.max(14, this.radius * 0.58);
const cx = this.x;
const cy = this.y - drawH * 0.64 - crownH * 0.55;
if (showDecor) {
ctx.shadowColor = "rgba(244, 184, 42, 0.78)";
ctx.shadowBlur = 12;
}
ctx.fillStyle = "rgba(248, 201, 55, 0.96)";
ctx.strokeStyle = "rgba(116, 78, 18, 0.92)";
ctx.lineWidth = Math.max(1.4, crownH * 0.10);
ctx.beginPath();
ctx.moveTo(cx - crownW * 0.48, cy + crownH * 0.26);
ctx.lineTo(cx - crownW * 0.38, cy - crownH * 0.22);
ctx.lineTo(cx - crownW * 0.17, cy + crownH * 0.05);
ctx.lineTo(cx, cy - crownH * 0.42);
ctx.lineTo(cx + crownW * 0.17, cy + crownH * 0.05);
ctx.lineTo(cx + crownW * 0.38, cy - crownH * 0.22);
ctx.lineTo(cx + crownW * 0.48, cy + crownH * 0.26);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(255, 243, 154, 0.92)";
ctx.fillRect(cx - crownW * 0.38, cy + crownH * 0.16, crownW * 0.76, crownH * 0.16);
ctx.fillStyle = "rgba(151, 88, 38, 0.82)";
for (const px of [-0.28, 0, 0.28]) {
ctx.beginPath();
ctx.arc(cx + crownW * px, cy + crownH * 0.02, Math.max(1.4, crownH * 0.075), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (this.favorite) {
ctx.save();
@ -581,13 +541,11 @@
const hpActive = (this.hpBarTimer || 0) > 0;
const stressActive = (this.stressBarTimer || 0) > 0;
if (hpActive || stressActive) {
if ((hpActive || stressActive) && (showDecor || this.world.selected === this || this.hurtTimer > 0.04)) {
const bw = Math.max(28, this.radius * 2.35);
const bh = 5;
const x = this.x - bw / 2;
const baseY = this.y - drawH * 0.55 - 16;
const stressY = hpActive ? baseY + 7 : baseY;
const hpY = stressActive ? baseY - 7 : baseY;
const drawMeter = (y, pct, fill, alpha) => {
ctx.save();
ctx.globalAlpha = alpha;
@ -609,11 +567,11 @@
};
if (stressActive) {
const pct = clamp((this.stress || 0) / 130, 0, 1);
drawMeter(stressY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1));
drawMeter(baseY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1));
}
if (hpActive) {
const pct = typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp(this.energy / Math.max(1, this.maxEnergy || 100), 0, 1);
drawMeter(hpY, pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1));
drawMeter(baseY - (stressActive ? 11 : 0), pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1));
}
}
},

View file

@ -198,37 +198,6 @@ function updatePanicBehavior(t, world, dt, needs) {
const now = Number(world?.time || 0) || 0;
if (!Number.isFinite(t.panicStartedAt) || t.state !== "panic") t.panicStartedAt = now;
if (t.lastPanicCause === "tarinai_champion") {
const kingFleeReason = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
const kingHardStop = Number(t.panicHardStopAt || 0) || 0;
if (kingHardStop > 0 && now >= kingHardStop) {
t.lastPanicCause = "";
t.fearTimer = 0;
t.lastNeedShockBreaker = null;
return "finished";
}
const nest = t.target?.type === "nest_box" ? t.target : null;
if (nest && !nest.dead) {
if (typeof setBehaviorText === "function") {
setBehaviorText(t, {
need: "safety",
actionId: "panic_escape",
actionLabel: "\u9003\u3052\u3066\u3044\u308B",
reasonText: kingFleeReason,
target: nest,
phase: "perform",
source: "behavior",
});
}
if (t.state !== "panic") t.setActionState?.("panic", { target: nest, reason: kingFleeReason, wake: true, sleeping: false });
t.fearTimer = Math.max(t.fearTimer || 0, 0.34);
if (dist(t, nest) <= Math.max(50, (t.radius || 20) + (nest.r || 24) + 18)) {
t.enterNestBox?.(nest, dt, { forceDisplace: true, reason: kingFleeReason });
}
return true;
}
}
// Survival needs must be able to break panic. Otherwise a stale fear state
// can suppress eating/sleeping long enough to kill the colony.
if (!realDanger && survivalNeedShouldCancelPanic(t, needs)) {

View file

@ -20,88 +20,6 @@
return attacker?.outgoingDamage ? attacker.outgoingDamage(raw) : raw;
}
const TARINAI_KING_FLEE_REASON = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
function isNestContainerItem(item) {
return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
}
function sleepContainerText(item, verb = "\u4f11\u3093\u3067\u3044\u308b") {
return item?.type === "pipe" ? `\u571f\u7ba1\u306e\u4e2d\u3067${verb}` : `\u5de3\u7bb1\u306e\u4e2d\u3067${verb}`;
}
function tarinaiIsChampionBreedingPartner(champion, candidate) {
if (!champion || !candidate || champion === candidate) return false;
if (champion.birthPartnerId && champion.birthPartnerId === candidate.id) return true;
if (candidate.birthPartnerId && candidate.birthPartnerId === champion.id) return true;
const championBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(champion) : null;
const candidateBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(candidate) : null;
const breedingIds = new Set(["approach_mate", "birth_ritual"]);
if (breedingIds.has(championBehavior?.actionId) && champion.target === candidate) return true;
if (breedingIds.has(candidateBehavior?.actionId) && candidate.target === champion) return true;
if ((champion.birthRitualTimer || 0) > 0.04 && champion.target === candidate) return true;
if ((candidate.birthRitualTimer || 0) > 0.04 && candidate.target === champion) return true;
return false;
}
function nearestKingFleeNestBox(tarinai, maxDist = 460) {
const worldRef = tarinai?.world;
if (!tarinai || !worldRef) return null;
const source = typeof worldRef.nearbyItems === "function" ? worldRef.nearbyItems(tarinai.x, tarinai.y, maxDist) : (worldRef.items || []);
let best = null;
let bestScore = Infinity;
for (const item of source || []) {
if (!item || item.dead || item.type !== "nest_box") continue;
const d = distXY(tarinai.x, tarinai.y, item.x, item.y);
if (d > maxDist) continue;
const padding = Math.max(16, (tarinai.radius || 20) * 0.72);
if (typeof worldRef.targetBlockedByObstacle === "function" && worldRef.targetBlockedByObstacle(tarinai, item, padding)) continue;
const occupants = typeof worldRef.nestBoxOccupants === "function" ? worldRef.nestBoxOccupants(item, Infinity) : [];
const capacity = typeof worldRef.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(item) : 5;
const fullnessPenalty = Math.max(0, occupants.length - capacity + 1) * 18;
const score = d + fullnessPenalty;
if (score < bestScore) {
bestScore = score;
best = item;
}
}
return best;
}
function forceKingFlee(avoider, champion, dt = 0.016) {
if (!avoider || !champion || avoider.dead) return false;
const nest = nearestKingFleeNestBox(avoider);
const target = nest || avoider.panicDestination?.(champion, true) || {
x: avoider.x + (avoider.x - champion.x || 1) * 1.8,
y: avoider.y + (avoider.y - champion.y || 0) * 1.8,
};
const now = avoider.world?.time || 0;
avoider.lastNeedShockBreaker = champion;
avoider.lastPanicCause = "tarinai_champion";
avoider.lastPanicDetail = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u3044";
avoider.panicStartedAt = now;
avoider.panicHardStopAt = Math.max(avoider.panicHardStopAt || 0, now + 6.5);
avoider.target = target;
avoider.setActionState?.("panic", { target, reason: TARINAI_KING_FLEE_REASON, wake: true, sleeping: false });
if (typeof setBehaviorText === "function") {
setBehaviorText(avoider, {
need: "safety",
actionId: "panic_escape",
actionLabel: "\u9003\u3052\u3066\u3044\u308B",
reasonText: TARINAI_KING_FLEE_REASON,
target,
phase: "perform",
source: "behavior",
});
}
avoider.thought = TARINAI_KING_FLEE_REASON;
avoider.fearTimer = Math.max(avoider.fearTimer || 0, 1.05 * avoider.personalityProfile().fear);
if (nest && distXY(avoider.x, avoider.y, nest.x, nest.y) <= Math.max(50, (avoider.radius || 20) + (nest.r || 24) + 18)) {
avoider.enterNestBox?.(nest, dt, { forceDisplace: true, reason: TARINAI_KING_FLEE_REASON });
}
return true;
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
interactWithOthers(dt) {
let closeCount = 0;
@ -123,33 +41,29 @@
const champion = this.isTarinaiChampion ? this : (o.isTarinaiChampion ? o : null);
if (champion && this.isTarinaiChampion !== o.isTarinaiChampion) {
const avoider = champion === this ? o : this;
if (tarinaiIsChampionBreedingPartner(champion, avoider)) {
avoider.adjustRelation?.(champion, dt * 0.035, -dt * 0.06, "tarinai_champion_partner");
if (this !== champion) continue;
} else {
const nx = (avoider.x - champion.x) / d;
const ny = (avoider.y - champion.y) / d;
const avoidRange = Math.max(92, selfRadius + otherRadius + 34);
const urgency = clamp(1 - d / avoidRange, 0, 1);
const push = dt * (64 + urgency * 260);
avoider.vx += nx * push;
avoider.vy += ny * push;
if (d <= selfRadius + otherRadius + 14) {
avoider.vx += nx * dt * 190;
avoider.vy += ny * dt * 190;
champion.vx -= nx * dt * 8;
champion.vy -= ny * dt * 8;
avoider.surpriseTimer = Math.max(avoider.surpriseTimer || 0, 0.20);
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}\u306F\u305F\u308A\u306A\u3044\u738B\u8005\u306E${champion.name}\u306B\u3076\u3064\u304B\u308A\u305D\u3046\u306B\u306A\u3063\u3066\u907F\u3051\u305F\u3002`, "relation", { participants: [avoider, champion], eventType: "avoid_relationship", sound: false });
}
const nx = (avoider.x - champion.x) / d;
const ny = (avoider.y - champion.y) / d;
const avoidRange = Math.max(92, selfRadius + otherRadius + 34);
const urgency = clamp(1 - d / avoidRange, 0, 1);
const push = dt * (64 + urgency * 260);
avoider.vx += nx * push;
avoider.vy += ny * push;
if (d <= selfRadius + otherRadius + 14) {
avoider.vx += nx * dt * 190;
avoider.vy += ny * dt * 190;
champion.vx -= nx * dt * 8;
champion.vy -= ny * dt * 8;
avoider.surpriseTimer = Math.max(avoider.surpriseTimer || 0, 0.20);
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", sound: false });
}
forceKingFlee(avoider, champion, dt);
avoider.adjustRelation?.(champion, -dt * 0.025, dt * 0.18, "tarinai_champion_avoid");
if (this !== champion) continue;
}
avoider.lastNeedShockBreaker = champion;
avoider.thought = `${champion.name || "相手"}はたりない王者だ`;
avoider.adjustRelation?.(champion, -dt * 0.025, dt * 0.18, "tarinai_champion_avoid");
if (this !== champion) continue;
}
const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
@ -296,7 +210,7 @@
},
move(dt) {
if (!(this.burning || (this.burnTimer || 0) > 0.02) && this.freezeSleepDiseaseMotion(dt)) return;
if (this.freezeSleepDiseaseMotion(dt)) return;
let ax = 0, ay = 0;
const itemSpeedMul = this.itemSpeedMultiplier ? this.itemSpeedMultiplier() : 1;
const baseSpeed = 46.5 * 1.5 * this.trait.speed * itemSpeedMul * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1);
@ -324,7 +238,7 @@
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) });
const sleepHungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1);
this.hunger = clamp(this.hunger + dt * 0.018 * sleepHungerScale, 0, 115);
if (this.target && !this.target.dead && isNestContainerItem(this.target)) {
if (this.target && !this.target.dead && this.target.type === "nest_box") {
this.updateNestBoxPresence(dt);
this.vx = 0;
this.vy = 0;
@ -435,27 +349,6 @@
return;
}
const burningNow = Boolean(this.burning || (this.burnTimer || 0) > 0.02);
if (burningNow) {
const burnText = globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
this.state = "panic";
this.target = null;
this.lastPanicCause = "burning";
this.lastPanicDetail = burnText;
this.thought = burnText;
globalThis.TarinaiBurnMotionUtil?.updateBurnWanderAngle(this, dt, {
angleKey: "tarinai-burn-run-angle",
turnKey: "tarinai-burn-run-turn",
jitterKey: "tarinai-burn-run-jitter",
bucketScale: 5,
turnMin: 0.10,
turnMax: 0.34,
jitterMin: -8.0,
jitterMax: 8.0,
});
ax += Math.cos(this.burnWanderAngle || this.wanderAngle || 0) * baseSpeed * 3.15;
ay += Math.sin(this.burnWanderAngle || this.wanderAngle || 0) * baseSpeed * 3.15;
} else {
if (this.state === "panic") {
const targetDist = this.target && !this.target.dead ? distXY(this.x, this.y, this.target.x, this.target.y) : Infinity;
const speed = Math.hypot(this.vx, this.vy);
@ -512,28 +405,19 @@
const bedDx = this.target.x - this.x;
const bedDy = this.target.y - this.y;
const bedDist = Math.hypot(bedDx, bedDy);
const reachedSleepFurniture = isNestContainerItem(this.target)
const reachedSleepFurniture = this.target.type === "nest_box"
? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62))
: (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20));
if (reachedSleepFurniture) {
const sleepTarget = this.target;
const isEasyBed = sleepTarget?.type === "grass_bed";
sleepTarget?.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined);
const sleepText = isNestContainerItem(sleepTarget) ? sleepContainerText(sleepTarget, "\u4f11\u3093\u3067\u3044\u308b") : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b");
this.startSleeping?.(sleepTarget, sleepText);
if (isNestContainerItem(sleepTarget)) this.enterNestBox(sleepTarget, dt);
return;
}
}
if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && isNestContainerItem(this.target)) {
const nestDist = distXY(this.x, this.y, this.target.x, this.target.y);
if (nestDist <= Math.max(50, (this.radius || 20) + (this.target.r || 24) + 18)) {
this.enterNestBox?.(this.target, dt, { forceDisplace: true, reason: TARINAI_KING_FLEE_REASON });
this.startSleeping?.(sleepTarget, sleepTarget?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
if (sleepTarget?.type === "nest_box") this.enterNestBox(sleepTarget, dt);
return;
}
}
let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1;
if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && isNestContainerItem(this.target)) sign = 1;
if (this.target?.detour) sign = 1;
let strength = 1;
if (this.state === "follow_parent") {
@ -589,7 +473,6 @@
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34;
}
}
this.vx += ax * dt;
this.vy += ay * dt;
@ -598,7 +481,7 @@
this.vx *= Math.pow(moveFriction, dt * 8);
this.vy *= Math.pow(moveFriction, dt * 8);
const maxV = (burningNow ? 168 : (this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 76)))) * Math.max(1, itemSpeedMul || 1) * (this.geneticStatRatio ? this.geneticStatRatio("speed") : 1) * (iceGround ? 1.35 : 1);
const maxV = (this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 76))) * Math.max(1, itemSpeedMul || 1) * (this.geneticStatRatio ? this.geneticStatRatio("speed") : 1) * (iceGround ? 1.35 : 1);
const v = Math.hypot(this.vx, this.vy);
if (v > maxV) {
this.vx = this.vx / v * maxV;
@ -642,17 +525,18 @@
temperatureDeathReason() {
const status = this.temperatureStatus || this.world?.temperatureStatusFor?.(this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15), this) || null;
const recent = (this.world?.time || 0) - Number(this.lastTemperatureDamageAt || -999) <= 36;
if (recent && status && (status.harmExcess || 0) > 0) return status.direction === "cold" ? "\u51CD\u7D50" : "\u71B1\u4E2D\u75C7";
if (recent && status && (status.harmExcess || 0) > 0) return status.direction === "cold" ? "凍結" : "熱中症";
return "";
},
abandonUnreachableGrassBed(bed, reason = "\u8FBF\u308A\u3064\u3051\u306A\u3044\u306E\u3067\u653E\u68C4\u3057\u305F") {
abandonUnreachableGrassBed(bed, reason = "辿りつけないので放棄した") {
if (!bed || bed.dead || bed.type !== "grass_bed") return false;
bed.ownerId = "";
bed.unreachableUntil = 0;
bed.abandonedById = this.id || "";
bed.abandonedUntil = (this.world?.time || 0) + 90;
bed.abandonedReason = reason;
bed.carriedById = "";
this.target = null;
this.targetKey = "";
@ -694,6 +578,7 @@
if (this.world.recentDeathTimes.length > 24) this.world.recentDeathTimes = this.world.recentDeathTimes.slice(-24);
for (const item of this.world.items || []) {
if (!item || item.dead || !item.isStructure || item.type !== "plushie" || item.ownerId !== this.id) continue;
item.dead = true;
item.hp = 0;
item.amount = 0;
item.carriedById = "";

View file

@ -12,18 +12,26 @@
"TarinaiHealthUpdateStep",
]);
const runner = global.TarinaiUpdateStepPipelineRunner.create(
CONCRETE_STEP_NAMES,
(step, tarinai, dt, context) => step.update(tarinai, dt, context)
);
let cachedSteps = null;
function concreteSteps() {
if (!cachedSteps) cachedSteps = CONCRETE_STEP_NAMES.map(name => global[name]);
return cachedSteps;
}
function updateOne(tarinai, dt, options = {}) {
if (!tarinai || tarinai.dead) return false;
const steps = concreteSteps();
if (steps.length !== CONCRETE_STEP_NAMES.length) return false;
const context = {
...(options.context || {}),
wasSleepingAtFrameStart: false,
};
return runner.run(tarinai, dt, context);
for (const step of steps) {
const result = step.update(tarinai, dt, context) || {};
if (result.done) return result.ok !== false;
}
return true;
}
global.TarinaiUpdatePipeline = Object.freeze({ updateOne });

View file

@ -59,21 +59,22 @@
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) && !(typeof isNestContainerType === "function" ? isNestContainerType(tarinai.target.type) : (tarinai.target.type === "nest_box" || tarinai.target.type === "pipe"))) return true;
if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && tarinai.target.type !== "nest_box") return true;
return false;
}
function cadence(worldRef, tarinai, visibleRect, nearVisibleRect = null) {
function cadence(worldRef, tarinai, visibleRect) {
if (!tarinai || tarinai.dead) return { interval: Infinity, lane: "dead", smoothMotion: false, visible: false, urgent: false };
const isSelected = worldRef.selected === tarinai;
if (isSelected) return { interval: 0, lane: "selected", smoothMotion: false, visible: true, urgent: true };
const id = behaviorId(tarinai);
const state = String(tarinai.state || id || "");
const tier = global.TarinaiPerf.renderQualityTier() || "high";
const count = (worldRef.tarinai || []).length;
const mobile = Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
const isVisible = visible(worldRef, tarinai, visibleRect);
const farRect = isVisible ? visibleRect : (nearVisibleRect || helpers.updateVisibleWorldRect(worldRef, 520));
const farRect = isVisible ? visibleRect : helpers.updateVisibleWorldRect(worldRef, 520);
const isNear = isVisible || Boolean(helpers.pointInRect(tarinai.x || 0, tarinai.y || 0, farRect));
const motionNeed = hasMotionNeed(tarinai);
@ -94,7 +95,7 @@
// runs on visible skipped frames.
const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic/i.test(id);
if (panicUrgent) {
const interval = isVisible ? (mobile ? 0.13 : 0.08) : (isNear ? (mobile ? 0.24 : 0.18) : (mobile ? 0.46 : 0.36));
const interval = isVisible ? (mobile ? 0.13 : (tier === "low" ? 0.12 : 0.08)) : (isNear ? (mobile ? 0.24 : 0.18) : (mobile ? 0.46 : 0.36));
return { interval, lane: "panic", smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)), visible: isVisible, urgent: true };
}
@ -103,10 +104,10 @@
if (/birth_ritual/i.test(id) || state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) {
lane = "birth";
interval = isVisible ? 0.12 : 0.24;
interval = isVisible ? (tier === "low" ? 0.16 : 0.12) : 0.24;
} else if (/approach_parent_or_child|follow_parent|approach_mate|seek_bed|play_ball|seek_material|build/i.test(id) || /follow_parent|seek_bed|play_ball/.test(state)) {
lane = "moving";
interval = isVisible ? 0.10 : (isNear ? 0.22 : 0.55);
interval = isVisible ? (tier === "low" ? 0.14 : 0.10) : (isNear ? 0.22 : 0.55);
} else if (/eat_food|eat/i.test(id) || state === "eat" || (tarinai.eatTimer || 0) > 0.04) {
lane = "eat";
interval = isVisible ? 0.22 : 0.48;
@ -115,20 +116,16 @@
interval = isVisible ? 0.42 : (isNear ? 0.70 : 1.10);
} else if (/idle|none/.test(id) || state === "idle") {
lane = "idle";
if (isVisible) interval = count > 28 ? 0.16 : 0.12;
if (isVisible) interval = tier === "low" ? 0.24 : (tier === "medium" || tier === "mid" ? 0.20 : (count > 28 ? 0.16 : 0.12));
else interval = isNear ? 0.55 : 1.10;
} else if (isVisible) {
lane = "visible";
interval = count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05);
interval = tier === "low" ? (count > 24 ? 0.18 : 0.14) : (tier === "medium" || tier === "mid" ? (count > 28 ? 0.14 : 0.10) : (count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05)));
} else {
lane = isNear ? "near" : "far";
interval = isNear ? 0.30 : 0.90;
interval = isNear ? (tier === "low" ? 0.40 : 0.30) : (tier === "low" ? 1.20 : 0.90);
}
if (Number.isFinite(interval) && !/birth|eat|sleep/.test(lane)) {
if (count > 54) interval *= 1.26;
else if (count > 38) interval *= 1.14;
}
if (mobile && Number.isFinite(interval)) interval *= count > 24 ? 1.35 : 1.18;
const passivePhysicsNeed = hasPassivePhysicsNeed(tarinai, worldRef, isVisible);
const sleepPhysical = lane === "sleep" && passivePhysicsNeed;

View file

@ -36,14 +36,12 @@
t.fearTimer = Math.max(0, t.fearTimer - dt);
t.intimidateTimer = Math.max(0, t.intimidateTimer - dt);
t.intimidatedTimer = Math.max(0, t.intimidatedTimer - dt);
t.intimidationCooldown = Math.max(0, (t.intimidationCooldown || 0) - dt);
t.blastSpinTimer = Math.max(0, (t.blastSpinTimer || 0) - dt);
t.postBirthPeaceTimer = Math.max(0, (t.postBirthPeaceTimer || 0) - dt);
t.hpBarTimer = Math.max(0, (t.hpBarTimer || 0) - dt);
t.stressBarTimer = Math.max(0, (t.stressBarTimer || 0) - dt);
t.updateZunchiDisease(dt);
t.updateSpecialDiseases(dt);
if (t.updateBurning) t.updateBurning(dt);
if (t.updateItemEffects) t.updateItemEffects(dt);
if (t.dead) return { done: true };
t.loveMochiTimer = Math.max(0, (t.loveMochiTimer || 0) - dt);
@ -102,7 +100,7 @@
t.fightTargetIds = [];
t.fightTargetId = null;
t.counterAttackFromId = null;
if (t.state === "fight" || t.state === "seek_enemy") t.setActionState?.("idle", { target: null, reason: "\u55A7\u5629\u304C\u7D42\u308F\u3063\u3066\u5C11\u3057\u8DDD\u96E2\u3092\u7F6E\u3044\u3066\u3044\u308B\u3002", sleeping: false, clearTarget: true });
if (t.state === "fight" || t.state === "seek_enemy") t.setActionState?.("idle", { target: null, reason: "喧嘩が終わって少し距離を置いている。", sleeping: false, clearTarget: true });
}
}
t.grassEatTimer = Math.max(0, t.grassEatTimer - dt);
@ -162,13 +160,13 @@
const stressRate = excess * (0.66 + Math.min(20, excess) * 0.035);
applyNeedShock(t, { safety: stressRate * dt, health: stressRate * dt * 0.42 });
if ((t.world?.time || 0) >= (t.nextBubbleAt || 0) && detChance(t.world, "temperature-stress-bubble", dt * 0.18, t)) {
t.bubble(tempStatus.direction === "cold" ? "\u3076\u308B\u3076\u308B" : "\u3042\u3061\u3085\u3044", 2.4, "rgba(76,62,72,0.78)");
t.bubble(tempStatus.direction === "cold" ? "ぶるぶる" : "あちゅい", 2.4, "rgba(76,62,72,0.78)");
}
}
if ((tempStatus.harmExcess || 0) > 0) {
const harm = tempStatus.harmExcess || 0;
t.energy -= dt * (0.10 + harm * 0.075 + harm * harm * 0.006);
t.lastDamageCause = tempStatus.direction === "cold" ? "\u51CD\u7D50" : "\u71B1\u4E2D\u75C7";
t.lastDamageCause = tempStatus.direction === "cold" ? "凍結" : "熱中症";
t.lastTemperatureDamageAt = t.world?.time || 0;
}

View file

@ -56,6 +56,7 @@
if (speed > 0.55 || recentlyHit) {
const hold = speed > 18 ? 1.15 : (speed > 3 ? 0.75 : 0.42);
t._sleepExternalMotionUntil = Math.max(Number(t._sleepExternalMotionUntil || 0) || 0, now + hold);
t._sleepExternalMotionReason = reason;
return true;
}
return now < Number(t._sleepExternalMotionUntil || 0);
@ -74,6 +75,7 @@
if (now < Number(t._sleepExternalWakeSuppressUntil || -999)) return false;
t._sleepExternalWakeSuppressUntil = now + 0.45;
t._sleepExternalMotionUntil = 0;
t._sleepExternalMotionReason = "";
t.consumePendingGrassBedOnWake?.("\u5916\u529b\u3067\u8d77\u304d\u305f");
t.sleeping = false;
t.sleepSession = null;
@ -120,7 +122,7 @@
const state = String(t.state || "");
const sleepExternal = markSleepExternalMotion(t, dt, "passive-physical");
if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && !(typeof isNestContainerType === "function" ? isNestContainerType(t.target.type) : (t.target.type === "nest_box" || t.target.type === "pipe"))) {
if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && t.target.type !== "nest_box") {
const comfort = world?.bedComfort ? world.bedComfort(t.target) : 0.72;
const spot = t.sleepSpotFor ? t.sleepSpotFor(t.target) : t.target;
const dx = numeric(spot?.x, beforeX) - beforeX;
@ -246,16 +248,20 @@
applyVelocityShockDamage(t, before, motionDt);
t.updateNestBoxPresence(motionDt);
// t.move() calls world.moveTarinaiWithCollision(), which already performs
// swept solid-obstacle resolution. Avoid a second full collision pass and
// keep only a staggered low-frequency relax pass for near-stationary bodies
// so old overlaps still self-heal.
// t.move() normally calls world.moveTarinaiWithCollision(), which already
// performs swept solid-obstacle resolution. Running resolveFenceCollision()
// again for every creature made stationary/sleeping crowds pay the full
// obstacle query cost twice per update. Keep the legacy fallback for worlds
// without swept movement, and run a staggered low-frequency relax pass for
// near-stationary bodies so old overlaps still self-heal.
const world = t.world;
if (world?.resolveFenceCollision) {
const dx = (Number.isFinite(t.x) ? t.x : 0) - beforeX;
const dy = (Number.isFinite(t.y) ? t.y : 0) - beforeY;
const movedSq = dx * dx + dy * dy;
if (movedSq <= 0.25 && !world.isTarinaiHiddenInNestBox?.(t)) {
if (!world.moveTarinaiWithCollision && movedSq > 0.25) {
world.resolveFenceCollision(t);
} else if (movedSq <= 0.25 && !world.isTarinaiHiddenInNestBox?.(t)) {
const now = world.time || 0;
if (now >= (t._nextSolidRelaxAt || 0)) {
const jitter = stableUnit(t.id || t.familyKey || 0, "solid-relax") * 0.45;

View file

@ -4,39 +4,59 @@ const TEXT_CATALOG = {
toolTips: toolTipsFromDefinitions(),
ecologyCards: [
{
title: "\u305F\u308A\u306A\u3044\u3068\u306F",
title: "\u305f\u308a\u306a\u3044\u3068\u306f",
image: "assets/sprites/tarinai_01_smile.webp",
text: "\u5EAD\u3084\u30B1\u30FC\u30B8\u3067\u751F\u6D3B\u3059\u308B\u89B3\u5BDF\u5BFE\u8C61\u3002\u7A7A\u8179\u3001\u7761\u7720\u3001\u5065\u5EB7\u3001\u5B89\u5168\u3001\u95A2\u4FC2\u3001\u5145\u8DB3\u306E\u6B32\u6C42\u3068\u30B9\u30C8\u30EC\u30B9\u3067\u884C\u52D5\u304C\u5909\u308F\u308A\u307E\u3059\u3002\u6027\u683C\u3001\u4F53\u683C\u3001\u5BB6\u65CF\u3001\u7D4C\u9A13\u3001\u75C5\u6C17\u3001\u6226\u7E3E\u306F\u500B\u4F53\u3054\u3068\u306B\u6B8B\u308A\u307E\u3059\u3002",
text: "\u5ead\u3084\u30b1\u30fc\u30b8\u3067\u751f\u6d3b\u3059\u308b\u89b3\u5bdf\u5bfe\u8c61\u3002\u7a7a\u8179\u3001\u7761\u7720\u3001\u5065\u5eb7\u3001\u5b89\u5168\u3001\u95a2\u4fc2\u3001\u5145\u8db3\u306e6\u6b32\u6c42\u3068\u30b9\u30c8\u30ec\u30b9\u3067\u884c\u52d5\u304c\u5909\u308f\u308a\u307e\u3059\u3002\u500b\u4f53\u3054\u3068\u306b\u6027\u683c\u3001\u4f53\u683c\u3001\u5bb6\u65cf\u3001\u7d4c\u9a13\u304c\u6b8b\u308a\u307e\u3059\u3002",
},
{
title: "\u6B32\u6C42\u3068\u884C\u52D5",
title: "\u6b32\u6c42\u3068\u30b9\u30c8\u30ec\u30b9",
image: "assets/ui/ecology_hp_stress.webp",
text: "\u8DB3\u308A\u306A\u3044\u6B32\u6C42\u304C\u5F37\u3044\u307B\u3069\u3001\u98DF\u4E8B\u3001\u7761\u7720\u3001\u4EF2\u9593\u63A2\u3057\u3001\u904A\u3073\u3001\u907F\u6691\u30FB\u907F\u5BD2\u3001\u9003\u8D70\u306A\u3069\u3092\u512A\u5148\u3057\u307E\u3059\u3002\u6050\u6016\u3001\u6DF7\u96D1\u3001\u6C5A\u308C\u3001\u3051\u3093\u304B\u3001\u5730\u9762\u3084\u6C17\u6E29\u306F\u30B9\u30C8\u30EC\u30B9\u3068\u884C\u52D5\u9078\u629E\u306B\u5F71\u97FF\u3057\u307E\u3059\u3002",
text: "\u7a7a\u8179\u3084\u7720\u6c17\u304c\u5f37\u3044\u3068\u98df\u3079\u7269\u3084\u5bdd\u5e8a\u3092\u63a2\u3057\u307e\u3059\u3002\u30b9\u30c8\u30ec\u30b9\u306f\u6050\u6016\u3001\u6df7\u96d1\u3001\u6c5a\u308c\u3001\u3051\u3093\u304b\u3001\u5730\u9762\u306e\u7a2e\u985e\u306a\u3069\u3067\u5897\u6e1b\u3057\u3001\u9ad8\u3044\u307b\u3069\u9003\u8d70\u3001\u5a01\u5687\u3001\u4f53\u8abf\u4e0d\u826f\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308a\u307e\u3059\u3002",
},
{
title: "\u98DF\u4E8B\u3001\u75C5\u6C17\u3001\u56DE\u5FA9",
title: "\u98df\u4e8b\u3068\u6c34\u5206",
image: "assets/sprites/tarinai_05_drool.webp",
text: "\u8349\u3001\u305A\u3093\u3061\u3001\u9905\u3001\u30B8\u30E5\u30FC\u30B9\u3001\u6C34\u3001\u85AC\u306A\u3069\u3092\u72B6\u6CC1\u306B\u5FDC\u3058\u3066\u4F7F\u3044\u307E\u3059\u3002\u305A\u3093\u3061\u75C5\u3001\u306D\u3080\u308A\u75C5\u3001\u7206\u767A\u75C5\u3001\u304D\u305A\u3064\u304D\u75C5\u3067\u306F\u6C42\u3081\u308B\u3082\u306E\u3084\u884C\u52D5\u304C\u5909\u308F\u308A\u3001\u6C34\u30FB\u85AC\u30FB\u4F11\u606F\u30FB\u65E5\u5149\u6D74\u30FB\u74B0\u5883\u6539\u5584\u304C\u56DE\u5FA9\u306B\u95A2\u308F\u308A\u307E\u3059\u3002",
text: "\u8349\u3001\u305a\u3093\u3061\u3001\u9905\u3001\u30b8\u30e5\u30fc\u30b9\u306a\u3069\u3092\u72b6\u6cc1\u306b\u5fdc\u3058\u3066\u98df\u3079\u307e\u3059\u3002\u75c5\u6c17\u3084\u6975\u5ea6\u306e\u7a7a\u8179\u3067\u306f\u512a\u5148\u9806\u4f4d\u304c\u5909\u308f\u308a\u3001\u8fd1\u304f\u306e\u98df\u3079\u7269\u3092\u63a2\u3057\u307e\u3059\u3002\u6c34\u3084\u96e8\u6ef4\u306f\u4e00\u90e8\u306e\u75c5\u6c17\u3084\u74b0\u5883\u306e\u56de\u5fa9\u306b\u3082\u95a2\u308f\u308a\u307e\u3059\u3002",
},
{
title: "\u7761\u7720\u3068\u5DE3\u7BB1",
image: "assets/ui/tool_nest_box.webp",
text: "\u7720\u3044\u500B\u4F53\u306F\u5DE3\u7BB1\u3001\u30D9\u30C3\u30C9\u3001\u304B\u3093\u305F\u3093\u30D9\u30C3\u30C9\u3092\u63A2\u3057\u3001\u898B\u3064\u304B\u3089\u306A\u3044\u6642\u306F\u305D\u306E\u5834\u3067\u7720\u308A\u307E\u3059\u3002\u5DE3\u7BB1\u306F\u4E2D\u3067\u4F11\u3081\u308B\u5BDD\u5834\u6240\u3067\u3059\u3002\u901A\u5E38\u306F\u5B9A\u54E1\u3092\u8D85\u3048\u3066\u5165\u308C\u307E\u305B\u3093\u304C\u3001\u305F\u308A\u306A\u3044\u738B\u304B\u3089\u9003\u3052\u308B\u6642\u306F\u4E00\u5339\u3092\u62BC\u3057\u306E\u3051\u3066\u5165\u308C\u307E\u3059\u3002",
title: "\u7761\u7720\u3068\u5bdd\u5834\u6240",
image: "assets/sprites/tarinai_20_sleep2.webp",
text: "\u7720\u304f\u306a\u308b\u3068\u5de3\u7bb1\u3084\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u63a2\u3057\u307e\u3059\u3002\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u306f\u8349\u3092\u4f7f\u3063\u3066\u4f5c\u3089\u308c\u3001\u8d77\u5e8a\u6642\u306b\u6d88\u3048\u307e\u3059\u3002\u9577\u304f\u5bdd\u5e8a\u3092\u7528\u610f\u3067\u304d\u306a\u3044\u5834\u5408\u306f\u3001\u4f55\u3082\u306a\u3044\u5834\u6240\u3067\u7720\u308b\u3053\u3068\u3082\u3042\u308a\u307e\u3059\u3002",
},
{
title: "\u95A2\u4FC2\u3001\u5BB6\u65CF\u3001\u7E41\u6B96",
title: "\u8349\u3001\u5730\u9762\u3001\u5929\u6c17",
image: "assets/ui/tool_grass.webp",
text: "\u8349\u306f\u6210\u9577\u3057\u3001\u98df\u6599\u3084\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u306e\u6750\u6599\u306b\u306a\u308a\u307e\u3059\u3002\u5730\u9762\u306f\u7802\u3001\u571f\u3001\u30b3\u30f3\u30af\u30ea\u30fc\u30c8\u3001\u3075\u308f\u3075\u308f\u6bdb\u5e03\u3001\u8db3\u3064\u307c\u3001\u6c37\u3092\u5207\u308a\u66ff\u3048\u3089\u308c\u3001\u8349\u306e\u767a\u751f\u3084\u30b9\u30c8\u30ec\u30b9\u5897\u52a0\u306b\u5f71\u97ff\u3057\u307e\u3059\u3002\u96e8\u3084\u6642\u9593\u5e2f\u3082\u72b6\u614b\u306b\u95a2\u308f\u308a\u307e\u3059\u3002",
},
{
title: "\u6027\u683c\u3068\u884c\u52d5\u50be\u5411",
image: "assets/sprites/tarinai_21_normal_happy.webp",
text: "\u653b\u6483\u6027\u3001\u958b\u653e\u6027\u3001\u793e\u4ea4\u6027\u3001\u795e\u7d4c\u8cea\u6027\u306a\u3069\u304c\u3042\u308a\u3001\u7d4c\u9a13\u3067\u5c11\u3057\u305a\u3064\u5909\u5316\u3057\u307e\u3059\u3002\u6027\u683c\u306f\u98df\u4e8b\u3001\u63a5\u8fd1\u3001\u9003\u8d70\u3001\u3051\u3093\u304b\u3001\u7e41\u6b96\u76f8\u624b\u306e\u63a2\u3057\u65b9\u306a\u3069\u306e\u5dee\u3068\u3057\u3066\u8868\u308c\u307e\u3059\u3002",
},
{
title: "\u95a2\u4fc2\u3001\u5bb6\u65cf\u3001\u7e41\u6b96",
image: "assets/sprites/tarinai_27_birth_ritual.webp",
text: "\u89AA\u5B50\u3001\u5171\u540C\u89AA\u3001\u53CB\u597D\u3001\u6575\u5BFE\u3001\u6050\u6016\u3092\u8A18\u9332\u3057\u307E\u3059\u3002\u6761\u4EF6\u304C\u305D\u308D\u3046\u3068\u7E41\u6B96\u76F8\u624B\u3078\u8FD1\u3065\u304D\u3001\u3078\u3053\u3078\u3053\u5F8C\u306B\u5B50\u304C\u751F\u307E\u308C\u307E\u3059\u3002\u5BB6\u7CFB\u56F3\u3067\u306F\u4E16\u4EE3\u3001\u6027\u683C\u30BF\u30B0\u3001\u6226\u7E3E\u3001\u907A\u4F1D\u50BE\u5411\u3092\u78BA\u8A8D\u3067\u304D\u307E\u3059\u3002",
text: "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u53cb\u597d\u3001\u6575\u5bfe\u3001\u6050\u6016\u304c\u8a18\u9332\u3055\u308c\u307e\u3059\u3002\u6761\u4ef6\u304c\u305d\u308d\u3046\u3068\u3078\u3053\u3078\u3053\u3092\u884c\u3044\u3001\u5b50\u304c\u751f\u307e\u308c\u307e\u3059\u3002\u5b50\u306f\u89aa\u306e\u6027\u683c\u50be\u5411\u3092\u53d7\u3051\u7d99\u304e\u3001\u4e16\u4ee3\u60c5\u5831\u306f\u5bb6\u7cfb\u56f3\u306b\u6b8b\u308a\u307e\u3059\u3002",
},
{
title: "\u305F\u308A\u306A\u3044\u738B\u3068\u305A\u3093\u3061\u3069\u308C\u3044",
title: "\u75c5\u6c17\u3068\u56de\u5fa9",
image: "assets/sprites/tarinai_24_stress_dizzy.webp",
text: "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001\u304d\u305a\u3064\u304d\u75c5\u306a\u3069\u304c\u3042\u308a\u307e\u3059\u3002\u75c5\u6c17\u3054\u3068\u306b\u6c42\u3081\u308b\u3082\u306e\u3084\u884c\u52d5\u304c\u5909\u308f\u308a\u3001\u6c34\u3001\u85ac\u3001\u98df\u3079\u7269\u3001\u4f11\u606f\u3001\u74b0\u5883\u306e\u6539\u5584\u304c\u56de\u5fa9\u306b\u95a2\u4fc2\u3057\u307e\u3059\u3002",
},
{
title: "\u5371\u967a\u3068\u4e8b\u6545",
image: "assets/sprites/tarinai_26_intimidate.webp",
text: "\u9EC4\u91D1\u306E\u51A0\u306B\u89E6\u308C\u308B\u3001\u307E\u305F\u306F\u3051\u3093\u304B\u3067\u5F37\u3044\u5B9F\u7E3E\u3092\u51FA\u3059\u3068\u305F\u308A\u306A\u3044\u738B\u306B\u306A\u308A\u307E\u3059\u3002\u5974\u96B7\u306E\u9396\u306B\u89E6\u308C\u305F\u500B\u4F53\u306F\u305A\u3093\u3061\u3069\u308C\u3044\u306B\u306A\u308A\u3001\u666E\u901A\u306E\u305F\u308A\u306A\u3044\u3068\u306F\u8DDD\u96E2\u3092\u53D6\u308A\u307E\u3059\u3002\u738B\u3068\u3069\u308C\u3044\u306E\u72B6\u614B\u306F\u500B\u4F53\u30C7\u30FC\u30BF\u3068\u5BB6\u7CFB\u56F3\u306B\u6B8B\u308A\u3001\u95A2\u4FC2\u3084\u884C\u52D5\u306E\u89B3\u5BDF\u5BFE\u8C61\u306B\u306A\u308A\u307E\u3059\u3002",
text: "\u3051\u3093\u304b\u3001\u7206\u7af9\u3001\u3052\u3093\u3053\u3064\u3001\u30d4\u30f3\u3001\u30dc\u30fc\u30eb\u3001\u30a2\u30ea\u3001\u843d\u4e0b\u7269\u306a\u3069\u3067\u4f53\u529b\u304c\u6e1b\u308a\u307e\u3059\u3002\u4f53\u529b\u304c0\u306b\u306a\u308b\u3068\u6b7b\u4ea1\u3057\u3001\u6b7b\u56e0\u306f\u30ed\u30b0\u3068\u7d71\u8a08\u306b\u6b8b\u308a\u307e\u3059\u3002\u5371\u967a\u306a\u9053\u5177\u306f\u89b3\u5bdf\u306e\u6d41\u308c\u3092\u5927\u304d\u304f\u5909\u3048\u307e\u3059\u3002",
},
{
title: "\u9053\u5177\u3068\u4E8B\u6545",
title: "\u9053\u5177\u3068\u69cb\u9020\u7269",
image: "assets/ui/tool_duplicator.webp",
text: "\u98DF\u3079\u7269\u3001\u85AC\u3001\u5DE3\u7BB1\u3001\u67F5\u3001\u770B\u677F\u3001\u8907\u88FD\u6A5F\u306A\u3069\u3092\u914D\u7F6E\u3067\u304D\u307E\u3059\u3002\u7206\u7AF9\u3001\u3052\u3093\u3053\u3064\u3001\u30D4\u30F3\u3001\u30DC\u30FC\u30EB\u3001\u30A2\u30EA\u3001\u843D\u4E0B\u7269\u306A\u3069\u306F\u4E8B\u6545\u3084\u6B7B\u4EA1\u306B\u3064\u306A\u304C\u308A\u3001\u51FA\u6765\u4E8B\u306F\u30ED\u30B0\u3068\u7D71\u8A08\u306B\u6B8B\u308A\u307E\u3059\u3002",
text: "\u98df\u3079\u7269\u3001\u85ac\u3001\u5de3\u7bb1\u3001\u67f5\u3001\u770b\u677f\u3001\u8907\u88fd\u6a5f\u306a\u3069\u3092\u914d\u7f6e\u3067\u304d\u307e\u3059\u3002\u8907\u88fd\u6a5f\u306b\u306f\u5bfe\u5fdc\u3059\u308b\u98df\u3079\u7269\u3001\u85ac\u3001\u8349\u306a\u3069\u3092\u30bb\u30c3\u30c8\u3067\u304d\u307e\u3059\u3002\u524a\u9664\u30c4\u30fc\u30eb\u3067\u306f\u4e0d\u8981\u306a\u69cb\u9020\u7269\u3084\u4e00\u90e8\u30a2\u30a4\u30c6\u30e0\u3092\u53d6\u308a\u9664\u3051\u307e\u3059\u3002",
},
{
title: "\u30b3\u30ed\u30cb\u30fc\u306e\u898b\u65b9",
image: "assets/sprites/tarinai_11_weak.webp",
text: "\u30b3\u30ed\u30cb\u30fc\u6b04\u3067\u306f\u500b\u4f53\u6570\u3001\u6b7b\u4ea1\u6570\u3001\u5168\u4f53\u72b6\u614b\u3001\u5730\u9762\u3001\u6b32\u6c42\u5e73\u5747\u3001\u7269\u3092\u78ba\u8a8d\u3067\u304d\u307e\u3059\u3002\u89b3\u5bdf\u3067\u306f\u9078\u629e\u4e2d\u306e\u500b\u4f53\u306e\u5065\u5eb7\u3001\u6027\u683c\u3001\u95a2\u4fc2\u3001\u884c\u52d5\u7406\u7531\u3001\u8a18\u9332\u3092\u8ffd\u3048\u307e\u3059\u3002",
},
],};
@ -124,7 +144,7 @@ TEXT_CATALOG.temperatureEndureText = function temperatureEndureText(t = null) {
const status = t.temperatureStatus || t.world.temperatureStatusFor?.(t.feltTemperature ?? t.tempComfort ?? t.world.temperatureAt?.(t.x, t.y, t) ?? (CONFIG.standardTemperature ?? 15), t);
if (!status || status.comfortable || (status.discomfort || 0) <= 0.5) return "";
if (t.world.hasReachableComfortTemperatureSpot?.(t)) return "";
return status.direction === "cold" ? "\u5BD2\u3055\u306B\u8010\u3048\u3066\u3044\u308B\u3002" : "\u6691\u3055\u306B\u8010\u3048\u3066\u3044\u308B\u3002";
return status.direction === "cold" ? "寒さに耐えている。" : "暑さに耐えている。";
};
TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) {
@ -144,8 +164,7 @@ TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) {
TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) {
if (!t) return "\u306a\u3057";
if ((t.pinchThoughtTimer || 0) > 0 && (t.playerHeld || t._heldByPlayer)) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
if (t.lastPanicCause === "tarinai_champion" && (t.state === "panic" || (t.fearTimer || 0) > 0.04)) return "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
const behaviorState = currentTarinaiBehavior(t);
const behaviorLabel = behaviorText(t);
const cleanedBehaviorLabel = TEXT_CATALOG.cleanBehaviorText(behaviorLabel, behaviorState?.actionId || t.state || "");
@ -153,7 +172,7 @@ TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) {
if (cleanedBehaviorLabel) return cleanedBehaviorLabel;
const target = TEXT_CATALOG.targetLabel(t.target);
if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b";
if (t.state === "sleep") return t.target?.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : (t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b");
if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
if (t.state === "panic") return t.target ? `${target}\u304b\u3089\u9003\u3052\u3066\u3044\u308b` : "\u6016\u304c\u3063\u3066\u3044\u308b";
if (t.state === "intimidate") return target !== "\u306a\u3057" ? `${target}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "ant_intimidate") return "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";

View file

@ -12,10 +12,7 @@ const ui = {
soundMasterToggle: document.getElementById("soundMasterToggle"),
creditsBtn: document.getElementById("creditsBtn"),
saveBtn: document.getElementById("saveBtn"),
visualSettingsBtn: document.getElementById("visualSettingsBtn"),
resetBtn: document.getElementById("resetBtn"),
visualSettingsDialog: document.getElementById("visualSettingsDialog"),
visualSettingsCloseBtn: document.getElementById("visualSettingsCloseBtn"),
creditsDialog: document.getElementById("creditsDialog"),
creditsCloseBtn: document.getElementById("creditsCloseBtn"),
panel: document.querySelector(".panel"),

View file

@ -40,24 +40,6 @@ function bindUI() {
return window.matchMedia?.("(min-width: 981px)")?.matches ?? window.innerWidth >= 981;
}
function syncVisualSettingsDialog() {
const settings = window.TarinaiPerf?.visualSettings?.() || {};
ui.visualSettingsDialog?.querySelectorAll("[data-visual-setting]").forEach((btn) => {
const active = settings[btn.dataset.visualSetting] === btn.dataset.visualValue;
btn.classList.toggle("active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function openVisualSettingsDialog() {
syncVisualSettingsDialog();
ui.visualSettingsDialog?.classList.remove("hidden");
}
function closeVisualSettingsDialog() {
ui.visualSettingsDialog?.classList.add("hidden");
}
function markCollapsibleCardState(card, collapsed, { user = false } = {}) {
if (!card) return;
card.classList.toggle("collapsed", Boolean(collapsed));
@ -68,7 +50,7 @@ function bindUI() {
const toggle = card.querySelector(".panel-card-toggle");
toggle?.setAttribute("aria-expanded", collapsed ? "false" : "true");
const state = toggle?.querySelector(".panel-card-state");
if (state) state.textContent = collapsed ? "\u5C55\u958B" : "\u6298\u308A\u7573\u307F";
if (state) state.textContent = collapsed ? "展開" : "折り畳み";
}
function markToolCategoryState(category, collapsed, { user = false } = {}) {
@ -101,13 +83,13 @@ function bindUI() {
uiCache.showSelectedEmpty = true;
uiCache.selectedEmpty = false;
renderSelected();
showToast("\u305F\u308A\u306A\u3044\u304C\u672A\u9078\u629E\u3067\u3059\u3002\u305F\u308A\u306A\u3044\u30C7\u30FC\u30BF\u3092\u958B\u3051\u308B\u72B6\u614B\u306B\u3057\u307E\u3057\u305F\u3002");
showToast("たりないが未選択です。たりないデータを開ける状態にしました。");
}
const target = panelSectionForTab(tab);
if (!target) return false;
target.classList?.remove?.("hidden");
if (tab === "family") {
// \u66F4\u65B0OFF\u3067\u3082\u3001\u30E6\u30FC\u30B6\u30FC\u304C\u5BB6\u7CFB\u56F3\u3078\u79FB\u52D5\u3057\u305F\u6642\u3060\u3051\u73FE\u5728\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u63CF\u753B\u3059\u308B\u3002
// 更新OFFでも、ユーザーが家系図へ移動した時だけ現在スナップショットを描画する。
window.resetArchiveRenderState?.();
renderArchive?.();
}
@ -117,7 +99,7 @@ function bindUI() {
const top = Math.max(0, target.offsetTop - tabsH - 8);
panel.scrollTo({ top, behavior: "auto" });
} else {
// \u30B9\u30DE\u30DB\u306F\u30DA\u30FC\u30B8\u5168\u4F53\u30B9\u30AF\u30ED\u30FC\u30EB\u3002\u76EE\u7684\u5730UI\u306E\u4E0A\u7AEF\u3092\u753B\u9762\u4E0A\u7AEF\u3078\u5408\u308F\u305B\u308B\u3002
// スマホはページ全体スクロール。目的地UIの上端を画面上端へ合わせる。
scrollWindowToElement(target, 0, "auto");
}
setActivePanelTab(tab);
@ -474,22 +456,6 @@ function bindUI() {
ui.creditsDialog?.addEventListener("click", (e) => {
if (e.target === ui.creditsDialog) ui.creditsDialog.classList.add("hidden");
});
ui.visualSettingsBtn?.addEventListener("click", () => {
audio.uiClick?.();
openVisualSettingsDialog();
});
ui.visualSettingsCloseBtn?.addEventListener("click", closeVisualSettingsDialog);
ui.visualSettingsDialog?.addEventListener("click", (e) => {
if (e.target === ui.visualSettingsDialog) { closeVisualSettingsDialog(); return; }
const btn = e.target.closest("[data-visual-setting]");
if (!btn) return;
const ok = window.TarinaiPerf?.setVisualSetting?.(btn.dataset.visualSetting || "", btn.dataset.visualValue || "");
if (!ok) return;
audio.uiClick?.();
syncVisualSettingsDialog();
resizeCanvas?.();
render?.();
});
ui.resetBtn?.addEventListener("click", () => {
audio.uiClick?.();
openFieldDialog();
@ -564,7 +530,7 @@ function bindUI() {
world.copyBuffer = null;
selectTool("observe");
for (const b of uiCache.toolButtons || []) b.classList.remove("selected");
showToast("\u9053\u5177\u9078\u629E\u3092\u89E3\u9664\u3057\u307E\u3057\u305F\u3002");
showToast("道具選択を解除しました。");
return;
}
if (world.tool === tool && cycleToolSize(tool)) return;
@ -630,7 +596,6 @@ function bindUI() {
if (e.key !== "Escape") return;
closeFieldDialog();
closeEcologyDialog();
closeVisualSettingsDialog();
closeArchiveResetDialog();
ui.creditsDialog?.classList.add("hidden");
});

View file

@ -31,7 +31,7 @@ function renderStats() {
const colonyMood = world.evaluateColonyMood?.() || world.colonyMood || { label: "\u5b89\u5b9a" };
setTextIfChanged(ui.statColonyMood, "colonyMood", colonyMood?.label || "\u5b89\u5b9a");
const temp = world.updateTemperature?.(0) ?? world.currentTemperature ?? (CONFIG.standardTemperature ?? 15);
const tempLabel = `${Math.round(temp)}\u2103`;
const tempLabel = `${Math.round(temp)}`;
setTextIfChanged(ui.temperatureValue, "temperature", tempLabel);
if (ui.temperatureSlider) {
ui.temperatureSlider.disabled = world.temperatureAuto !== false;

View file

@ -26,13 +26,26 @@ function lineageBuildComponentsIdle(family, token, onProgress, onDone) {
let dfsIndex = 0;
const seen = new Set();
const addParent = (childId, parentId) => lineageAddParentEdge(childId, parentId, known, parentsByChild, childrenByParent);
const addParent = (childId, parentId) => {
if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return;
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
parentsByChild.get(childId).add(parentId);
childrenByParent.get(parentId).add(childId);
};
const addGraphEdge = (a, b) => {
if (!a || !b || !graph.has(a) || !graph.has(b)) return;
graph.get(a).add(b);
graph.get(b).add(a);
};
const validParentChild = (childId, parentId) => globalThis.TarinaiFamilyGraph.relationAllowsParentChild(family, childId, parentId);
const validParentChild = (childId, parentId) => {
const child = family[childId];
const parent = family[parentId];
if (!child || !parent) return false;
const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId);
const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId);
return childListsParent || parentListsChild;
};
const finishEmpty = () => {
lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } };
onDone([]);

View file

@ -5,12 +5,23 @@ function lineageEdgeFamily(family = world.family || {}) {
let lineageRelationIndexCache = { family: null, version: null, index: null };
function lineageAddParentEdge(childId, parentId, known, parentsByChild, childrenByParent) {
return globalThis.TarinaiFamilyGraph.addParentEdge(childId, parentId, known, parentsByChild, childrenByParent);
}
function lineageBuildRelationIndex(family) {
return globalThis.TarinaiFamilyGraph.buildRelationIndex(family);
const known = new Set(Object.keys(family || {}));
const parentsByChild = new Map();
const childrenByParent = new Map();
const addParent = (childId, parentId) => {
if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return;
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
parentsByChild.get(childId).add(parentId);
childrenByParent.get(parentId).add(childId);
};
for (const n of Object.values(family || {})) {
if (!n?.id) continue;
for (const parentId of n.parents || []) addParent(n.id, parentId);
for (const childId of n.children || []) addParent(childId, n.id);
}
return { parentsByChild, childrenByParent };
}
function lineageRelationIndex(family = world.family || {}) {

View file

@ -38,7 +38,7 @@ function lineageTreeNodeHtml(n, x, y) {
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 || "\u6B7B\u4EA1");
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();
@ -48,12 +48,12 @@ function lineageTreeNodeHtml(n, x, y) {
const deathLine = "";
const tagLine = displayTags.length ? `<span class="family-personality-tags">${displayTags.map(tag => `<em>${escapeHtml(tag)}</em>`).join("")}</span>` : "";
const champion = !!(live?.isTarinaiChampion || n?.isTarinaiChampion);
const statLine = n ? `<span class="family-genetic-stats" title="\u5BFF\u547D / \u4F53\u683C / \u653B\u6483 / \u901F\u5EA6">
<span><b>\u5BFF</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "life")))}</span>
<span><b>\u4F53</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "size")))}</span>
<span><b>\u653B</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "attack")))}</span>
<span><b>\u901F</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "speed")))}</span>
</span><span class="family-fight-stat" title="\u3051\u3093\u304B\u52DD\u7387 / \u56DE\u6570"><b>\u52DD</b>${escapeHtml(lineageFightRateText(n))}</span>` : "";
const statLine = n ? `<span class="family-genetic-stats" title="寿命 / 体格 / 攻撃 / 速度">
<span><b>寿</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "life")))}</span>
<span><b></b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "size")))}</span>
<span><b></b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "attack")))}</span>
<span><b></b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "speed")))}</span>
</span><span class="family-fight-stat" title="けんか勝率 / 回数"><b>勝</b>${escapeHtml(lineageFightRateText(n))}</span>` : "";
const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
const childCls = (n?.parents || []).length ? " child" : "";
const iconBaseScale = live
@ -64,8 +64,8 @@ function lineageTreeNodeHtml(n, x, y) {
return `<div class="family-node tree-node ${cls}${childCls}${champion ? " champion" : ""}"${data} title="${title}" style="left:${Math.round(x)}px;top:${Math.round(y)}px;--family-icon-scale:${iconScale}">
<div class="family-portrait" aria-hidden="true"><img src="${img}" loading="lazy" decoding="async" alt=""></div>
<div class="family-copy">
<strong>${champion ? "\u265B " : ""}${name}</strong>
<span>${escapeHtml(gen)} / ${escapeHtml(status)}${champion ? " / \u738B\u8005" : ""}</span>
<strong>${champion ? " " : ""}${name}</strong>
<span>${escapeHtml(gen)} / ${escapeHtml(status)}${champion ? " / 王者" : ""}</span>
${statLine}
${deathLine}
${tagLine}

View file

@ -21,59 +21,15 @@ function lineagePathFromPoints(points) {
return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
}
function lineageNodeRectsBetween(positions, nodeW, nodeH, y1, y2, excludeIds = new Set(), pad = 10) {
const top = Math.min(y1, y2) - pad;
const bottom = Math.max(y1, y2) + pad;
const rects = [];
for (const pos of positions?.values?.() || []) {
const id = pos?.node?.id;
if (!pos || (id && excludeIds.has(id))) continue;
const rectTop = pos.y - pad;
const rectBottom = pos.y + nodeH + pad;
if (rectBottom < top || rectTop > bottom) continue;
rects.push({ left: pos.x - pad, right: pos.x + nodeW + pad, top: rectTop, bottom: rectBottom });
}
return rects;
}
function lineageVerticalLaneClear(x, y1, y2, rects) {
const top = Math.min(y1, y2);
const bottom = Math.max(y1, y2);
return !(rects || []).some(r => x > r.left && x < r.right && Math.max(top, r.top) < Math.min(bottom, r.bottom));
}
function lineageCrossGenerationPartnerLane(a, b, nodeW, nodeH, context = {}) {
const ay = a.y + nodeH / 2;
const by = b.y + nodeH / 2;
const exclude = new Set([a.node?.id, b.node?.id].filter(Boolean));
const rects = lineageNodeRectsBetween(context.positions, nodeW, nodeH, ay, by, exclude, 12);
function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY) {
const ax = a.x + nodeW / 2;
const bx = b.x + nodeW / 2;
const directX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2;
if (lineageVerticalLaneClear(directX, ay, by, rects)) return directX;
const minLeft = Math.min(a.x, b.x, ...rects.map(r => r.left));
const maxRight = Math.max(a.x + nodeW, b.x + nodeW, ...rects.map(r => r.right));
const width = Number(context.width || 0) || Infinity;
const candidates = [
maxRight + 18,
minLeft - 18,
Math.max(a.x + nodeW, b.x + nodeW) + 18,
Math.min(a.x, b.x) - 18,
].filter(x => Number.isFinite(x) && x >= 12 && x <= width - 12);
const clear = candidates.filter(x => lineageVerticalLaneClear(x, ay, by, rects));
const pool = clear.length ? clear : candidates;
if (pool.length) return pool.sort((x, y) => Math.abs(x - directX) - Math.abs(y - directX))[0];
return directX;
}
function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context = {}) {
const railX = lineageCrossGenerationPartnerLane(a, b, nodeW, nodeH, context);
const aMidY = a.y + nodeH / 2;
const bMidY = b.y + nodeH / 2;
const aSideX = railX >= a.x + nodeW / 2 ? a.x + nodeW : a.x;
const bSideX = railX >= b.x + nodeW / 2 ? b.x + nodeW : b.x;
const points = [[aSideX, aMidY], [railX, aMidY], [railX, bMidY], [bSideX, bMidY]];
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: railX, unionY: Math.max(aMidY, bMidY) };
const ay = a.y + nodeH;
const by = b.y + nodeH;
const y = Math.max(unionY, ay + 12, by + 12);
const unionX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2;
const points = [[ax, ay], [ax, y], [unionX, y], [bx, y], [bx, by]];
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX, unionY: y };
}
function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
@ -98,28 +54,10 @@ function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffs
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y };
}
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0, context = {}) {
if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context);
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY);
return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB);
}
function lineageChildAnchorOnMarriageLine(partner, fallbackX, fallbackY) {
const horizontals = (partner?.segments || [])
.filter(seg => seg.kind === "h" && Math.abs(seg.x2 - seg.x1) > 10)
.sort((a, b) => {
const ty = Number.isFinite(partner?.unionY) ? partner.unionY : fallbackY;
const dy = Math.abs(a.y1 - ty) - Math.abs(b.y1 - ty);
if (Math.abs(dy) > 0.01) return dy;
return Math.abs(b.x2 - b.x1) - Math.abs(a.x2 - a.x1);
});
const seg = horizontals[0] || null;
if (!seg) return { x: fallbackX, y: fallbackY };
const minX = Math.min(seg.x1, seg.x2) + 8;
const maxX = Math.max(seg.x1, seg.x2) - 8;
const midX = (seg.x1 + seg.x2) / 2;
const x = minX <= maxX ? clamp(Number.isFinite(fallbackX) ? fallbackX : midX, minX, maxX) : midX;
return { x, y: seg.y1 };
}
function lineageVerticalBridgePath(x, y1, y2, bridges = [], radius = 8.5) {
if (!Number.isFinite(x) || !Number.isFinite(y1) || !Number.isFinite(y2)) return '';
@ -188,44 +126,6 @@ function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) {
return { d };
}
function lineageChildBusSpan(group, nodeW) {
const childCenters = (group.children || []).map(c => c.x + nodeW / 2);
if (!childCenters.length) return null;
return {
left: Math.min(group.joinX, ...childCenters),
right: Math.max(group.joinX, ...childCenters),
};
}
function lineageSpansOverlap(a, b, pad = 12) {
if (!a || !b) return false;
return Math.max(a.left, b.left) <= Math.min(a.right, b.right) + pad;
}
function lineageSeparateChildBusLanes(groupsInGap, nodeW) {
const lanes = [];
const ordered = (groupsInGap || [])
.filter(group => group && Number.isFinite(group.busY))
.map(group => ({ group, span: lineageChildBusSpan(group, nodeW), baseY: group.busY }))
.filter(entry => entry.span)
.sort((a, b) => a.baseY - b.baseY || a.span.left - b.span.left || a.span.right - b.span.right);
for (const entry of ordered) {
let laneIndex = 0;
while (lanes[laneIndex]?.some(prev => Math.abs(prev.group.busY - entry.baseY) < 12 && lineageSpansOverlap(prev.span, entry.span))) {
laneIndex += 1;
}
if (!lanes[laneIndex]) lanes[laneIndex] = [];
const fromY = Number(entry.group.fromY);
const childTop = Number(entry.group.childTop);
const minY = Number.isFinite(fromY) ? fromY + 26 : -Infinity;
const maxY = Number.isFinite(childTop) ? childTop - 18 : Infinity;
const offset = laneIndex * 9;
entry.group.busY = clamp(entry.baseY - offset, minY, maxY);
lanes[laneIndex].push(entry);
}
}
function lineageComponentSignature(component, family) {
family = lineageEdgeFamily(family);
const idSet = new Set(component.map(x => x?.id).filter(Boolean));
@ -337,7 +237,7 @@ function lineageAssignPartnerAttachOffsets(linkGroups) {
for (const [key, groups] of buckets.entries()) {
groups.sort((a, b) => a.joinX - b.joinX || a.childTop - b.childTop);
const n = groups.length;
const step = 14;
const step = 9;
groups.forEach((group, i) => {
const offset = n <= 1 ? 0 : (i - (n - 1) / 2) * step;
const [nodeId] = key.split(':');
@ -384,8 +284,8 @@ function lineageBuildLinks(nodes, idSet, layout) {
for (const groupsInGap of byGap.values()) {
groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length);
const top = Math.min(...groupsInGap.map(g => g.parentBottom)) + 58;
const bottom = Math.max(...groupsInGap.map(g => g.childTop)) - 54;
const top = Math.min(...groupsInGap.map(g => g.parentBottom)) + 44;
const bottom = Math.max(...groupsInGap.map(g => g.childTop)) - 42;
const span = Math.max(16, bottom - top);
groupsInGap.forEach((group, i) => {
const laneY = top + span * ((i + 1) / (groupsInGap.length + 1));
@ -398,22 +298,23 @@ function lineageBuildLinks(nodes, idSet, layout) {
const attachA = 0;
const attachB = 0;
const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB, layout);
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB);
if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX;
const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY;
const anchor = lineageChildAnchorOnMarriageLine(partner, Number.isFinite(partner.unionX) ? partner.unionX : group.joinX, unionY);
group.joinX = anchor.x;
group.fromY = anchor.y;
group.busY = group.children.length === 1 ? Math.min(group.childTop - 22, anchor.y + 38) : Math.min(group.childTop - 28, Math.max(anchor.y + 34, laneY + 24 + i * 5));
const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36);
group.fromY = clearY;
group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, clearY + 28) : Math.min(group.childTop - 24, Math.max(clearY + 24, laneY + 18 + i * 3));
if (partner.d) {
partnerLinks.push(partner.d);
horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h'));
}
const trunk = lineageVerticalBridgePath(group.joinX, unionY, group.fromY, []);
if (trunk) childLinks.push(trunk);
} else {
group.fromY = group.parentBottom;
group.busY = group.children.length === 1 ? Math.min(group.childTop - 22, group.fromY + 38) : Math.min(group.childTop - 28, Math.max(group.fromY + 34, laneY + 24 + i * 5));
group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, group.fromY + 30) : Math.min(group.childTop - 24, Math.max(group.fromY + 28, laneY + 18 + i * 3));
}
});
lineageSeparateChildBusLanes(groupsInGap, nodeW);
}
for (const groupsInGap of byGap.values()) {

View file

@ -91,25 +91,19 @@ function lineageBuildLinksFast(nodes, idSet, layout) {
childLinkEdges += parents.length;
const childX = childPos.x + nodeW / 2;
const childY = childPos.y;
let fromY = Math.max(...parents.map(p => p.y + nodeH));
let joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length;
const fromY = Math.max(...parents.map(p => p.y + nodeH));
const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length;
if (parents.length >= 2) {
const left = parents[0];
const right = parents[parents.length - 1];
const partnerKey = parents.map(p => p.node?.id).filter(Boolean).sort().join("+");
const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
const x1 = left.x + nodeW;
const x2 = right.x;
if (x2 > x1) {
joinX = clamp(joinX, x1 + 8, x2 - 8);
fromY = y;
if (partnerKey && !seenPartners.has(partnerKey)) {
seenPartners.add(partnerKey);
partnerLinks.push(`M ${x1.toFixed(1)} ${y.toFixed(1)} L ${x2.toFixed(1)} ${y.toFixed(1)}`);
}
if (partnerKey && !seenPartners.has(partnerKey)) {
seenPartners.add(partnerKey);
const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
partnerLinks.push(`M ${(left.x + nodeW).toFixed(1)} ${y.toFixed(1)} L ${right.x.toFixed(1)} ${y.toFixed(1)}`);
}
}
const laneY = Math.min(childY - 22, fromY + Math.max(36, (childY - fromY) * 0.54));
const laneY = Math.min(childY - 18, fromY + Math.max(28, (childY - fromY) * 0.48));
childLinks.push(`M ${joinX.toFixed(1)} ${fromY.toFixed(1)} L ${joinX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${childY.toFixed(1)}`);
}
return { partnerLinks, childLinks, childLinkGroups, childLinkEdges };

View file

@ -252,7 +252,7 @@
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);
} else if ((target.type === "ball" || target.type === "balloon")) {
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
@ -293,8 +293,8 @@
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.type === "balloon")) target.spinVelocity = clamp((target.spinVelocity || 0) + releaseVx * 0.018, -42, 42);
} else if ((target.type === "ball" || target.type === "balloon")) {
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;
}
@ -302,13 +302,6 @@
if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-release");
target.playerHeld = false;
target._heldByPlayer = false;
if (uiCache.grabKind === "tarinai") {
target.pinchThoughtTimer = 0;
if (target.thought === "つままれている") target.thought = "";
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(target) : null;
const reason = String(behavior?.reason || behavior?.text || behavior?.label || "");
if (reason === "つままれている" && typeof clearBehavior === "function") clearBehavior(target);
}
if (target.type === "duplicator") {
target.duplicatorLoadSuppressedUntil = Math.max(target.duplicatorLoadSuppressedUntil || 0, (world.time || 0) + 1.2);
}
@ -381,10 +374,13 @@
if (!p?.inside) return false;
global.TarinaiHistory.capture(world, "shoot");
uiCache.shooting = true;
uiCache.shootMoved = false;
uiCache.shootSuppressClick = true;
uiCache.shootFired = false;
uiCache.shootStartX = clientX;
uiCache.shootStartY = clientY;
uiCache.shootLastClientX = clientX;
uiCache.shootLastClientY = clientY;
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = true;
@ -408,6 +404,8 @@
}
function moveShoot(p, clientX, clientY, options = {}) {
uiCache.shootLastClientX = clientX;
uiCache.shootLastClientY = clientY;
const rawDx = (Number(p.x) || 0) - (Number(uiCache.shootLastRawWorldX ?? p.x) || 0);
const rawDy = (Number(p.y) || 0) - (Number(uiCache.shootLastRawWorldY ?? p.y) || 0);
uiCache.shootLastRawWorldX = p.x;
@ -418,6 +416,7 @@
if (p.inside) world.moveShootCursorBy?.(rawDx, rawDy);
const totalMove = Math.hypot(clientX - (uiCache.shootStartX || clientX), clientY - (uiCache.shootStartY || clientY));
if (totalMove > 3) {
uiCache.shootMoved = true;
if (options.touch) uiCache.touchMoved = true;
}
canvas.style.cursor = "none";
@ -524,6 +523,7 @@
world.pointer.inside = !!p.inside;
const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0));
world.pointer.motion = clamp(delta / 18, 0, 2);
world.pointer.movedAt = performance.now();
if (world.tool === "shoot") {
if (p.inside) {
if (world.shootCursorActive) {

View file

@ -32,9 +32,10 @@ function applyFieldLayout(fieldType = world.fieldType || "garden") {
resizeCanvas();
}
const RESET_PRESET_IDS = new Set(["empty", "default", "athletic", "crowded", "family", "war", "happy"]);
function normalizeResetPresetId(presetId = "default") {
if (window.TarinaiResetPresets?.normalize) return window.TarinaiResetPresets.normalize(presetId);
return new Set(["empty", "default", "athletic", "crowded", "family", "war", "happy"]).has(String(presetId || "")) ? String(presetId) : "default";
return RESET_PRESET_IDS.has(String(presetId || "")) ? String(presetId) : "default";
}
function syncFieldDialogChoices() {

Some files were not shown because too many files have changed in this diff Show more