Compare commits
9 commits
129c07183a
...
ad2cc76429
| Author | SHA1 | Date | |
|---|---|---|---|
| ad2cc76429 | |||
| f941200504 | |||
| e00bfd8879 | |||
| 4fdc567e08 | |||
| 348f355eb2 | |||
| 0e432c62ae | |||
| cefa2ace5a | |||
| f500279abd | |||
| 5ed977788c |
58
AI_MAP.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# tarinai_ AI map
|
||||
|
||||
Purpose: static Japanese browser simulation/game. Optimize context by reading this file only after `README.md`, then open the relevant subsystem files. For exact file expansion, use `scripts/ai_inventory.ps1` or `scripts/ai_inventory.py`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- 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.47`.
|
||||
- Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render.
|
||||
- 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.
|
||||
|
||||
## Subsystem Map
|
||||
|
||||
- Core/bootstrap: `js/version.js`, `js/event_bus.js`, registries, `js/data.js`, `js/ground_types.js`, `js/health.js`, `js/main.js`, `js/debug_tools.js`, `js/perf_profiler.js`.
|
||||
- Rendering/assets/audio: `js/assets.js`, `js/audio.js`, `js/render.js`, `js/tarinai_render.js`, `js/item_render_runtime.js`, `js/sound_pack.js`.
|
||||
- Simulation phases: `js/sim_core.js`, `js/simulation*.js`, `js/world_update*.js`, `js/system_order.js`.
|
||||
- World domain: `js/world*.js`, `js/weather_system.js`; covers state, camera/view, environment, combat/effects, placement/history, tools, family/social, ants, spatial budgets.
|
||||
- Creature domain: `js/tarinai*.js`; covers state, identity, needs, actions, forced behavior, social life, item effects, disease/nests, movement/update pipeline, rendering.
|
||||
- Item/structure domain: `js/item*.js`, `js/items.js`, `js/structures.js`, `js/structure_lifecycle.js`; covers definitions, spawning defaults, lifecycle, dynamic behavior, rendering.
|
||||
- Physics/mechanics: `js/physics*.js`, `js/mechanical_system.js`, `js/constraint_system.js`, `js/collision_footprint_system.js`.
|
||||
- UI/input: `js/ui*.js`, `js/text_catalog.js`, `js/command_dispatcher.js`; covers panels, tools, selected creature, logs, charts, family tree, mouse/touch, dialogs, labels.
|
||||
- Family tree: `js/family_graph.js`, `js/ui_family_*.js`; data transform, layout, paths, async rendering, validation.
|
||||
- Save/history: `js/save*.js`, `js/snapshot_system.js`, `js/restore_coordinator.js`, `js/history_system.js`.
|
||||
- Scripts: `scripts/generate_app_files.py`, `scripts/generate_item_icons.py`, `scripts/regression_check.py`, `scripts/ai_inventory.py`, `scripts/ai_inventory.ps1`.
|
||||
|
||||
## File Pattern Semantics
|
||||
|
||||
- `*_pipeline.js`: ordered runner/facade for update or lifecycle work.
|
||||
- `*_step_*.js`: narrow pipeline step wrapper.
|
||||
- `*_system.js`: domain service, phase service, or compatibility facade.
|
||||
- `*_runtime.js`: stable runtime-facing facade around split modules.
|
||||
- `*_registry.js`: definition registry and lookup/normalization helpers.
|
||||
- `world_*`: world-owned behavior; `tarinai_*`: creature-owned behavior; `item_*`: item-owned behavior; `ui_*`: DOM/input/panel behavior.
|
||||
- `assets/sprites/tarinai_*.webp`: creature visual state sprites; numeric prefix is stable asset ID/order.
|
||||
- `assets/ui/tool_*`: tool palette icons; `assets/ui/favicon*`, `apple-touch-icon`, `ecology_*` are app/help icons.
|
||||
- `assets/objects/*`: field object sprites for ants, zunchi, pushpin, oshibyo, genkotsu, plushie.
|
||||
- `assets/sounds/voice_*`: creature voice samples; `shoot_*`, `major_damage_*`, `firecracker_*` are SFX.
|
||||
|
||||
## Task-Oriented Read Sets
|
||||
|
||||
- UI/layout bug: `index.html`, relevant `css/*.css`, `js/ui_bind.js`, `js/ui_layout_dialogs.js`, matching `js/ui_*.js`.
|
||||
- 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`.
|
||||
- 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`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- 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 global exports for compatibility with regression checks and browser runtime.
|
||||
- Prefer focused edits in the owning subsystem; split modules are intentional compatibility boundaries.
|
||||
- Run `node --check js/*.js` and `python scripts/regression_check.py` for behavior changes.
|
||||
175
FILES.json
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
{
|
||||
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
|
||||
"version": "39.15.77",
|
||||
"entrypoints": {
|
||||
"app": "index.html",
|
||||
"runtime": "js/main.js",
|
||||
"update_order": "js/system_order.js",
|
||||
"manifest": "app_manifest.json",
|
||||
"service_worker": "service-worker.js"
|
||||
},
|
||||
"read_strategy": {
|
||||
"default": [
|
||||
"README.md"
|
||||
],
|
||||
"architecture": [
|
||||
"README.md",
|
||||
"AI_MAP.md"
|
||||
],
|
||||
"exact_inventory": [
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All",
|
||||
"python scripts/ai_inventory.py --all"
|
||||
],
|
||||
"machine_routing": [
|
||||
"FILES.json"
|
||||
]
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"id": "root",
|
||||
"patterns": [
|
||||
".gitignore",
|
||||
".htaccess",
|
||||
"app_manifest.json",
|
||||
"favicon.ico",
|
||||
"index.html",
|
||||
"README.md",
|
||||
"AI_MAP.md",
|
||||
"GAME_FEATURES.md",
|
||||
"FILES.json",
|
||||
"service-worker.js"
|
||||
],
|
||||
"meaning": "Repo/app shell metadata, server/static cache config, docs, manifest, entrypoint."
|
||||
},
|
||||
{
|
||||
"id": "css",
|
||||
"patterns": [
|
||||
"css/*.css"
|
||||
],
|
||||
"meaning": "Base, layout, panel, component, and mobile responsive styling."
|
||||
},
|
||||
{
|
||||
"id": "scripts",
|
||||
"patterns": [
|
||||
"scripts/*.py",
|
||||
"scripts/*.ps1"
|
||||
],
|
||||
"meaning": "Generators, regression checks, and token-aware AI inventory tooling."
|
||||
},
|
||||
{
|
||||
"id": "core",
|
||||
"patterns": [
|
||||
"js/version.js",
|
||||
"js/event_bus.js",
|
||||
"js/registry_base.js",
|
||||
"js/disease_registry.js",
|
||||
"js/sound_pack.js",
|
||||
"js/data.js",
|
||||
"js/ground_types.js",
|
||||
"js/health.js",
|
||||
"js/input_mode_manager.js",
|
||||
"js/main.js",
|
||||
"js/math.js",
|
||||
"js/perf_profiler.js",
|
||||
"js/debug_tools.js"
|
||||
],
|
||||
"meaning": "Bootstrap, shared constants/helpers, registries, config, profiling/debug."
|
||||
},
|
||||
{
|
||||
"id": "assets_runtime",
|
||||
"patterns": [
|
||||
"js/assets.js",
|
||||
"js/audio.js",
|
||||
"js/render.js",
|
||||
"js/tarinai_render.js",
|
||||
"js/item_render_runtime.js"
|
||||
],
|
||||
"meaning": "Image/audio loading and canvas rendering."
|
||||
},
|
||||
{
|
||||
"id": "simulation",
|
||||
"patterns": [
|
||||
"js/sim_core.js",
|
||||
"js/simulation*.js",
|
||||
"js/system_order.js"
|
||||
],
|
||||
"meaning": "Simulation primitives, concrete systems, phase ordering."
|
||||
},
|
||||
{
|
||||
"id": "world",
|
||||
"patterns": [
|
||||
"js/world*.js",
|
||||
"js/weather_system.js"
|
||||
],
|
||||
"meaning": "World state/view/update/tools/environment/combat/family/social/ants."
|
||||
},
|
||||
{
|
||||
"id": "physics",
|
||||
"patterns": [
|
||||
"js/physics*.js",
|
||||
"js/mechanical_system.js",
|
||||
"js/constraint_system.js",
|
||||
"js/collision_footprint_system.js"
|
||||
],
|
||||
"meaning": "Geometry, collisions, constraints, mechanical item behavior."
|
||||
},
|
||||
{
|
||||
"id": "items",
|
||||
"patterns": [
|
||||
"js/item*.js",
|
||||
"js/items.js",
|
||||
"js/structures.js",
|
||||
"js/structure_lifecycle.js"
|
||||
],
|
||||
"meaning": "Item/structure registry, lifecycle, dynamic behavior, rendering."
|
||||
},
|
||||
{
|
||||
"id": "tarinai",
|
||||
"patterns": [
|
||||
"js/tarinai*.js",
|
||||
"js/ants.js"
|
||||
],
|
||||
"meaning": "Creature state/actions/needs/social life/item effects/disease/rendering plus ant actor model."
|
||||
},
|
||||
{
|
||||
"id": "save",
|
||||
"patterns": [
|
||||
"js/save*.js",
|
||||
"js/snapshot_system.js",
|
||||
"js/restore_coordinator.js",
|
||||
"js/history_system.js"
|
||||
],
|
||||
"meaning": "Snapshot schema/codec/storage/restore/history."
|
||||
},
|
||||
{
|
||||
"id": "ui",
|
||||
"patterns": [
|
||||
"js/ui*.js",
|
||||
"js/text_catalog.js",
|
||||
"js/command_dispatcher.js",
|
||||
"js/family_graph.js"
|
||||
],
|
||||
"meaning": "DOM UI, input, tools, selected panel, logs, charts, family tree, text labels, command routing."
|
||||
},
|
||||
{
|
||||
"id": "assets",
|
||||
"patterns": [
|
||||
"assets/sprites/tarinai_*.webp",
|
||||
"assets/ui/tool_*",
|
||||
"assets/ui/favicon*",
|
||||
"assets/ui/apple-touch-icon.png",
|
||||
"assets/ui/ecology_*.webp",
|
||||
"assets/objects/*",
|
||||
"assets/sounds/*"
|
||||
],
|
||||
"meaning": "Creature sprites, UI/tool icons, field object images, voice/SFX samples."
|
||||
}
|
||||
],
|
||||
"conventions": {
|
||||
"*_pipeline.js": "ordered runner/facade",
|
||||
"*_step_*.js": "pipeline stage wrapper",
|
||||
"*_system.js": "domain service or compatibility facade",
|
||||
"*_runtime.js": "stable runtime-facing facade",
|
||||
"*_registry.js": "definition lookup/normalization"
|
||||
}
|
||||
}
|
||||
171
GAME_FEATURES.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# tarinai_ Game Features
|
||||
|
||||
This document describes the playable features of `tarinai_`: what the player sees, what tools do, and how tarinai behave. It is intentionally player/design-facing, not an implementation map.
|
||||
|
||||
## Game Overview
|
||||
|
||||
`tarinai_` is an observation sandbox about small creatures called tarinai. The player places food, furniture, hazards, machines, and environment tools, then watches how individuals live, move, eat, sleep, panic, fight, reproduce, get sick, recover, and leave family records.
|
||||
|
||||
The core loop is:
|
||||
|
||||
1. Choose a field and observe the colony.
|
||||
2. Place tools or objects that change the environment.
|
||||
3. Select individual tarinai to inspect health, needs, personality, relations, diseases, records, and current behavior.
|
||||
4. Watch the event log, colony graphs, and family tree to understand long-term outcomes.
|
||||
|
||||
## Main UI
|
||||
|
||||
- **Canvas / field**: the main simulation area. Tarinai, items, effects, ants, weather, and terrain are drawn here.
|
||||
- **Top buttons**: pause/resume, speed control, ecology guide, sound settings, credits, save, and reset.
|
||||
- **Mobile mode controls**: auto, camera, and tool modes for touch screens.
|
||||
- **Right panel tabs**: jump between main game, selected tarinai data, tools, colony, events, and family tree.
|
||||
- **Tarinai data panel**: appears when a tarinai is selected. Shows health, needs, personality, active effects, relations, inventory, behavior reason, and records.
|
||||
- **Tool palette**: grouped buttons for operation, food, medicine, habitat, physics, and danger tools.
|
||||
- **Colony panel**: shows population, deaths, colony mood, ground type, and charts for population, needs, and objects.
|
||||
- **Event panel**: logs births, deaths, accidents, fights, food events, weather, grass, and relation changes. Optional push-style notifications can be enabled by event type.
|
||||
- **Family tree**: shows parent-child generations, inherited stats, personality tags, family links, and archived lineage records.
|
||||
- **Ecology dialog**: in-game guide explaining tarinai needs, stress, food, sleep, personality, family, disease, accidents, tools, and colony reading.
|
||||
- **Sound menu**: master sound plus voice, notification, and operation categories.
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### Operation
|
||||
|
||||
- **解除 / clear**: cancels the selected tool and returns to normal observe mode.
|
||||
- **たりないデータ / observe**: select a tarinai or inspect compatible objects such as signboards.
|
||||
- **追加 / new**: calls a new tarinai into the field.
|
||||
- **つつく / poke**: pokes tarinai and can push balls.
|
||||
- **つまむ / pinch**: drags tarinai and some placed objects.
|
||||
- **コピー / copy**: copies a placed item and pastes a new instance with its settings.
|
||||
- **戻す / undo** and **進む / redo**: reverse or reapply placement/edit actions.
|
||||
- **削除 / delete**: removes placed tools, objects, and stains.
|
||||
- **範囲削除 / area delete**: removes many nearby objects or stains around the cursor.
|
||||
- **洗浄 / water hose**: drag to clean dirt, zunchi, corpses, and stains.
|
||||
|
||||
### Food
|
||||
|
||||
- **ずんだ餅 / sweet**: favored food; can cure some illness.
|
||||
- **へこ餅 / love mochi**: makes reproduction behavior more likely for a while.
|
||||
- **けんか餅 / fight mochi**: makes fighting behavior more likely for a while.
|
||||
- **草 / grass**: food and comfort source; can grow and be used for simple beds.
|
||||
- **水滴 / water**: drinkable water, also appears during rain and can be set in duplicators.
|
||||
- **ずんだ汁 / zunda juice**: clears item effects from a tarinai.
|
||||
- **複製機 / duplicator**: can be loaded with compatible food, medicine, grass, water, pushpins, or oshibyo and then produces or applies that content.
|
||||
|
||||
### Medicine
|
||||
|
||||
- **ねむり薬 / sleep drug**: causes sleepiness or sleep disease; damage can cure the disease.
|
||||
- **下剤 / laxative**: causes repeated zunchi and reduces food benefit.
|
||||
- **プロテイン / protein**: permanent power mode; more damage dealt, lower disease chance.
|
||||
- **ンイテロプ / niteropu**: permanent weak mode; less damage dealt, higher disease chance.
|
||||
- **弾薬 / ammo**: temporary high-speed movement and strong collision impact.
|
||||
- **怪しい薬 / mystery drug**: randomly applies an eligible item effect or disease.
|
||||
- **水銀 / mercury**: increases lifespan multiplier on each use.
|
||||
- **巨大薬 / giant drug**: greatly increases size, attack, and speed, but deals ongoing damage.
|
||||
- **矮小薬 / dwarf drug**: greatly decreases size, attack, and speed, and also deals ongoing damage.
|
||||
|
||||
### Habitat
|
||||
|
||||
- **ずんち / zunchi**: tarinai waste; affects disease and can fertilize grass.
|
||||
- **ロボ掃除機 / 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.
|
||||
|
||||
### Danger
|
||||
|
||||
- **石 / stone**: heavy object; dangerous when dropped or moved into others.
|
||||
- **げんこつ / genkotsu**: large fist impact on the ground.
|
||||
- **爆竹 / firecracker**: creates an explosion.
|
||||
- **画鋲 / pushpin**: rolls when dropped; can lodge in tarinai and cause panic plus continuous damage.
|
||||
- **おしり鋲 / oshibyo**: blocks zunchi when lodged; pulling it out releases stored zunchi.
|
||||
- **射撃 / shoot**: attacks with a gun effect.
|
||||
- **アリの巣 / ant nest**: spawns ants that search for tarinai and carry them to the nest.
|
||||
|
||||
## Tarinai Needs And Stats
|
||||
|
||||
Tarinai behavior is shaped by health, energy, needs, stress, personality, family, disease, and nearby objects.
|
||||
|
||||
Key needs include:
|
||||
|
||||
- **Food / hunger**: hungry tarinai seek grass, sweets, food, water-related options, or other edible targets.
|
||||
- **Sleep**: sleepy tarinai seek beds, nest boxes, grass beds, or eventually sleep anywhere.
|
||||
- **Safety**: danger, pain, hazards, ants, fights, and panic increase defensive behavior.
|
||||
- **Social / relations**: tarinai can seek friends, follow parents, avoid enemies, fight rivals, or approach mates.
|
||||
- **Fulfillment / comfort**: toys, ground, sun, shelter, and environment affect mood and stress.
|
||||
- **Health**: damage, disease, stress, and recovery decide whether tarinai live, weaken, or die.
|
||||
|
||||
Stress changes with fear, crowding, dirt, disease, fights, ground type, and environment. High stress makes fleeing, intimidation, sickness, and poor behavior more likely.
|
||||
|
||||
Personality and genetics affect tendencies such as aggression, openness, sociability, nervousness, body size, lifespan, damage, and speed. Some traits are inherited by children and shown in the family tree.
|
||||
|
||||
## Movement And Behavior
|
||||
|
||||
Tarinai are autonomous. Their movement is usually a result of current state, target choice, and local environment.
|
||||
|
||||
Common behavior states:
|
||||
|
||||
- **Idle / wander**: waits, watches, or moves lightly around the field.
|
||||
- **Seek food**: moves toward edible items when hungry or affected by disease.
|
||||
- **Eat / drink**: consumes food, grass, water, medicine, or special items.
|
||||
- **Seek bed / sleep**: finds a bed, nest box, grass bed, or sleeps in place.
|
||||
- **Play ball**: approaches and interacts with balls.
|
||||
- **Seek material / build**: gathers material and creates simple structures such as grass beds.
|
||||
- **Seek friend / follow parent**: moves toward social targets or parents.
|
||||
- **Fight / seek enemy**: approaches and attacks rivals, especially under fight effects or aggression.
|
||||
- **Panic / flee**: runs from danger, pain, destroyed owned objects, hazards, or fear triggers.
|
||||
- **Intimidate**: threatens another tarinai or ants instead of directly fleeing or attacking.
|
||||
- **Birth ritual**: pre-birth/reproduction interaction with a partner.
|
||||
- **Sunbath**: rests in good sun to reduce stress.
|
||||
- **Disease states**: zunchi sickness, sleep disease, explosion disease, and injury sickness can override normal priorities.
|
||||
- **Frozen**: outside-field preservation state.
|
||||
|
||||
Tarinai can be pushed by physics objects, carried or affected by ants, dragged by the player, attached with ropes/rods, blocked by fences, and damaged by hazards. Some objects become owned or emotionally important, so losing them can trigger panic.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Field types**: park, garden, and cage provide different field sizes.
|
||||
- **Ground types**: sand, dirt, concrete, blanket, foot massage, ice, and laboratory-style ground affect stress, grass, and movement.
|
||||
- **Weather**: rain and other weather influence water, mood, and environment effects.
|
||||
- **Grass ecology**: grass grows, feeds tarinai, comforts them, and can become bed material.
|
||||
- **Dirt and corpses**: zunchi, splats, corpses, and ant corpses affect hygiene, disease, and observation results.
|
||||
- **Day/light conditions**: lighting and sun influence rendering and sunbath behavior.
|
||||
|
||||
## Disease And Recovery
|
||||
|
||||
Diseases and item effects can strongly redirect behavior.
|
||||
|
||||
- **Zunchi disease**: changes food/water priorities and makes the tarinai feel unwell.
|
||||
- **Sleep disease**: causes sleep-focused behavior.
|
||||
- **Explosion disease**: creates dangerous explosive outcomes and water-seeking pressure.
|
||||
- **Fight disease / injury sickness**: causes unstable movement or fighting-related behavior.
|
||||
- **Item effects**: mochi, medicine, size drugs, mercury, ammo, and power modes create temporary or permanent modifiers.
|
||||
|
||||
Recovery depends on the disease or effect. Water, rest, food, medicine, zunda juice, damage, environment cleanup, or time can all matter depending on the case.
|
||||
|
||||
## Family, Records, And Colony Observation
|
||||
|
||||
- Tarinai can form parent-child records and partner links.
|
||||
- Children inherit parts of their parents' tendencies and are grouped by generation.
|
||||
- Family tree nodes show portraits, inherited stats, personality tags, and relationship structure.
|
||||
- Event logs record important simulation events such as birth, death, accidents, fights, weather, grass, and relation changes.
|
||||
- Colony charts summarize population, needs, and object counts over time.
|
||||
- Selected tarinai records help explain why an individual is acting a certain way.
|
||||
|
||||
## Save, Reset, And Persistence
|
||||
|
||||
- The app supports manual save and restore through browser storage.
|
||||
- Reset opens field selection and starts a new field state.
|
||||
- Service worker caching allows the static app shell to remain available after load, depending on browser support.
|
||||
87
README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# tarinai_
|
||||
|
||||
AI-first entrypoint for a static browser simulation/game. Keep this file small; read deeper docs only when needed.
|
||||
|
||||
## v39.15.70 robot cleaner tuning
|
||||
- Tuned ロボ掃除機 targeting: normal speed is 60, high-speed mode is 120, and speed no longer changes based on the selected cleanup targets.
|
||||
- The cleaner checks the nearest matching target every 2 seconds, then keeps trying to clean that target between scans. Contact cleanup still removes every matching object it touches.
|
||||
- The 死骸 target also covers blood traces. When たりない is enabled, nearby Tarinai panic while chased and are cleaned on contact.
|
||||
|
||||
## Read Strategy
|
||||
|
||||
- Start here for architecture, script order constraints, and where to look next.
|
||||
- For game mechanics, UI, items, and tarinai behavior, read `GAME_FEATURES.md`.
|
||||
- For subsystem work, read `AI_MAP.md` plus only the matching files.
|
||||
- For exact file inventory, run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All` or narrow it: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 ui`. Python equivalent: `python scripts/ai_inventory.py --all`.
|
||||
- For machine-readable routing, read `FILES.json`.
|
||||
- Avoid loading all assets into context; asset filenames are mostly self-describing and grouped by glob.
|
||||
|
||||
## Runtime Shape
|
||||
|
||||
- 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`.
|
||||
- Generated/static app shell: `app_manifest.json`, `service-worker.js`, and `js/version.js` must stay version-aligned.
|
||||
|
||||
## File Routing
|
||||
|
||||
- `index.html`: DOM shell, CSS/JS load order, panels/dialogs/canvas.
|
||||
- `css/*.css`: base/layout/panel/components/mobile styling.
|
||||
- `js/world*.js`: world state, view, update phases, tools, placement, combat, environment, family/social, ants.
|
||||
- `js/tarinai*.js`: creature state, needs, actions, behavior, social life, item effects, update pipeline, rendering.
|
||||
- `js/item*.js`, `js/structures*.js`: item registry/runtime/lifecycle/dynamic behavior/rendering plus structures.
|
||||
- `js/simulation*.js`, `js/system_order.js`, `js/sim_core.js`: concrete update systems and phase ordering.
|
||||
- `js/ui*.js`, `js/text_catalog.js`, `js/command_dispatcher.js`: DOM UI, input, selected panel, logs, charts, family tree, tools, labels.
|
||||
- `js/save*.js`, `js/snapshot_system.js`, `js/restore_coordinator.js`, `js/history_system.js`: persistence and history.
|
||||
- `js/physics*.js`, `js/mechanical_system.js`, `js/constraint_system.js`, `js/collision_footprint_system.js`: geometry, mechanics, constraints, collisions.
|
||||
- `assets/sprites/tarinai_*.webp`: creature state sprites.
|
||||
- `assets/ui/tool_*`: tool palette icons; other `assets/ui/*` are app/help icons.
|
||||
- `assets/objects/*`: field object images.
|
||||
- `assets/sounds/*`: voice and SFX samples.
|
||||
- `scripts/*`: generators, regression checks, and AI inventory.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `*_pipeline` = ordered runner/facade.
|
||||
- `*_step_*` = pipeline stage wrapper.
|
||||
- `*_system` = domain service or compatibility facade.
|
||||
- `*_runtime` = stable runtime-facing facade.
|
||||
- `*_registry` = definition lookup/normalization.
|
||||
- Many split files preserve regression contracts; do not collapse them casually.
|
||||
|
||||
## Checks
|
||||
|
||||
- Syntax: `node --check js/*.js`
|
||||
- Regression: `python scripts/regression_check.py`
|
||||
- Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All`
|
||||
|
||||
|
||||
## 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.
|
||||
- Smartphone manual scroll no longer uses internal vertical scroll traps for event log / family tree content.
|
||||
- Auto-scroll does not open collapsed cards.
|
||||
|
||||
|
||||
## 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.
|
||||
- Family tree nodes show inherited stats in a 2x2 grid and move the tarinai portrait to the upper-right.
|
||||
- Oshibyo lodged position is anchored to the left side of the tarinai sprite.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
## 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.
|
||||
- Shooting impact effects are larger and remain visible for slightly longer.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "39.15.18",
|
||||
"version": "39.15.90",
|
||||
"css": [
|
||||
"css/base.css",
|
||||
"css/layout.css",
|
||||
|
|
@ -13,16 +13,26 @@
|
|||
"js/registry_base.js",
|
||||
"js/disease_registry.js",
|
||||
"js/sound_pack.js",
|
||||
"js/deterministic_helpers.js",
|
||||
"js/item_tool_definitions.js",
|
||||
"js/item_visual_definitions.js",
|
||||
"js/item_food_definitions.js",
|
||||
"js/item_effect_definitions.js",
|
||||
"js/item_registry.js",
|
||||
"js/data.js",
|
||||
"js/ground_types.js",
|
||||
"js/input_mode_manager.js",
|
||||
"js/math.js",
|
||||
"js/physics_helpers.js",
|
||||
"js/collision_footprint_system.js",
|
||||
"js/physics_helpers.js",
|
||||
"js/placement_preview_system.js",
|
||||
"js/pin_attachment_system.js",
|
||||
"js/physics_shape_editor_system.js",
|
||||
"js/mechanical_system.js",
|
||||
"js/mechanical_shape_bridge.js",
|
||||
"js/constraint_system.js",
|
||||
"js/physics_world_system.js",
|
||||
"js/physics_projection_system.js",
|
||||
"js/assets.js",
|
||||
"js/audio.js",
|
||||
"js/perf_profiler.js",
|
||||
|
|
@ -33,7 +43,6 @@
|
|||
"js/items.js",
|
||||
"js/item_type_initializers.js",
|
||||
"js/item_lifecycle_support.js",
|
||||
"js/item_lifecycle_runtime.js",
|
||||
"js/item_update_policy.js",
|
||||
"js/item_dynamic_tool_system.js",
|
||||
"js/item_dynamic_ball_system.js",
|
||||
|
|
@ -63,14 +72,13 @@
|
|||
"js/tarinai_needs_core.js",
|
||||
"js/tarinai_behavior_text.js",
|
||||
"js/tarinai_forced_behavior.js",
|
||||
"js/tarinai_nest_sleep_system.js",
|
||||
"js/tarinai_item_targeting.js",
|
||||
"js/tarinai_consumable_behavior.js",
|
||||
"js/tarinai_social_action_runtime.js",
|
||||
"js/tarinai_building_behavior.js",
|
||||
"js/tarinai_action_definitions.js",
|
||||
"js/tarinai_needs_items.js",
|
||||
"js/tarinai_forced_action_planner_system.js",
|
||||
"js/tarinai_action_planner_system.js",
|
||||
"js/tarinai_need_planner_system.js",
|
||||
"js/tarinai_item_interaction_context.js",
|
||||
"js/tarinai_food_interaction_system.js",
|
||||
|
|
@ -92,6 +100,9 @@
|
|||
"js/world_view.js",
|
||||
"js/family_graph.js",
|
||||
"js/world_family_social.js",
|
||||
"js/impact_core_system.js",
|
||||
"js/impact_response_system.js",
|
||||
"js/collision_response_system.js",
|
||||
"js/world_combat_effects.js",
|
||||
"js/world_environment.js",
|
||||
"js/world_spatial_budget.js",
|
||||
|
|
@ -107,9 +118,9 @@
|
|||
"js/simulation_maintenance_system.js",
|
||||
"js/simulation_ambient_system.js",
|
||||
"js/simulation_systems.js",
|
||||
"js/world_update_phases.js",
|
||||
"js/system_order.js",
|
||||
"js/simulation.js",
|
||||
"js/colony_situation_system.js",
|
||||
"js/world_update.js",
|
||||
"js/world_tool_actions.js",
|
||||
"js/world_placement_log.js",
|
||||
|
|
@ -127,12 +138,12 @@
|
|||
"js/save_codec.js",
|
||||
"js/save_storage.js",
|
||||
"js/save_system.js",
|
||||
"js/patch_helpers.js",
|
||||
"js/ui_tooltips.js",
|
||||
"js/ui_layout_dialogs.js",
|
||||
"js/ui_ground.js",
|
||||
"js/ui_tools.js",
|
||||
"js/ui_input_shared.js",
|
||||
"js/ui_pointer_action_system.js",
|
||||
"js/ui_input_touch.js",
|
||||
"js/ui_input_mouse.js",
|
||||
"js/ui_bind.js",
|
||||
|
|
|
|||
BIN
assets/sounds/major_damage_01.wav
Normal file
BIN
assets/sounds/major_damage_02.wav
Normal file
BIN
assets/sounds/major_damage_03.wav
Normal file
BIN
assets/sounds/major_damage_04.wav
Normal file
BIN
assets/sounds/shoot_bolt_action.mp3
Normal file
BIN
assets/sounds/shoot_pistol.mp3
Normal file
BIN
assets/sounds/voice_008_temperature_buruburu.wav
Normal file
BIN
assets/sounds/voice_009_temperature_achui.wav
Normal file
BIN
assets/sprites/tarinai_34_hot_01.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/sprites/tarinai_35_hot_02.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/sprites/tarinai_36_hot_03.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/sprites/tarinai_37_cold_01.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/sprites/tarinai_38_cold_02.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/sprites/tarinai_39_cold_03.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
BIN
assets/ui/tool_shoot.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
76
css/base.css
|
|
@ -74,25 +74,6 @@ button {
|
|||
line-height: 1.55;
|
||||
min-height: 1.55em;
|
||||
}
|
||||
.loading-bar {
|
||||
height: 12px;
|
||||
margin: 18px 0 8px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(90, 70, 48, 0.12);
|
||||
}
|
||||
.loading-bar-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #9ce84b, #f0d35b);
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
.loading-percent {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: min(1480px, 100vw);
|
||||
|
|
@ -137,13 +118,6 @@ h1 {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(11px, 1.1vw, 13px);
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
|
|
@ -240,9 +214,30 @@ h1 {
|
|||
color: var(--ink);
|
||||
}
|
||||
.field-choice:hover { border-color: rgba(112,178,66,0.45); }
|
||||
.field-choice.active,
|
||||
.field-choice[aria-pressed="true"] {
|
||||
border-color: rgba(112,178,66,0.72);
|
||||
box-shadow: 0 0 0 2px rgba(112,178,66,0.18), inset 0 0 0 1px rgba(255,255,255,0.64);
|
||||
background: linear-gradient(180deg, rgba(248,255,240,0.96), rgba(231,246,210,0.82));
|
||||
}
|
||||
.field-choice strong, .field-choice span { display: block; }
|
||||
.field-choice strong { font-size: 15px; }
|
||||
.field-choice span { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.field-dialog-lead {
|
||||
margin: -4px 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-dialog-section-title {
|
||||
margin: 12px 0 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(75,62,43,0.78);
|
||||
}
|
||||
.reset-dialog-panel { width: min(760px, 100%); }
|
||||
.reset-preset-choice-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.reset-preset-choice-grid .field-choice { min-height: 66px; padding: 7px 5px; }
|
||||
.field-dialog-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
|
|
@ -429,7 +424,6 @@ h1 {
|
|||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool-sprite-watermark { display: none !important; }
|
||||
.tool[data-tool="giant_drug"] .tool-sprite-preview { width: 39px; height: 39px; left: -4px; }
|
||||
.tool[data-tool="dwarf_drug"] .tool-sprite-preview,
|
||||
.tool[data-tool="oshibyo"] .tool-sprite-preview { width: 22px; height: 22px; left: 5px; }
|
||||
|
|
@ -441,6 +435,7 @@ h1 {
|
|||
.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.webp"); }
|
||||
.tool[data-tool="genkotsu"] { --tool-icon: url("../assets/ui/tool_genkotsu.webp"); }
|
||||
.tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.webp"); }
|
||||
.tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; }
|
||||
.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.webp"); }
|
||||
.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.webp"); }
|
||||
.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.webp"); }
|
||||
|
|
@ -550,13 +545,38 @@ h1 {
|
|||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.robot-cleaner-editor-panel { width: min(300px, calc(100vw - 28px)); }
|
||||
.robot-cleaner-editor-help {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin: 0 2px 10px;
|
||||
}
|
||||
.robot-cleaner-targets {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
.robot-cleaner-targets label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.58);
|
||||
border: 1px solid rgba(84,68,48,0.11);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.robot-cleaner-targets input { accent-color: #70b242; }
|
||||
|
||||
/* v16.07.08: right menu fixed quick tabs */
|
||||
.panel-quick-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 8px 4px 9px;
|
||||
margin: -2px 0 2px;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
.tool-tip-popover {
|
||||
position: fixed;
|
||||
z-index: 2147483000;
|
||||
width: 220px;
|
||||
max-width: min(220px, calc(100vw - 16px));
|
||||
width: fit-content;
|
||||
min-width: min(160px, calc(100vw - 18px));
|
||||
max-width: min(620px, calc(100vw - 18px));
|
||||
box-sizing: border-box;
|
||||
padding: 7px 9px;
|
||||
border-radius: 10px;
|
||||
background: rgba(46, 38, 30, 0.78);
|
||||
|
|
@ -15,6 +17,9 @@
|
|||
backdrop-filter: blur(1px);
|
||||
text-shadow: 0 1px 3px rgba(25, 18, 12, 0.55);
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tool-tip-popover.hidden { display: none; }
|
||||
.selected-actions {
|
||||
|
|
@ -241,12 +246,6 @@
|
|||
font-size: 12px;
|
||||
color: rgba(88, 73, 60, 0.82);
|
||||
}
|
||||
.loading-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: rgba(111, 92, 73, 0.78);
|
||||
}
|
||||
|
||||
.collapsible-card {
|
||||
padding-top: 10px;
|
||||
|
|
@ -278,14 +277,14 @@
|
|||
position: relative;
|
||||
}
|
||||
.panel-card-state::before {
|
||||
content: "▾";
|
||||
content: "\25be ";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-52%);
|
||||
color: rgba(98, 82, 64, 0.72);
|
||||
}
|
||||
.collapsible-card.collapsed .panel-card-state::before { content: "▸"; }
|
||||
.collapsible-card.collapsed .panel-card-state::before { content: "\25b8 "; }
|
||||
.collapsible-card.collapsed .panel-card-body { display: none; }
|
||||
.panel-card-body { padding-top: 12px; }
|
||||
|
||||
|
|
@ -419,6 +418,7 @@
|
|||
.log-push-toast.log-push-food::before { background: #e9bd61; }
|
||||
.log-push-toast.log-push-weather::before { background: #7bbbdc; }
|
||||
.log-push-toast.log-push-grass::before { background: #8cc77a; }
|
||||
.log-push-toast.log-push-event::before { background: #8b82d6; }
|
||||
.log-push-toast.leaving {
|
||||
animation: logPushOut .18s ease both;
|
||||
}
|
||||
|
|
@ -684,60 +684,6 @@
|
|||
outline: 2px solid rgba(103,178,44,0.58);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.save-hash-profile {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(115, 91, 62, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.56);
|
||||
color: #4a3929;
|
||||
font-size: 12px;
|
||||
}
|
||||
.save-hash-profile:empty {
|
||||
display: none;
|
||||
}
|
||||
.save-profile-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
.save-profile-summary span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(115, 91, 62, 0.08);
|
||||
}
|
||||
.save-profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.save-profile-grid > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.54);
|
||||
}
|
||||
.save-profile-grid em {
|
||||
opacity: 0.62;
|
||||
font-style: normal;
|
||||
}
|
||||
.save-profile-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 7px;
|
||||
align-items: center;
|
||||
}
|
||||
.save-profile-items span {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(111, 153, 102, 0.13);
|
||||
}
|
||||
|
||||
|
||||
/* v36: pin only the operation tool category under the quick-scroll tabs. */
|
||||
.tool-category[data-tool-category="operate"]:not(.collapsed) {
|
||||
position: sticky;
|
||||
|
|
@ -745,8 +691,8 @@
|
|||
z-index: 11;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(221, 237, 255, 0.98));
|
||||
border-color: rgba(82,128,190,0.36);
|
||||
box-shadow: 0 8px 18px rgba(58, 82, 112, 0.13), inset 0 1px 0 rgba(255,255,255,0.82);
|
||||
border: 3px solid rgba(52,112,188,0.72);
|
||||
box-shadow: 0 9px 22px rgba(58, 82, 112, 0.18), inset 0 0 0 1px rgba(255,255,255,0.86);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-toggle {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98));
|
||||
|
|
@ -756,7 +702,7 @@
|
|||
background: rgba(238, 247, 255, 0.98);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-body {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool {
|
||||
|
|
@ -785,3 +731,49 @@
|
|||
@media (max-width: 980px) {
|
||||
.tool-category[data-tool-category="operate"]:not(.collapsed) { top: 58px; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
body.touch-mobile-ui .tool-tip-popover {
|
||||
max-width: min(280px, calc(100vw - 24px));
|
||||
}
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.log-card.collapsed .panel-card-body { display: none !important; }
|
||||
|
||||
|
||||
.temperature-control {
|
||||
margin: 10px 0 2px;
|
||||
padding: 10px 11px 11px;
|
||||
border: 1px solid rgba(118, 92, 61, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 244, 0.56);
|
||||
}
|
||||
.temperature-control-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(68, 55, 41, 0.84);
|
||||
}
|
||||
.temperature-control-head strong {
|
||||
margin-left: auto;
|
||||
font-size: 15px;
|
||||
}
|
||||
.temperature-auto {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.temperature-slider {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.temperature-slider:disabled {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,19 @@
|
|||
.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="copy"] { --tool-emoji: "⧉"; }
|
||||
.tool[data-tool="area_delete"] { --tool-emoji: "✕"; }
|
||||
.tool[data-tool="rope"] { --tool-emoji: "╰"; }
|
||||
.tool[data-tool="rod"] { --tool-emoji: "━"; }
|
||||
.tool[data-tool="reciprocator"] { --tool-emoji: "⇄"; }
|
||||
.tool[data-tool="undo"] { --tool-emoji: "↶"; }
|
||||
.tool[data-tool="redo"] { --tool-emoji: "↷"; }
|
||||
.tool[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);
|
||||
|
|
@ -41,10 +43,16 @@
|
|||
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; }
|
||||
.tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; }
|
||||
.tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
font-size: 30px;
|
||||
font-size: 31px;
|
||||
line-height: 1;
|
||||
color: #f3a7aa;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.65), 0 2px 5px rgba(146,54,62,0.16);
|
||||
filter: none;
|
||||
transform: translateY(-54%) scale(var(--tool-icon-zoom, 1));
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
top: 42% !important;
|
||||
transform: translate(-50%, -50%) scale(var(--tool-icon-zoom, 1)) !important;
|
||||
}
|
||||
|
||||
.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; }
|
||||
|
|
@ -66,8 +74,6 @@
|
|||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.tool-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.tool-head h2 { margin-bottom: 0; }
|
||||
.btn.mini { padding: 6px 10px; font-size: 12px; }
|
||||
.tool-body { margin-top: 10px; }
|
||||
.tool-palette { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
|
@ -160,14 +166,6 @@
|
|||
.stat span { display: block; font-size: 11px; color: var(--muted); }
|
||||
.stat strong { font-size: 18px; }
|
||||
|
||||
.meter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
meter {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
|
|
@ -315,6 +313,7 @@ meter {
|
|||
.log-weather { border-left-color: rgba(92, 142, 210, 0.62); }
|
||||
.log-relation { border-left-color: rgba(204, 126, 176, 0.62); }
|
||||
.log-observe { border-left-color: rgba(175, 146, 86, 0.62); }
|
||||
.log-event { border-left-color: rgba(132, 120, 210, 0.62); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
#app { padding: 10px; }
|
||||
|
|
@ -326,21 +325,12 @@ meter {
|
|||
#gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); }
|
||||
.stage-wrap { min-height: var(--field-mobile-min-height, 460px); }
|
||||
.field-choice-grid { grid-template-columns: 1fr; }
|
||||
.reset-preset-choice-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.tree-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.54);
|
||||
border: 1px solid rgba(84,68,48,0.10);
|
||||
}
|
||||
|
||||
|
||||
/* Clearer tool category grouping. */
|
||||
|
|
@ -355,4 +345,3 @@ meter {
|
|||
@media (min-width: 921px) {
|
||||
.mobile-mode-controls { display: none !important; }
|
||||
}
|
||||
|
||||
|
|
|
|||
595
css/mobile.css
|
|
@ -1,40 +1,3 @@
|
|||
.sound-category-controls,
|
||||
.log-push-controls {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(84,68,48,.12);
|
||||
background: rgba(255,255,255,.46);
|
||||
font-size: 12px;
|
||||
}
|
||||
.control-group-title { font-weight: 700; margin-bottom: 7px; color: rgba(58,48,39,.88); }
|
||||
.sound-category-controls { display: grid; grid-template-columns: 1fr; gap: 6px; }
|
||||
.sound-category-controls label,
|
||||
.push-master,
|
||||
.push-kind-grid label { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
|
||||
.sound-category-controls input,
|
||||
.push-master input,
|
||||
.push-kind-grid input { accent-color: #82bf66; }
|
||||
.log-push-controls { display: flex; flex-direction: column; gap: 8px; }
|
||||
.push-kind-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px 8px; }
|
||||
.log-push-overlay { position: fixed; left: 18px; bottom: 18px; z-index: 9998; width: min(320px, calc(100vw - 36px)); display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||
.log-push-toast { position: relative; overflow: hidden; padding: 10px 12px 10px 17px; border-radius: 15px; background: rgba(255,252,246,.94); border: 1px solid rgba(84,68,48,.16); box-shadow: 0 12px 32px rgba(44,34,25,.18); animation: logPushIn .18s ease-out; }
|
||||
.log-push-toast::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 5px; background: #a5c77a; }
|
||||
.log-push-toast .log-push-tag { display: inline-block; margin-right: 6px; font-size: 11px; font-weight: 700; color: rgba(54,45,38,.88); }
|
||||
.log-push-toast .log-push-time { font-size: 10px; color: rgba(104,88,72,.72); }
|
||||
.log-push-toast .log-push-text { margin-top: 3px; font-size: 12px; line-height: 1.35; color: rgba(48,40,33,.92); }
|
||||
.log-push-toast.log-push-death::before { background: #8f817a; }
|
||||
.log-push-toast.log-push-accident::before { background: #ee9e5d; }
|
||||
.log-push-toast.log-push-birth::before { background: #98cf7a; }
|
||||
.log-push-toast.log-push-fight::before { background: #e47b80; }
|
||||
.log-push-toast.log-push-relation::before { background: #b99be0; }
|
||||
.log-push-toast.log-push-food::before { background: #e9bd61; }
|
||||
.log-push-toast.log-push-weather::before { background: #7bbbdc; }
|
||||
.log-push-toast.log-push-grass::before { background: #8cc77a; }
|
||||
.log-push-toast.leaving { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; }
|
||||
@keyframes logPushIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
|
||||
/* 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); }
|
||||
|
|
@ -59,7 +22,7 @@
|
|||
.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: 760px) { .sound-panel { right: auto; left: 0; } }
|
||||
@media (max-width: 980px) { .sound-panel { right: auto; left: 0; } }
|
||||
|
||||
|
||||
/* Collapsed card sizing is shared by the base panel rules; no event-only shrink override. */
|
||||
|
|
@ -70,9 +33,6 @@
|
|||
}
|
||||
|
||||
/* v15.12.4: sound settings live only in the top-right sound menu */
|
||||
.log-card .sound-category-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* v15.17.0: mobile layout isolation and canvas touch gestures */
|
||||
#app { overflow-x: clip; }
|
||||
|
|
@ -164,9 +124,48 @@
|
|||
}
|
||||
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) { display: flex; }
|
||||
body:not(.touch-mobile-ui) .mobile-mode-controls { display: none !important; }
|
||||
|
||||
.mobile-rotate-controls {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 58px;
|
||||
z-index: 24;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 245, 0.92);
|
||||
box-shadow: 0 8px 22px rgba(80,60,35,.18);
|
||||
border: 1px solid rgba(120, 95, 60, .16);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.mobile-rotate-controls button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #65533f;
|
||||
background: rgba(255,255,255,.82);
|
||||
box-shadow: inset 0 -1px 0 rgba(0,0,0,.05);
|
||||
}
|
||||
.mobile-rotate-controls button:active { transform: translateY(1px); }
|
||||
body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) { display: flex; }
|
||||
body:not(.touch-mobile-ui) .mobile-rotate-controls { display: none !important; }
|
||||
@media (max-width: 980px) {
|
||||
body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 56px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 12002;
|
||||
max-width: calc(100vw - 22px);
|
||||
}
|
||||
}
|
||||
|
||||
body.touch-mobile-ui.mobile-mode-camera #gameCanvas { cursor: grab; }
|
||||
body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
|
||||
body.touch-mobile-ui.mobile-mode-family .lineage-main-card { z-index: 9; }
|
||||
|
||||
|
||||
/* Keep panel themes on narrow layouts. */
|
||||
|
|
@ -174,3 +173,515 @@ body.touch-mobile-ui.mobile-mode-family .lineage-main-card { z-index: 9; }
|
|||
.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);
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 68px);
|
||||
z-index: 12000;
|
||||
border: 1px solid rgba(103,178,44,.38);
|
||||
border-radius: 999px;
|
||||
padding: 9px 12px;
|
||||
background: rgba(246, 255, 236, 0.94);
|
||||
color: #3f5f2b;
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 10px 25px rgba(54, 76, 40, .20);
|
||||
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 {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#app {
|
||||
width: min(1480px, 100vw);
|
||||
padding: 18px;
|
||||
overflow: visible;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(680px, 1fr) minmax(320px, 390px);
|
||||
align-items: start;
|
||||
}
|
||||
.panel {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.panel-quick-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
.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);
|
||||
min-height: 520px;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
overscroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 10px max(10px, env(safe-area-inset-right, 0px)) calc(env(safe-area-inset-bottom, 0px) + 104px) max(10px, env(safe-area-inset-left, 0px));
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.topbar,
|
||||
.top-left,
|
||||
.brand,
|
||||
.layout,
|
||||
.stage-wrap,
|
||||
.panel,
|
||||
.card,
|
||||
.lineage-main-card {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.brand h1,
|
||||
h1 {
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
font-size: clamp(20px, 7vw, 30px);
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 1px 0 5px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.top-actions > .btn,
|
||||
.top-actions > .sound-menu,
|
||||
.top-actions .sound-menu-btn {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-actions .btn,
|
||||
.top-actions .sound-menu-btn {
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
overflow: visible;
|
||||
}
|
||||
.stage-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-height: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
isolation: auto;
|
||||
}
|
||||
body.touch-mobile-ui .panel-quick-tabs,
|
||||
.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;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
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);
|
||||
}
|
||||
.panel-quick-tabs button {
|
||||
min-height: 34px;
|
||||
padding: 5px 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) {
|
||||
display: flex !important;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: 10px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 24;
|
||||
max-width: calc(100% - 18px);
|
||||
}
|
||||
body.touch-mobile-ui.mobile-mode-auto #gameCanvas {
|
||||
touch-action: pan-y pinch-zoom !important;
|
||||
}
|
||||
body.touch-mobile-ui.mobile-mode-camera #gameCanvas,
|
||||
body.touch-mobile-ui.mobile-mode-tool #gameCanvas {
|
||||
touch-action: none !important;
|
||||
}
|
||||
.log {
|
||||
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;
|
||||
contain: none !important;
|
||||
clear: both;
|
||||
}
|
||||
.lineage-main-content {
|
||||
display: block !important;
|
||||
height: auto !important;
|
||||
min-height: 360px;
|
||||
max-height: none !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: visible !important;
|
||||
overscroll-behavior-y: auto !important;
|
||||
-webkit-overflow-scrolling: auto;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
.lineage-family-tree {
|
||||
max-width: 100%;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: visible !important;
|
||||
overscroll-behavior-x: contain;
|
||||
overscroll-behavior-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y pinch-zoom;
|
||||
}
|
||||
.lineage-family-tree .lineage-tree-panel {
|
||||
max-width: none;
|
||||
}
|
||||
.selected-card,
|
||||
.tool-card,
|
||||
.colony-card,
|
||||
.log-card,
|
||||
.lineage-main-card,
|
||||
.stage-wrap {
|
||||
scroll-margin-top: 0 !important;
|
||||
scroll-margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 104px);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
362
css/panel.css
|
|
@ -1,9 +1,4 @@
|
|||
/* CSS split file; preserve manifest load order. */
|
||||
.lineage-tree {
|
||||
overflow: auto;
|
||||
padding: 8px 10px 18px;
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.lineage-main-card {
|
||||
margin-top: 16px;
|
||||
|
|
@ -24,6 +19,14 @@
|
|||
}
|
||||
|
||||
.lineage-head h2 { margin-bottom: 0; }
|
||||
.lineage-title-note {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(126, 86, 52, 0.78);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.lineage-main-content {
|
||||
height: min(78vh, 980px);
|
||||
|
|
@ -44,11 +47,6 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
.lineage-main-content .lineage-tree {
|
||||
min-width: 360px;
|
||||
overflow: visible;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
|
||||
body::before {
|
||||
|
|
@ -119,6 +117,56 @@ body::before {
|
|||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.family-genetic-stats {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px 5px;
|
||||
margin-top: 2px;
|
||||
font-size: 9.7px;
|
||||
line-height: 1.13;
|
||||
color: rgba(88, 70, 48, 0.86);
|
||||
letter-spacing: -0.02em;
|
||||
white-space: normal;
|
||||
max-width: 82px;
|
||||
}
|
||||
.family-genetic-stats span {
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.family-genetic-stats b,
|
||||
.family-fight-stat b {
|
||||
font-weight: 800;
|
||||
color: rgba(120, 86, 30, 0.92);
|
||||
}
|
||||
.family-fight-stat {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
max-width: 82px;
|
||||
font-size: 9.7px;
|
||||
line-height: 1.13;
|
||||
color: rgba(88, 70, 48, 0.82);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lineage-family-tree .tree-node.champion {
|
||||
border-color: rgba(202, 146, 33, 0.58);
|
||||
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: "♛";
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 4px;
|
||||
z-index: 2;
|
||||
font-size: 14px;
|
||||
color: rgba(158, 102, 20, 0.95);
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.70);
|
||||
}
|
||||
|
||||
.family-node.alive .family-portrait { opacity: 0.80; }
|
||||
.family-node.dead .family-portrait { opacity: 0.20; }
|
||||
.family-portrait img {
|
||||
|
|
@ -134,141 +182,44 @@ body::before {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.family-copy .death-reason {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.12;
|
||||
margin-top: 1px;
|
||||
color: rgba(126, 80, 64, 0.92);
|
||||
}
|
||||
.family-personality-tags {
|
||||
display: flex !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
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;
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
}
|
||||
.family-personality-tags em {
|
||||
display: inline-block;
|
||||
max-width: none;
|
||||
padding: 1px 4px;
|
||||
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: 7.5px;
|
||||
font-size: 8.2px;
|
||||
font-style: normal;
|
||||
line-height: 1.25;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.family-copy strong { font-size: 11px; }
|
||||
.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; }
|
||||
|
||||
|
||||
.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; }
|
||||
.colony-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.colony-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 188px;
|
||||
min-height: 188px;
|
||||
border: 1px solid rgba(84, 68, 48, 0.14);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54));
|
||||
line-height: 1.18;
|
||||
overflow: hidden;
|
||||
}
|
||||
.colony-card h2 { line-height: 1.45; margin-bottom: 10px; }
|
||||
.colony-chart svg { display: block; width: 100%; height: 100%; }
|
||||
.colony-chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
align-items: center;
|
||||
margin: 4px 0 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.colony-chart-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.colony-chart-legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10);
|
||||
}
|
||||
|
||||
.relation-row strong { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||
.relation-face {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.66);
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
vertical-align: middle;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.lineage-family {
|
||||
padding: 10px;
|
||||
margin: 0 0 12px;
|
||||
border-radius: 15px;
|
||||
background: rgba(255,253,248,0.62);
|
||||
border: 1px solid rgba(84,68,48,0.12);
|
||||
}
|
||||
.lineage-family h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: rgba(64,52,38,0.86);
|
||||
}
|
||||
/* Family archive: actual top-down tree diagram. */
|
||||
.lineage-family-tree {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
padding: 10px 10px 22px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
.lineage-family-tree h3 {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: max-content;
|
||||
padding-right: 12px;
|
||||
background: rgba(255,253,248,0.82);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.lineage-tree-hint {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin: -2px 0 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 9px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
background: rgba(255,255,255,0.62);
|
||||
border: 1px solid rgba(84,68,48,0.08);
|
||||
}
|
||||
.tool[data-tip] { position: relative; }
|
||||
|
||||
|
||||
|
||||
.lineage-family-tree .lineage-tree-panel {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
|
@ -292,7 +243,6 @@ body::before {
|
|||
z-index: 1;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.lineage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -305,7 +255,6 @@ body::before {
|
|||
border-color: rgba(103,178,44,0.46);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lineage-render-status {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -355,9 +304,9 @@ body::before {
|
|||
}
|
||||
.lineage-family-tree .tree-node {
|
||||
position: absolute;
|
||||
width: 132px;
|
||||
height: 84px;
|
||||
min-height: 84px;
|
||||
width: 150px;
|
||||
height: 104px;
|
||||
min-height: 104px;
|
||||
box-sizing: border-box;
|
||||
align-items: flex-start;
|
||||
background: linear-gradient(180deg, #fffdf8, #f8f1e5);
|
||||
|
|
@ -377,10 +326,11 @@ body::before {
|
|||
transform: none;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-portrait {
|
||||
right: 0;
|
||||
bottom: -6px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
bottom: auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.lineage-family-tree .tree-generation-label {
|
||||
position: absolute;
|
||||
|
|
@ -388,7 +338,7 @@ body::before {
|
|||
width: 54px;
|
||||
padding: 3px 0;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.70);
|
||||
|
|
@ -403,13 +353,23 @@ body::before {
|
|||
border-radius: 9px;
|
||||
background: rgba(255,255,255,0.70);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ecology-grid { grid-template-columns: 1fr; }
|
||||
.ecology-card { grid-template-columns: 68px minmax(0, 1fr); }
|
||||
.ecology-card img { width: 68px; height: 68px; }
|
||||
.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;
|
||||
|
|
@ -418,5 +378,113 @@ body::before {
|
|||
}
|
||||
}
|
||||
|
||||
/* Restored from the original ZIP: fixed colony graph/legend sizing. */
|
||||
.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; }
|
||||
.colony-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.colony-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 188px;
|
||||
min-height: 188px;
|
||||
border: 1px solid rgba(84, 68, 48, 0.14);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54));
|
||||
overflow: hidden;
|
||||
}
|
||||
.colony-card h2 { line-height: 1.45; margin-bottom: 10px; }
|
||||
.colony-chart svg { display: block; width: 100%; height: 100%; }
|
||||
.colony-chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
align-items: center;
|
||||
margin: 4px 0 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.colony-chart-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.colony-chart-legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10);
|
||||
}
|
||||
|
||||
.tool[data-tip] { position: relative; }
|
||||
|
||||
/* 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: 86px !important;
|
||||
max-width: 86px !important;
|
||||
height: 28px !important;
|
||||
max-height: 28px !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;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
height: 13px !important;
|
||||
padding: 1px 2px !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
line-height: 11px !important;
|
||||
font-size: 7.2px !important;
|
||||
letter-spacing: -0.08em !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
|
|
|||
386
index.html
|
|
@ -3,20 +3,20 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>たりない観察</title>
|
||||
<title>たりない観察</title>
|
||||
<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.18" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=39.15.18" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=39.15.18" />
|
||||
<link rel="stylesheet" href="css/components.css?v=39.15.18" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=39.15.18" />
|
||||
<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">
|
||||
<div class="loading-panel">
|
||||
<div class="loading-kicker">たりない観察</div>
|
||||
<div class="loading-kicker">たりない観察</div>
|
||||
<h1 class="loading-title">読み込み中…</h1>
|
||||
<p id="loadingText" class="loading-text">準備しています。</p>
|
||||
<div id="loadingSpriteStrip" class="loading-sprite-strip" aria-hidden="true"></div>
|
||||
|
|
@ -27,21 +27,21 @@
|
|||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<a class="btn room-link" href="https://host.nishi.boats/~333/">333の部屋に移動</a>
|
||||
<a class="btn room-link" href="https://2012r2.nishi.boats/~333/">333の部屋に移動</a>
|
||||
<div class="brand">
|
||||
<h1>たりない観察</h1>
|
||||
<h1>たりない観察</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="pauseBtn" class="btn primary">一時停止</button>
|
||||
<button id="speedBtn" class="btn">速度 x1</button>
|
||||
<button id="ecologyBtn" class="btn">たりないの生態</button>
|
||||
<button id="ecologyBtn" class="btn">たりないの生態</button>
|
||||
<div id="soundMenu" class="sound-menu">
|
||||
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音設定 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="効果音設定">
|
||||
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="音">
|
||||
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>個体の声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>たりないの声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
|
||||
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
|
||||
</div>
|
||||
|
|
@ -54,29 +54,34 @@
|
|||
|
||||
<main class="layout">
|
||||
<section class="stage-wrap">
|
||||
<canvas id="gameCanvas" aria-label="たりない観察キャンバス"></canvas>
|
||||
<canvas id="gameCanvas" aria-label="たりない観察キャンバス"></canvas>
|
||||
<div id="mobileModeControls" class="mobile-mode-controls" aria-label="スマホ操作モード">
|
||||
<button type="button" data-mobile-mode="auto" class="active">自動</button>
|
||||
<button type="button" data-mobile-mode="camera">カメラ</button>
|
||||
<button type="button" data-mobile-mode="tool">道具</button>
|
||||
<button type="button" data-mobile-mode="family">家系図</button>
|
||||
</div>
|
||||
<div id="mobileRotateControls" class="mobile-rotate-controls" hidden aria-label="物理アイテムの回転">
|
||||
<button type="button" data-mobile-rotate="left">左回転</button>
|
||||
<button type="button" data-mobile-rotate="right">右回転</button>
|
||||
</div>
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<nav id="panelQuickTabs" class="panel-quick-tabs" aria-label="右メニューのタブ">
|
||||
<button type="button" data-panel-tab="selected">たりないデータ</button>
|
||||
<button type="button" data-panel-tab="tools">道具</button>
|
||||
<button type="button" data-panel-tab="colony">コロニー</button>
|
||||
<button type="button" data-panel-tab="events">イベント</button>
|
||||
<nav id="panelQuickTabs" class="panel-quick-tabs" aria-label="右メニューのタブ">
|
||||
<button type="button" data-panel-tab="game">本体</button>
|
||||
<button type="button" data-panel-tab="selected">たりないデータ</button>
|
||||
<button type="button" data-panel-tab="tools">道具</button>
|
||||
<button type="button" data-panel-tab="colony">コロニー</button>
|
||||
<button type="button" data-panel-tab="events">イベント</button>
|
||||
<button type="button" data-panel-tab="family">家系図</button>
|
||||
</nav>
|
||||
<section id="selectedCard" class="card selected-card hidden">
|
||||
<div class="selected-head">
|
||||
<h2>たりないデータ</h2>
|
||||
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="観察をやめる">×</button>
|
||||
<h2>たりないデータ</h2>
|
||||
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="たりないデータを閉じる">×</button>
|
||||
</div>
|
||||
<div id="selectedInfo" class="selected-info empty">未選択</div>
|
||||
<div id="selectedInfo" class="selected-info empty">たりないを選択してください</div>
|
||||
</section>
|
||||
|
||||
<section id="toolCard" class="card tool-card collapsible-card" data-card-key="tools">
|
||||
|
|
@ -92,12 +97,16 @@
|
|||
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">コロニー</span><span class="panel-card-state">折り畳み</span></button>
|
||||
<div class="panel-card-body">
|
||||
<div class="stats colony-stats">
|
||||
<div class="stat"><span>個体</span><strong id="statPop">0</strong></div>
|
||||
<div class="stat"><span>死亡</span><strong id="statDead">0</strong></div>
|
||||
<div class="stat"><span>状態</span><strong id="statColonyMood">安定</strong></div>
|
||||
<div class="stat ground-stat" data-ground-toggle role="button" tabindex="0"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="地面をきりかえる"><strong id="statGroundType">砂</strong></button></div>
|
||||
<div class="stat"><span>個体</span><strong id="statPop">0</strong></div>
|
||||
<div class="stat"><span>死亡</span><strong id="statDead">0</strong></div>
|
||||
<div class="stat"><span>状態</span><strong id="statColonyMood">安定</strong></div>
|
||||
<div class="stat ground-stat" data-ground-toggle role="button" tabindex="0"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="地面をきりかえる"><strong id="statGroundType">砂</strong></button></div>
|
||||
</div>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">個体数</button><button data-chart="life">欲求</button><button data-chart="objects">物</button></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">個体数</button><button data-chart="life">欲求</button><button data-chart="objects">物</button></div>
|
||||
<div id="colonyChartLegend" class="colony-chart-legend"></div>
|
||||
<div id="colonyChart" class="colony-chart" aria-label="コロニーの推移グラフ" role="img"></div>
|
||||
</div>
|
||||
|
|
@ -117,6 +126,7 @@
|
|||
<label><input type="checkbox" data-push-kind="food"> 食事</label>
|
||||
<label><input type="checkbox" data-push-kind="weather"> 天気</label>
|
||||
<label><input type="checkbox" data-push-kind="grass"> 草</label>
|
||||
<label><input type="checkbox" data-push-kind="event"> 出来事</label>
|
||||
<label><input type="checkbox" data-push-kind="relation"> 関係</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -125,9 +135,11 @@
|
|||
</aside>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
<section class="card lineage-main-card">
|
||||
<div class="lineage-head">
|
||||
<h2>家系図</h2>
|
||||
<h2>家系図<span class="lineage-title-note">重い場合があるので注意</span></h2>
|
||||
<div class="lineage-actions">
|
||||
<button id="archiveUpdateToggleBtn" class="btn" type="button" aria-pressed="false">更新 OFF</button>
|
||||
<button id="archiveResetBtn" class="btn danger" type="button">家系図をリセット</button>
|
||||
|
|
@ -135,18 +147,35 @@
|
|||
</div>
|
||||
<div id="archiveContent" class="archive-content lineage-main-content"></div>
|
||||
</section>
|
||||
|
||||
|
||||
<button id="backToGameBtn" class="back-to-game-btn" type="button" hidden aria-label="ゲーム本体へ戻る">▲ 本体</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="fieldDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="fieldDialogTitle">
|
||||
<div class="field-dialog-panel">
|
||||
<h2 id="fieldDialogTitle">フィールドを選択</h2>
|
||||
<div class="field-choice-grid">
|
||||
<button class="field-choice" data-field-type="park" type="button"><strong>公園</strong><span>大</span></button>
|
||||
<button class="field-choice" data-field-type="garden" type="button"><strong>庭</strong><span>中</span></button>
|
||||
<button class="field-choice" data-field-type="cage" type="button"><strong>ケージ</strong><span>小</span></button>
|
||||
<div class="field-dialog-panel reset-dialog-panel">
|
||||
<h2 id="fieldDialogTitle">リセット設定</h2>
|
||||
<p class="field-dialog-lead">サイズとプリセットを組み合わせて観察環境を生成します。</p>
|
||||
<div class="field-dialog-section-title">サイズ</div>
|
||||
<div class="field-choice-grid reset-size-choice-grid">
|
||||
<button class="field-choice" data-field-type="park" type="button" aria-pressed="false"><strong>公園</strong><span>大</span></button>
|
||||
<button class="field-choice" data-field-type="garden" type="button" aria-pressed="true"><strong>庭</strong><span>中</span></button>
|
||||
<button class="field-choice" data-field-type="cage" type="button" aria-pressed="false"><strong>ケージ</strong><span>小</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-section-title">プリセット</div>
|
||||
<div class="field-choice-grid reset-preset-choice-grid">
|
||||
<button class="field-choice preset-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>空白</strong><span>何も無い</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>デフォルト</strong><span>初期生成</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>アスレチック</strong><span>柵とボール</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="crowded" type="button" aria-pressed="false"><strong>過密</strong><span>大量とずんだ</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>家族</strong><span>2匹とへこ餅</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>戦争</strong><span>けんかと柵</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>幸福</strong><span>掃除機と巣箱</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="fieldCancelBtn" class="btn" type="button">キャンセル</button>
|
||||
<button id="fieldConfirmBtn" class="btn danger" type="button">再生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -158,7 +187,7 @@
|
|||
<h2 id="creditsDialogTitle">クレジット</h2>
|
||||
<div class="credits-content">
|
||||
<p><strong>製作:</strong> 333</p>
|
||||
<p><strong>イラスト:</strong> むらさき氏 / いらすとや: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
|
||||
<p><strong>イラスト:</strong> むらさき氏 / いらすとや: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
|
||||
<p><strong>効果音:</strong> VOICEVOX: ずんだもん 効果音ラボ: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
|
||||
</div>
|
||||
<div class="field-dialog-actions credits-actions">
|
||||
|
|
@ -180,7 +209,7 @@
|
|||
|
||||
<div id="ecologyDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="ecologyDialogTitle">
|
||||
<div class="field-dialog-panel ecology-dialog-panel">
|
||||
<h2 id="ecologyDialogTitle">たりないの生態</h2>
|
||||
<h2 id="ecologyDialogTitle">たりないの生態</h2>
|
||||
<div id="ecologyGrid" class="ecology-grid"></div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="ecologyCloseBtn" class="btn" type="button">閉じる</button>
|
||||
|
|
@ -189,141 +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.18" defer></script>
|
||||
<script src="js/event_bus.js?v=39.15.18" defer></script>
|
||||
<script src="js/registry_base.js?v=39.15.18" defer></script>
|
||||
<script src="js/disease_registry.js?v=39.15.18" defer></script>
|
||||
<script src="js/sound_pack.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_registry.js?v=39.15.18" defer></script>
|
||||
<script src="js/data.js?v=39.15.18" defer></script>
|
||||
<script src="js/ground_types.js?v=39.15.18" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=39.15.18" defer></script>
|
||||
<script src="js/math.js?v=39.15.18" defer></script>
|
||||
<script src="js/physics_helpers.js?v=39.15.18" defer></script>
|
||||
<script src="js/collision_footprint_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/mechanical_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/constraint_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/physics_world_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/assets.js?v=39.15.18" defer></script>
|
||||
<script src="js/audio.js?v=39.15.18" defer></script>
|
||||
<script src="js/perf_profiler.js?v=39.15.18" defer></script>
|
||||
<script src="js/render.js?v=39.15.18" defer></script>
|
||||
<script src="js/sim_core.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=39.15.18" defer></script>
|
||||
<script src="js/structures.js?v=39.15.18" defer></script>
|
||||
<script src="js/items.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_runtime.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_update_policy.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_runtime.js?v=39.15.18" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=39.15.18" defer></script>
|
||||
<script src="js/ants.js?v=39.15.18" defer></script>
|
||||
<script src="js/health.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_forced_action_planner_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_action_planner_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_render.js?v=39.15.18" defer></script>
|
||||
<script src="js/world.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_view.js?v=39.15.18" defer></script>
|
||||
<script src="js/family_graph.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_family_social.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_environment.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_ants_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/weather_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=39.15.18" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation_systems.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_update_phases.js?v=39.15.18" defer></script>
|
||||
<script src="js/system_order.js?v=39.15.18" defer></script>
|
||||
<script src="js/simulation.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_update.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_placement_log.js?v=39.15.18" defer></script>
|
||||
<script src="js/world_event_effects.js?v=39.15.18" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=39.15.18" defer></script>
|
||||
<script src="js/text_catalog.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_log.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_helpers.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_selected.js?v=39.15.18" defer></script>
|
||||
<script src="js/save_schema.js?v=39.15.18" defer></script>
|
||||
<script src="js/snapshot_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=39.15.18" defer></script>
|
||||
<script src="js/history_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/save_codec.js?v=39.15.18" defer></script>
|
||||
<script src="js/save_storage.js?v=39.15.18" defer></script>
|
||||
<script src="js/save_system.js?v=39.15.18" defer></script>
|
||||
<script src="js/patch_helpers.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_ground.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_tools.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_bind.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_charts.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_family_data.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_family_async.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=39.15.18" defer></script>
|
||||
<script src="js/ui_family_render.js?v=39.15.18" defer></script>
|
||||
<script src="js/main.js?v=39.15.18" defer></script>
|
||||
<script src="js/debug_tools.js?v=39.15.18" 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>
|
||||
|
|
|
|||
25
js/ants.js
|
|
@ -13,14 +13,6 @@ const ANT_WORKER_RETURN_WEAK_SPEED = 9.0;
|
|||
const ANT_WORKER_DRAG_SPEED = 6.1875;
|
||||
const ANT_QUEEN_SPEED = 13.5;
|
||||
|
||||
const ANT_EXTENSION_HOOKS = Object.freeze({
|
||||
nestHunger: "reserved",
|
||||
nestGrowth: "reserved",
|
||||
queenReproduction: "existing-queen-founding",
|
||||
workerLifecycle: "worker-pool-hp",
|
||||
carryingSuccessRate: "current-deterministic-haul",
|
||||
territorialBehavior: "reserved",
|
||||
});
|
||||
|
||||
function clampAntHp(v) {
|
||||
return clamp(Number(v) || 0, 0, ANT_WORKER_HP);
|
||||
|
|
@ -84,7 +76,6 @@ class AntActor {
|
|||
this.seed = opts.seed || Math.random() * 1000;
|
||||
this.wanderAngle = opts.wanderAngle || rand(0, Math.PI * 2);
|
||||
this.nextTurn = opts.nextTurn || rand(0.4, 1.6);
|
||||
this.returnTimer = opts.returnTimer || 0;
|
||||
this.foundingX = opts.foundingX;
|
||||
this.foundingY = opts.foundingY;
|
||||
this.foundingDelayUntil = Number.isFinite(opts.foundingDelayUntil) ? opts.foundingDelayUntil : -Infinity;
|
||||
|
|
@ -292,7 +283,6 @@ class AntActor {
|
|||
target.target = null;
|
||||
if (targetWasSleeping) {
|
||||
target.startSleeping?.(null, "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b");
|
||||
target.sleepTimer = Math.max(target.sleepTimer || 0, 0.8);
|
||||
} else {
|
||||
if (target.enterPanic) target.enterPanic({ threat: this, reason: "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b", fear: 0.36, wake: true, cause: "ant_drag" });
|
||||
else { target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(this) : null, reason: "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b", wake: true }); target.fearTimer = Math.max(target.fearTimer || 0, 0.36); }
|
||||
|
|
@ -425,10 +415,11 @@ class AntActor {
|
|||
return this.kind === "queen" ? "ant_queen" : this.workerSpriteId();
|
||||
}
|
||||
|
||||
draw(ctx, time = 0, lighting = null) {
|
||||
draw(ctx, ...args) {
|
||||
if (this.dead) return;
|
||||
const id = this.spriteId();
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage(id, id) : images.get(id);
|
||||
const img = getRenderableImage(id, id);
|
||||
const lighting = args.find(value => value && typeof value === "object") || null;
|
||||
const lightState = lighting || getLightingState(this.world);
|
||||
ctx.save();
|
||||
const shadow = projectedShadowParams(lightState, this.kind === "queen" ? 0.78 : 0.26);
|
||||
|
|
@ -479,13 +470,3 @@ class AntActor {
|
|||
}
|
||||
}
|
||||
|
||||
window.AntActor = AntActor;
|
||||
window.ANT_NEST_START_COUNT = ANT_NEST_START_COUNT;
|
||||
window.ANT_NEST_MAX_COUNT = ANT_NEST_MAX_COUNT;
|
||||
window.ANT_NEST_MAX_OUTSIDE = ANT_NEST_MAX_OUTSIDE;
|
||||
window.ANT_WORKER_HP = ANT_WORKER_HP;
|
||||
window.ANT_QUEEN_HP = ANT_QUEEN_HP;
|
||||
window.ANT_EXTENSION_HOOKS = ANT_EXTENSION_HOOKS;
|
||||
window.buildAntWorkerPool = buildAntWorkerPool;
|
||||
window.ensureAntNestWorkerPool = ensureAntNestWorkerPool;
|
||||
window.syncAntNestCount = syncAntNestCount;
|
||||
|
|
|
|||
96
js/assets.js
|
|
@ -1,20 +1,80 @@
|
|||
"use strict";
|
||||
const images = new Map();
|
||||
const imageBoundsCache = new WeakMap();
|
||||
const imageMetrics = new Map();
|
||||
const imagePromises = new Map();
|
||||
const imageAssetIndex = new Map();
|
||||
|
||||
const DECOR_ASSETS = [
|
||||
{ id: "zunchi", path: "assets/objects/zunchi.webp" },
|
||||
{ id: "zunchi_02", path: "assets/objects/zunchi_02.webp" },
|
||||
{ id: "ant_queen", path: "assets/objects/ant_queen.webp" },
|
||||
{ id: "ant_worker", path: "assets/objects/ant_worker.webp" },
|
||||
{ id: "genkotsu", path: "assets/objects/genkotsu.webp" },
|
||||
{ id: "pushpin", path: "assets/objects/pushpin.webp" },
|
||||
{ id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" },
|
||||
{ id: "oshibyo", path: "assets/objects/oshibyo.webp" },
|
||||
{ id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" },
|
||||
{ id: "plushie_bear", path: "assets/objects/plushie_bear.png" },
|
||||
];
|
||||
|
||||
const ALPHA_BOUNDS = {
|
||||
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 },
|
||||
angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
|
||||
teary: { x: 17, y: 17, w: 478, h: 438, imageW: 512, imageH: 472 },
|
||||
jito: { x: 18, y: 18, w: 476, h: 451, imageW: 512, imageH: 487 },
|
||||
drool: { x: 18, y: 18, w: 476, h: 425, imageW: 512, imageH: 461 },
|
||||
cry: { x: 15, y: 15, w: 482, h: 448, imageW: 512, imageH: 478 },
|
||||
sleep: { x: 18, y: 18, w: 475, h: 439, imageW: 511, imageH: 475 },
|
||||
pokan: { x: 18, y: 18, w: 476, h: 433, imageW: 512, imageH: 469 },
|
||||
stretch: { x: 15, y: 15, w: 482, h: 354, imageW: 512, imageH: 384 },
|
||||
hurt: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
weak: { x: 53, y: 18, w: 405, h: 435, imageW: 512, imageH: 468 },
|
||||
back: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
flee: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
zunda_eat: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
hurt2: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
normal_smirk: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
normal_tongue: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
fear: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
flee_fear2: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
sleep2: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
normal_happy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
fear_blue: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
fear_cry: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 },
|
||||
zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 },
|
||||
hungry_70: { x: 0, y: 0, w: 430, h: 366, imageW: 430, imageH: 366 },
|
||||
zunchi_slave_alt: { x: 42, y: 22, w: 425, h: 426, imageW: 512, imageH: 468 },
|
||||
zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 },
|
||||
zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 },
|
||||
ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 },
|
||||
ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 },
|
||||
genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 },
|
||||
sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
|
||||
sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 },
|
||||
sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
|
||||
hot_1: { x: 16, y: 20, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
hot_2: { x: 16, y: 20, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
hot_3: { x: 16, y: 20, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
cold_1: { x: 16, y: 17, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
cold_2: { x: 16, y: 17, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
cold_3: { x: 16, y: 17, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
plushie_bear: { x: 0, y: 0, w: 150, h: 192, imageW: 150, imageH: 192 },
|
||||
};
|
||||
|
||||
|
||||
const scaledSpriteCache = new Map();
|
||||
const stainOverlayCache = new Map();
|
||||
const SPRITE_CACHE_LIMIT = 128;
|
||||
const STAIN_CACHE_LIMIT = 64;
|
||||
|
||||
const SUNBATH_IMAGE_IDS = new Set((typeof SPRITES !== "undefined" ? SPRITES : [])
|
||||
const SUNBATH_IMAGE_IDS = new Set(SPRITES
|
||||
.map(asset => asset?.id || "")
|
||||
.filter(id => String(id).startsWith("sunbath")));
|
||||
const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
|
||||
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
|
||||
const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
|
||||
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
|
||||
|
||||
function trimCanvasCache(cache, limit = 128) {
|
||||
while (cache.size > limit) {
|
||||
|
|
@ -35,13 +95,6 @@ function getCachedSpriteCanvas(id, img, w, h) {
|
|||
const qw = quantizedCanvasSize(w);
|
||||
const qh = quantizedCanvasSize(h);
|
||||
const key = `${id}:${qw}x${qh}`;
|
||||
if (window.TarinaiPhysics?.createCachedCanvas) {
|
||||
return window.TarinaiPhysics.createCachedCanvas(scaledSpriteCache, key, qw, qh, (c) => {
|
||||
if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high";
|
||||
c.clearRect(0, 0, qw, qh);
|
||||
c.drawImage(img, 0, 0, qw, qh);
|
||||
}, { limit: SPRITE_CACHE_LIMIT });
|
||||
}
|
||||
let canvas = scaledSpriteCache.get(key);
|
||||
if (canvas) return canvas;
|
||||
canvas = document.createElement("canvas");
|
||||
|
|
@ -86,7 +139,6 @@ function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) {
|
|||
c.globalAlpha = 1;
|
||||
c.globalCompositeOperation = "source-over";
|
||||
};
|
||||
if (window.TarinaiPhysics?.createCachedCanvas) return window.TarinaiPhysics.createCachedCanvas(stainOverlayCache, key, qw, qh, buildOverlay, { limit: STAIN_CACHE_LIMIT });
|
||||
let canvas = stainOverlayCache.get(key);
|
||||
if (canvas) return canvas;
|
||||
canvas = document.createElement("canvas");
|
||||
|
|
@ -109,7 +161,6 @@ function primeImageMetrics(asset) {
|
|||
w,
|
||||
h,
|
||||
ratio: h / Math.max(w, 1),
|
||||
visibleBottomRatio: bounds ? (bounds.y + bounds.h) / Math.max(bounds.imageH, 1) : 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -124,12 +175,10 @@ function setupAssetIndex() {
|
|||
function markImageLoaded(asset, img) {
|
||||
const w = img.naturalWidth || img.width || ALPHA_BOUNDS[asset.id]?.imageW || 512;
|
||||
const h = img.naturalHeight || img.height || ALPHA_BOUNDS[asset.id]?.imageH || 512;
|
||||
const bounds = ALPHA_BOUNDS[asset.id] || { x: 0, y: 0, w, h, imageW: w, imageH: h };
|
||||
imageMetrics.set(asset.id, {
|
||||
w,
|
||||
h,
|
||||
ratio: h / Math.max(w, 1),
|
||||
visibleBottomRatio: (bounds.y + bounds.h) / Math.max(bounds.imageH, 1),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +203,7 @@ function loadImageAsset(asset, priority = "auto") {
|
|||
img.onerror = () => { resolve(null); };
|
||||
});
|
||||
imagePromises.set(asset.id, promise);
|
||||
if (!img.src) img.src = (window.TarinaiVersion?.withStaticVersion ? window.TarinaiVersion.withStaticVersion(asset.path) : asset.path);
|
||||
if (!img.src) img.src = window.TARINAI_APP?.withVersion ? window.TARINAI_APP.withVersion(asset.path) : asset.path;
|
||||
return promise;
|
||||
}
|
||||
|
||||
|
|
@ -176,10 +225,9 @@ function getRenderableImage(id, fallbackId = "smile") {
|
|||
return null;
|
||||
}
|
||||
|
||||
function loadImages(ids = null, onProgress = null) {
|
||||
function loadImages(onProgress = null) {
|
||||
setupAssetIndex();
|
||||
const requested = ids ? new Set(ids) : INITIAL_IMAGE_IDS;
|
||||
const assets = [...requested].map(id => imageAssetIndex.get(id)).filter(Boolean);
|
||||
const assets = [...INITIAL_IMAGE_IDS].map(id => imageAssetIndex.get(id)).filter(Boolean);
|
||||
const total = Math.max(assets.length, 1);
|
||||
let done = 0;
|
||||
if (typeof onProgress === "function") onProgress(done, total, null);
|
||||
|
|
@ -227,14 +275,9 @@ function startBackgroundImageLoading() {
|
|||
function imageAlphaBounds(img) {
|
||||
if (!img) return null;
|
||||
if (img.alphaBounds) return img.alphaBounds;
|
||||
if (imageBoundsCache.has(img)) return imageBoundsCache.get(img);
|
||||
|
||||
const w = img.naturalWidth || img.width || 0;
|
||||
const h = img.naturalHeight || img.height || 0;
|
||||
if (!w || !h) return null;
|
||||
const bounds = { x: 0, y: 0, w, h, imageW: w, imageH: h };
|
||||
imageBoundsCache.set(img, bounds);
|
||||
return bounds;
|
||||
return w && h ? { x: 0, y: 0, w, h, imageW: w, imageH: h } : null;
|
||||
}
|
||||
|
||||
function getImageMetrics(idOrImg) {
|
||||
|
|
@ -250,9 +293,8 @@ function getImageMetrics(idOrImg) {
|
|||
}
|
||||
const w = idOrImg.naturalWidth || idOrImg.width || 0;
|
||||
const h = idOrImg.naturalHeight || idOrImg.height || 0;
|
||||
return w && h ? { w, h, ratio: h / w, visibleBottomRatio: 1 } : null;
|
||||
return w && h ? { w, h, ratio: h / w } : null;
|
||||
}
|
||||
|
||||
setupAssetIndex();
|
||||
|
||||
window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, imagePromises, scaledSpriteCache, stainOverlayCache, loadImages, startBackgroundImageLoading, trimImageMemory, ensureImage, isImageReady, getRenderableImage, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas };
|
||||
|
|
|
|||
66
js/audio.js
|
|
@ -10,12 +10,8 @@ const audio = {
|
|||
sampleLast: {},
|
||||
activeVoices: 0,
|
||||
maxConcurrent: 96,
|
||||
failedSamples: {},
|
||||
lastPlayedId: "",
|
||||
lastPlayedAt: 0,
|
||||
soundPackVersion: "inline",
|
||||
masterGainNode: null,
|
||||
compressorNode: null,
|
||||
_synthIdGain: 1,
|
||||
idGain: {},
|
||||
idMinGap: {},
|
||||
|
|
@ -24,9 +20,11 @@ const audio = {
|
|||
samplePaths: {},
|
||||
soundCategories: {},
|
||||
soundMap: {},
|
||||
_startupAudioDampenStarted: false,
|
||||
_startupAudioDampenUntil: 0,
|
||||
_currentPlayGainScale: 1,
|
||||
applySoundPack(pack = window.TARINAI_SOUND_PACK) {
|
||||
if (!pack || typeof pack !== "object") return false;
|
||||
this.soundPackVersion = String(pack.version || "custom");
|
||||
if (pack.categoryGain && typeof pack.categoryGain === "object") this.categoryGain = { ...this.categoryGain, ...pack.categoryGain };
|
||||
if (pack.idGain && typeof pack.idGain === "object") this.idGain = { ...this.idGain, ...pack.idGain };
|
||||
if (pack.idMinGap && typeof pack.idMinGap === "object") this.idMinGap = { ...this.idMinGap, ...pack.idMinGap };
|
||||
|
|
@ -44,15 +42,16 @@ const audio = {
|
|||
if (!this.masterGainNode) {
|
||||
const ctx = this.ctx;
|
||||
this.masterGainNode = ctx.createGain();
|
||||
this.masterGainNode.gain.setValueAtTime(1.85, ctx.currentTime);
|
||||
this.masterGainNode.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
this.masterGainNode.gain.linearRampToValueAtTime(0.78, ctx.currentTime + 0.18);
|
||||
if (typeof ctx.createDynamicsCompressor === "function") {
|
||||
this.compressorNode = ctx.createDynamicsCompressor();
|
||||
this.compressorNode.threshold.value = -18;
|
||||
this.compressorNode.knee.value = 24;
|
||||
this.compressorNode.ratio.value = 8;
|
||||
this.compressorNode.attack.value = 0.002;
|
||||
this.compressorNode.release.value = 0.18;
|
||||
this.masterGainNode.connect(this.compressorNode).connect(ctx.destination);
|
||||
const compressor = ctx.createDynamicsCompressor();
|
||||
compressor.threshold.value = -18;
|
||||
compressor.knee.value = 24;
|
||||
compressor.ratio.value = 8;
|
||||
compressor.attack.value = 0.002;
|
||||
compressor.release.value = 0.18;
|
||||
this.masterGainNode.connect(compressor).connect(ctx.destination);
|
||||
} else {
|
||||
this.masterGainNode.connect(ctx.destination);
|
||||
}
|
||||
|
|
@ -95,8 +94,7 @@ const audio = {
|
|||
a.preload = "auto";
|
||||
a.volume = this.sampleVolumeFor(key);
|
||||
a.addEventListener("error", () => {
|
||||
this.failedSamples[key] = path;
|
||||
window.TarinaiEvents?.emit("audio:sample-error", { id: key, path });
|
||||
window.TarinaiEvents.emit("audio:sample-error", { id: key, path });
|
||||
}, { once: true });
|
||||
this.samples[key] = a;
|
||||
}
|
||||
|
|
@ -141,17 +139,27 @@ const audio = {
|
|||
this.sampleLast[id] = t;
|
||||
return true;
|
||||
},
|
||||
startupGainScale() {
|
||||
const now = performance.now() / 1000;
|
||||
if (!this._startupAudioDampenStarted) {
|
||||
this._startupAudioDampenStarted = true;
|
||||
this._startupAudioDampenUntil = now + 0.85;
|
||||
return 0.26;
|
||||
}
|
||||
if (now < (this._startupAudioDampenUntil || 0)) return 0.42;
|
||||
return 1;
|
||||
},
|
||||
playSample(key, minGap = 0.16, category = null) {
|
||||
const base = this.samples[key];
|
||||
if (!base) return false;
|
||||
const cat = category || this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops");
|
||||
if (!this.canPlay(key, cat, minGap)) return false;
|
||||
this.lastPlayedId = key;
|
||||
this.lastPlayedAt = performance.now() / 1000;
|
||||
window.TarinaiEvents?.emit("audio:play", { id: key, category: cat, synthetic: false });
|
||||
window.TarinaiEvents.emit("audio:play", { id: key, category: cat, synthetic: false });
|
||||
this.ensure();
|
||||
const playScale = this.startupGainScale();
|
||||
const node = base.cloneNode(true);
|
||||
node.volume = Math.min(1, base.volume * (this.categoryGain?.[cat] ?? 1) * (this.idGain?.[key] ?? 1));
|
||||
node.volume = Math.min(1, base.volume * (this.categoryGain?.[cat] ?? 1) * (this.idGain?.[key] ?? 1) * playScale);
|
||||
this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16));
|
||||
node.play().catch(() => {});
|
||||
return true;
|
||||
|
|
@ -162,7 +170,7 @@ const audio = {
|
|||
if (!ctx) return;
|
||||
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return;
|
||||
this.beginVoice(dur);
|
||||
gain = Math.min(0.95, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1)));
|
||||
gain = Math.min(0.95, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1) * (this._currentPlayGainScale || 1)));
|
||||
const t0 = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
|
|
@ -182,7 +190,7 @@ const audio = {
|
|||
if (!ctx) return;
|
||||
if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return;
|
||||
this.beginVoice(dur);
|
||||
gain = Math.min(0.90, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1)));
|
||||
gain = Math.min(0.90, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1) * (this._currentPlayGainScale || 1)));
|
||||
const length = Math.max(1, Math.floor(ctx.sampleRate * dur));
|
||||
const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
|
@ -203,18 +211,20 @@ const audio = {
|
|||
const minGap = opts.minGap ?? 0.10;
|
||||
if (!this.canPlay(id, category, minGap)) return false;
|
||||
this.lastPlayedId = id;
|
||||
this.lastPlayedAt = performance.now() / 1000;
|
||||
window.TarinaiEvents?.emit("audio:play", { id, category, synthetic: !this.samples[id] });
|
||||
window.TarinaiEvents.emit("audio:play", { id, category, synthetic: !this.samples[id] });
|
||||
const playScale = this.startupGainScale();
|
||||
if (this.samples[id]) {
|
||||
const base = this.samples[id];
|
||||
this.ensure();
|
||||
const node = base.cloneNode(true);
|
||||
node.volume = Math.min(1, base.volume * (this.categoryGain?.[category] ?? 1) * (this.idGain?.[id] ?? 1));
|
||||
node.volume = Math.min(1, base.volume * (this.categoryGain?.[category] ?? 1) * (this.idGain?.[id] ?? 1) * playScale);
|
||||
this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16));
|
||||
node.play().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
this.synth(id, category);
|
||||
const prevPlayScale = this._currentPlayGainScale || 1;
|
||||
this._currentPlayGainScale = playScale;
|
||||
try { this.synth(id, category); } finally { this._currentPlayGainScale = prevPlayScale; }
|
||||
return true;
|
||||
},
|
||||
synth(id, category) {
|
||||
|
|
@ -227,7 +237,7 @@ const audio = {
|
|||
}, ms);
|
||||
try {
|
||||
switch (id) {
|
||||
case "sfx_notify": case "sfx_log_push":
|
||||
case "sfx_notify":
|
||||
this.tone(880, 0.055, "sine", 0.022, 80, category); t(62, () => this.tone(1180, 0.075, "triangle", 0.020, -40, category)); break;
|
||||
case "sfx_ui_click": this.tone(520, 0.035, "square", 0.010, 80, category); break;
|
||||
case "sfx_ui_fold": this.tone(420, 0.045, "triangle", 0.012, -130, category); break;
|
||||
|
|
@ -237,7 +247,6 @@ const audio = {
|
|||
case "sfx_grab": this.tone(300, 0.050, "triangle", 0.018, 90, category); break;
|
||||
case "sfx_drop": this.tone(220, 0.060, "triangle", 0.018, -80, category); break;
|
||||
case "sfx_water_hose": this.noise(0.13, 0.018, category, 2400); break;
|
||||
case "sfx_water_clean": this.noise(0.08, 0.014, category, 3200); this.tone(1120, 0.040, "sine", 0.009, 180, category); break;
|
||||
case "sfx_drink": this.tone(720, 0.045, "sine", 0.012, -120, category); t(42, () => this.tone(620, 0.040, "sine", 0.010, -80, category)); break;
|
||||
case "sfx_ball_hit": this.tone(260, 0.065, "triangle", 0.020, 120, category); break;
|
||||
case "sfx_stone_impact": this.tone(110, 0.080, "sawtooth", 0.032, -50, category); this.noise(0.05, 0.018, category, 600); break;
|
||||
|
|
@ -271,6 +280,8 @@ const audio = {
|
|||
},
|
||||
bubble(text) {
|
||||
const s = String(text || "");
|
||||
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 });
|
||||
|
|
@ -303,6 +314,8 @@ const audio = {
|
|||
grab() { this.play("sfx_grab", { category: "ops", minGap: 0.08 }); },
|
||||
drop() { this.play("sfx_drop", { category: "ops", minGap: 0.08 }); },
|
||||
waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); },
|
||||
shoot() { this.play("sfx_shoot", { category: "ops", minGap: 0.045 }); },
|
||||
boltAction() { this.play("sfx_bolt_action", { category: "ops", minGap: 0.10 }); },
|
||||
ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); },
|
||||
stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); },
|
||||
genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); },
|
||||
|
|
@ -317,5 +330,4 @@ const audio = {
|
|||
|
||||
audio.applySoundPack?.(window.TARINAI_SOUND_PACK);
|
||||
audio.loadSettings();
|
||||
window.TarinaiEvents?.on?.("audio:test", ev => audio.play(ev.detail?.id || "sfx_notify", ev.detail || {}));
|
||||
window.TarinaiAudio = audio;
|
||||
|
|
|
|||
|
|
@ -158,14 +158,30 @@
|
|||
return Math.max(14, reach);
|
||||
}
|
||||
const radiusFor = typeof global.itemRadiusFor === "function" ? global.itemRadiusFor : ((type, fallback = 12) => fallback);
|
||||
if (item.type === "duplicator") {
|
||||
// 保存済みデータに古い r=34 が残っていても、配置判定は外観に近い半径で扱う。
|
||||
return Math.max(14, Math.min(24, num(item.r, radiusFor(item.type, 24)) || radiusFor(item.type, 24)));
|
||||
}
|
||||
return Math.max(14, num(item.r, radiusFor(item.type, 12)) || radiusFor(item.type, 12));
|
||||
}
|
||||
|
||||
function isSoftPlacementIgnoredType(type = "") {
|
||||
const key = String(type || "");
|
||||
return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water";
|
||||
}
|
||||
|
||||
function placementRectsFor(worldRef, item) {
|
||||
const solid = worldRef?.solidObstacleRects?.(item) || [];
|
||||
if (solid.length) return solid;
|
||||
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || [];
|
||||
return solid;
|
||||
}
|
||||
|
||||
function hitTestItem(worldRef, item, x, y, opts = {}) {
|
||||
if (!item || item.dead) return { hit: false, distance: Infinity };
|
||||
const padding = Math.max(0, num(opts.padding, 0));
|
||||
let rects = opts.rects || worldRef?.solidObstacleRects?.(item) || [];
|
||||
if ((!rects || !rects.length) && item?.type === "poison_block") rects = global.TarinaiMechanicalSystem?.poisonHazardRects?.(item) || [];
|
||||
if ((!rects || !rects.length) && item?.type === "poison_block") rects = global.TarinaiMechanicalSystem.poisonHazardRects(item) || [];
|
||||
let bestDistance = Math.hypot(num(x) - num(item.x), num(y) - num(item.y));
|
||||
let hit = false;
|
||||
if (rects.length) {
|
||||
|
|
@ -174,13 +190,15 @@
|
|||
bestDistance = Math.min(bestDistance, Math.hypot(num(x) - num(rect.cx, item.x), num(y) - num(rect.cy, item.y)));
|
||||
}
|
||||
}
|
||||
const mechHit = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type);
|
||||
const mechHit = global.TarinaiMechanicalSystem.isMechanicalType(item.type);
|
||||
if (mechHit) {
|
||||
const hub = Math.max(18, Math.min(40, (global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) ?? 12) * 2.3));
|
||||
const hub = Math.max(18, Math.min(40, (global.TarinaiPhysicsBodySystem.scalar(item, "thickness", 12) ?? 12) * 2.3));
|
||||
if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true;
|
||||
} else if (!rects.length) {
|
||||
const mul = num(opts.radiusMultiplier, item.type === "ball" ? 4.0 : 2.2);
|
||||
const r = Math.max(24, num(item.r, 12) * mul);
|
||||
const baseRadius = item.type === "duplicator" ? Math.min(24, num(item.r, 24) || 24) : num(item.r, 12);
|
||||
const defaultMul = item.type === "ball" ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2);
|
||||
const mul = num(opts.radiusMultiplier, defaultMul);
|
||||
const r = Math.max(item.type === "duplicator" ? 18 : 24, baseRadius * mul);
|
||||
hit = bestDistance <= r + padding;
|
||||
}
|
||||
return { hit, distance: bestDistance };
|
||||
|
|
@ -188,27 +206,22 @@
|
|||
|
||||
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
|
||||
if (!worldRef || !item || item.dead) return true;
|
||||
const important = opts.important || new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "water"]);
|
||||
const isPhysicsItem = (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type)));
|
||||
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 placingGrass = item.type === "grass";
|
||||
const itemRects = worldRef.solidObstacleRects?.(item) || [];
|
||||
const itemRects = placementRectsFor(worldRef, item);
|
||||
const itemRadius = itemFootprintRadius(worldRef, item, itemRects);
|
||||
const search = Math.max(132, itemRadius * 3.2);
|
||||
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) {
|
||||
if (!other || other === item || other.dead) continue;
|
||||
if (isPhysicsItem && (other.type === "water" || other.type === "grass_bed")) continue;
|
||||
if (other.type === "poison_block") {
|
||||
const body = global.TarinaiPhysicsBodySystem?.ensureBody?.(other, worldRef, { syncFromLegacy: false });
|
||||
const solid = body?.collision ? body.collision.solid !== false : true;
|
||||
if (!solid) continue;
|
||||
}
|
||||
if (isSoftPlacementIgnoredType(other.type)) continue;
|
||||
if (!important.has(other.type)) continue;
|
||||
if (!placingGrass && other.type === "grass") continue;
|
||||
const otherRects = worldRef.solidObstacleRects?.(other) || [];
|
||||
const otherRects = placementRectsFor(worldRef, other);
|
||||
const otherRadius = itemFootprintRadius(worldRef, other, otherRects);
|
||||
const hasMechanism = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || global.TarinaiMechanicalSystem?.isMechanicalType?.(other.type);
|
||||
const margin = hasMechanism ? 1.25 : Math.max(4, Math.min(itemRadius, otherRadius) * 0.18);
|
||||
const hasMechanism = global.TarinaiMechanicalSystem.isMechanicalType(item.type) || global.TarinaiMechanicalSystem.isMechanicalType(other.type);
|
||||
const itemIsFence = typeof global.isFenceItemType === "function" ? global.isFenceItemType(item.type) : /fence/.test(String(item.type || ""));
|
||||
const otherIsFence = typeof global.isFenceItemType === "function" ? global.isFenceItemType(other.type) : /fence/.test(String(other.type || ""));
|
||||
const margin = (itemIsFence || otherIsFence) ? (item.type === "gate_fence" || other.type === "gate_fence" ? 1.5 : 2.5) : (hasMechanism ? 1.25 : Math.max(4, Math.min(itemRadius, otherRadius) * 0.18));
|
||||
if (itemRects.length && otherRects.length) {
|
||||
if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true;
|
||||
continue;
|
||||
|
|
@ -230,14 +243,10 @@
|
|||
rectLocalPoint,
|
||||
rectWorldVector,
|
||||
pointInRect,
|
||||
rectCorners,
|
||||
rectAxes,
|
||||
projectPoints,
|
||||
aabbOverlap,
|
||||
rectOverlapInfo,
|
||||
rectsOverlap,
|
||||
rectCircleOverlap,
|
||||
itemFootprintRadius,
|
||||
placementRectsFor,
|
||||
hitTestItem,
|
||||
itemPlacementOverlapBlocked,
|
||||
});
|
||||
|
|
|
|||
386
js/collision_response_system.js
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const ImpactCore = global.TarinaiImpactCoreSystem;
|
||||
const PHYSICAL_DAMAGE_SPEED_THRESHOLD = ImpactCore.PHYSICAL_DAMAGE_SPEED_THRESHOLD || 165;
|
||||
const physicalDamageFromImpactSpeed = ImpactCore.physicalDamageFromImpactSpeed;
|
||||
const itemHasAttachedLink = ImpactCore.itemHasAttachedLink;
|
||||
|
||||
|
||||
function resolveBallBallCollisions(worldRef, dt) {
|
||||
if (!worldRef) return undefined;
|
||||
if ((worldRef.itemCounts?.ball || 0) < 2) return;
|
||||
const now = worldRef.time || 0;
|
||||
if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map();
|
||||
const balls = worldRef.itemsOfType?.("ball") || (worldRef.items || []).filter(it => it && !it.dead && it.type === "ball");
|
||||
for (const a of balls) {
|
||||
if (!a || a.dead || a.type !== "ball") continue;
|
||||
const ar = a.r || 18;
|
||||
const avx = a.vx || 0;
|
||||
const avy = a.vy || 0;
|
||||
const aSpeed = Math.hypot(avx, avy);
|
||||
const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52;
|
||||
for (const b of worldRef.nearbyItems(a.x, a.y, range) || []) {
|
||||
if (!b || b === a || b.dead || b.type !== "ball") continue;
|
||||
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
||||
if (now - (worldRef.ballCollisionMemo.get(key) || -999) < 0.055) continue;
|
||||
const br = b.r || 18;
|
||||
const hitRadius = ar + br;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let d = Math.hypot(dx, dy);
|
||||
let hitX = a.x;
|
||||
let hitY = a.y;
|
||||
if (d > hitRadius) {
|
||||
const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x;
|
||||
const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y;
|
||||
const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x;
|
||||
const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y;
|
||||
const rvx = (a.x - ax0) - (b.x - bx0);
|
||||
const rvy = (a.y - ay0) - (b.y - by0);
|
||||
const rx = ax0 - bx0;
|
||||
const ry = ay0 - by0;
|
||||
const aa = rvx * rvx + rvy * rvy;
|
||||
if (aa <= 0.0001) continue;
|
||||
const bb = 2 * (rx * rvx + ry * rvy);
|
||||
const cc = rx * rx + ry * ry - hitRadius * hitRadius;
|
||||
const disc = bb * bb - 4 * aa * cc;
|
||||
if (disc < 0) continue;
|
||||
const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1);
|
||||
const axHit = ax0 + (a.x - ax0) * u;
|
||||
const ayHit = ay0 + (a.y - ay0) * u;
|
||||
const bxHit = bx0 + (b.x - bx0) * u;
|
||||
const byHit = by0 + (b.y - by0) * u;
|
||||
dx = bxHit - axHit;
|
||||
dy = byHit - ayHit;
|
||||
d = Math.hypot(dx, dy);
|
||||
hitX = (axHit + bxHit) * 0.5;
|
||||
hitY = (ayHit + byHit) * 0.5;
|
||||
if (d > hitRadius + 0.5) continue;
|
||||
} else {
|
||||
hitX = (a.x + b.x) * 0.5;
|
||||
hitY = (a.y + b.y) * 0.5;
|
||||
}
|
||||
if (d < 0.001) {
|
||||
const rvx = (b.vx || 0) - (a.vx || 0);
|
||||
const rvy = (b.vy || 0) - (a.vy || 0);
|
||||
const rs = Math.hypot(rvx, rvy);
|
||||
if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; }
|
||||
else { const ang = deterministicAngle(worldRef, "ball-ball-overlap-normal", a, b); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; }
|
||||
}
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
const rvx = (b.vx || 0) - (a.vx || 0);
|
||||
const rvy = (b.vy || 0) - (a.vy || 0);
|
||||
const relNormal = rvx * nx + rvy * ny;
|
||||
if (relNormal > 0 && d >= hitRadius - 0.5) continue;
|
||||
worldRef.ballCollisionMemo.set(key, now);
|
||||
const restitution = 0.86;
|
||||
const impulse = -(1 + restitution) * relNormal / 2;
|
||||
if (Number.isFinite(impulse)) {
|
||||
a.vx = (a.vx || 0) - impulse * nx;
|
||||
a.vy = (a.vy || 0) - impulse * ny;
|
||||
b.vx = (b.vx || 0) + impulse * nx;
|
||||
b.vy = (b.vy || 0) + impulse * ny;
|
||||
}
|
||||
const overlap = Math.max(0, hitRadius - d + 0.6);
|
||||
if (overlap > 0) {
|
||||
a.x -= nx * overlap * 0.5;
|
||||
a.y -= ny * overlap * 0.5;
|
||||
b.x += nx * overlap * 0.5;
|
||||
b.y += ny * overlap * 0.5;
|
||||
}
|
||||
const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0));
|
||||
a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38);
|
||||
b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38);
|
||||
worldRef.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" }));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
function resolveBallInteractions(worldRef, dt) {
|
||||
if (!worldRef) return undefined;
|
||||
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;
|
||||
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
|
||||
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
|
||||
const sx = ball.x - px;
|
||||
const sy = ball.y - py;
|
||||
const segLenSq = sx * sx + sy * sy;
|
||||
for (const t of worldRef.nearbyTarinai(ball.x, ball.y, range)) {
|
||||
if (!t || t.dead || t.state === "sleep" || worldRef.isTarinaiHiddenInNestBox(t)) continue;
|
||||
let hitX = ball.x;
|
||||
let hitY = ball.y;
|
||||
if (segLenSq > 1) {
|
||||
const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1);
|
||||
hitX = px + sx * u;
|
||||
hitY = py + sy * u;
|
||||
}
|
||||
let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y));
|
||||
const hitDistance = (ball.r || 18) + t.radius * 0.74;
|
||||
if (d > hitDistance) continue;
|
||||
const now = worldRef.time || 0;
|
||||
const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18;
|
||||
if (repeat && ballSpeed < 300) continue;
|
||||
let nx = (hitX - t.x) / d;
|
||||
let ny = (hitY - t.y) / d;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
if (ballSpeed > 0.01) {
|
||||
nx = (ball.vx || 0) / ballSpeed;
|
||||
ny = (ball.vy || 0) / ballSpeed;
|
||||
} else {
|
||||
nx = t.facingDir ? t.facingDir() : 1;
|
||||
ny = deterministicRange(worldRef, "ball-interaction-fallback-ny", -0.22, 0.22, ball, t);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) * 0.72 + (nx >= 0 ? -1 : 1) * 5.2, -38, 38);
|
||||
ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
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}にボールから鋲が移った。`, "accident", { participants: [t] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const constrainedBall = itemHasAttachedLink(worldRef, ball);
|
||||
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
|
||||
const moveVx = sx / frameDt;
|
||||
const moveVy = sy / frameDt;
|
||||
const effectiveVx = Math.abs(ball.vx || 0) + Math.abs(ball.vy || 0) > 0.1 ? (ball.vx || 0) : moveVx;
|
||||
const effectiveVy = Math.abs(ball.vx || 0) + Math.abs(ball.vy || 0) > 0.1 ? (ball.vy || 0) : moveVy;
|
||||
const incomingSpeed = -(effectiveVx * nx + effectiveVy * ny);
|
||||
const overlapPush = Math.max(0, hitDistance - d);
|
||||
const playIntentForPush = t.state === "play_ball" || t.target === ball;
|
||||
if (constrainedBall && !playIntentForPush && (incomingSpeed > 8 || overlapPush > 1.5)) {
|
||||
const pushImpulse = clamp(20 + incomingSpeed * 0.48 + overlapPush * 2.6, 20, 150);
|
||||
t.vx = clamp((t.vx || 0) - nx * pushImpulse + effectiveVx * 0.08, -360, 360);
|
||||
t.vy = clamp((t.vy || 0) - ny * pushImpulse + effectiveVy * 0.08, -360, 360);
|
||||
if (overlapPush > 0.4) {
|
||||
const sep = Math.min(14, overlapPush * 0.58 + 0.8);
|
||||
t.x = clamp((t.x || 0) - nx * sep, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
t.y = clamp((t.y || 0) - ny * sep, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
ball.x = clamp((ball.x || 0) + nx * sep * 0.28, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
ball.y = clamp((ball.y || 0) + ny * sep * 0.28, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
}
|
||||
ball.vx = clamp((ball.vx || effectiveVx) * 0.62 + nx * Math.min(42, pushImpulse * 0.22), -720, 720);
|
||||
ball.vy = clamp((ball.vy || effectiveVy) * 0.62 + ny * Math.min(42, pushImpulse * 0.22), -720, 720);
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 4.6, -38, 38);
|
||||
ball.lastKickedAt = now;
|
||||
ball.lastKickerId = t.id;
|
||||
t.target = null;
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.16);
|
||||
worldRef.markSpatialDirty?.("constrained-ball-push");
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDangerous = ballSpeed >= 285;
|
||||
if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) {
|
||||
const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
|
||||
const damage = physicalDamageFromImpactSpeed(ballSpeed, { min: 3.5, max: 38, scale: 18 });
|
||||
t.lastBallDamageAt = now;
|
||||
worldRef.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, {
|
||||
panic: true,
|
||||
target: { x: ball.x, y: ball.y },
|
||||
fearTimer: 0.95,
|
||||
thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
|
||||
});
|
||||
worldRef.applyImpactDamage(t, damage, "\u885d\u7a81", { source: ball });
|
||||
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
|
||||
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
|
||||
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
|
||||
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
|
||||
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
|
||||
t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer);
|
||||
t.fallDir = bvx >= 0 ? 1 : -1;
|
||||
t.fearTimer = Math.max(t.fearTimer, 0.95);
|
||||
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44);
|
||||
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
|
||||
worldRef.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8));
|
||||
worldRef.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" }));
|
||||
if (worldRef.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) worldRef.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] });
|
||||
ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10);
|
||||
ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10);
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38);
|
||||
continue;
|
||||
}
|
||||
|
||||
const relVx = (t.vx || 0) - (ball.vx || 0);
|
||||
const relVy = (t.vy || 0) - (ball.vy || 0);
|
||||
const relativeSpeed = Math.hypot(relVx, relVy);
|
||||
const playIntent = t.state === "play_ball" || t.target === ball;
|
||||
const playful = t.shouldApplyPersonalityBehavior?.("openness", 1);
|
||||
const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260);
|
||||
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52;
|
||||
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52;
|
||||
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);
|
||||
ball.lastKickedAt = now;
|
||||
ball.lastKickerId = t.id;
|
||||
t.vx -= nx * Math.min(22, impulse * 0.10);
|
||||
t.vy -= ny * Math.min(22, impulse * 0.10);
|
||||
t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100));
|
||||
const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20;
|
||||
const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0;
|
||||
if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 });
|
||||
if (playful && playIntent) {
|
||||
t.loneliness = clamp(t.loneliness - 0.9, 0, 100);
|
||||
t.affection = clamp(t.affection + 0.18, 0, 100);
|
||||
}
|
||||
t.goodMode = playIntent || playful ? "smile" : t.goodMode;
|
||||
if (playIntent && now > (t.nextPlayBubbleAt || 0)) {
|
||||
t.nextPlayBubbleAt = now + deterministicRange(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}\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)" }));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
function resolveTarinaiHighSpeedCollisions(worldRef, dt) {
|
||||
if (!worldRef) return undefined;
|
||||
const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD;
|
||||
const now = worldRef.time || 0;
|
||||
if (!worldRef.tarinaiCollisionMemo) worldRef.tarinaiCollisionMemo = new Map();
|
||||
const live = Array.from(worldRef.tarinai || [])
|
||||
.filter(t => t && !t.dead && !worldRef.isTarinaiHiddenInNestBox(t))
|
||||
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")));
|
||||
const candidates = [];
|
||||
const maxCandidates = Math.max(24, Math.min(320, live.length * 6));
|
||||
for (let i = 0; i < live.length; i += 1) {
|
||||
const a = live[i];
|
||||
const avx = a.vx || 0;
|
||||
const avy = a.vy || 0;
|
||||
const aSpeed = Math.hypot(avx, avy);
|
||||
if (aSpeed < threshold) continue;
|
||||
const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x;
|
||||
const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y;
|
||||
const ax1 = Number.isFinite(a.x) ? a.x : ax0;
|
||||
const ay1 = Number.isFinite(a.y) ? a.y : ay0;
|
||||
const asx = ax1 - ax0;
|
||||
const asy = ay1 - ay0;
|
||||
for (let j = 0; j < live.length; j += 1) {
|
||||
const b = live[j];
|
||||
if (!b || b === a) continue;
|
||||
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
||||
if (now - (worldRef.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
|
||||
const bvx = b.vx || 0;
|
||||
const bvy = b.vy || 0;
|
||||
const relVx = avx - bvx;
|
||||
const relVy = avy - bvy;
|
||||
const relSpeed = Math.hypot(relVx, relVy);
|
||||
if (relSpeed < threshold) continue;
|
||||
const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x;
|
||||
const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y;
|
||||
const bx1 = Number.isFinite(b.x) ? b.x : bx0;
|
||||
const by1 = Number.isFinite(b.y) ? b.y : by0;
|
||||
const bsx = bx1 - bx0;
|
||||
const bsy = by1 - by0;
|
||||
const rx0 = ax0 - bx0;
|
||||
const ry0 = ay0 - by0;
|
||||
const rvx = asx - bsx;
|
||||
const rvy = asy - bsy;
|
||||
const relMoveSq = rvx * rvx + rvy * rvy;
|
||||
const u = relMoveSq > 0.001 ? clamp(-(rx0 * rvx + ry0 * rvy) / relMoveSq, 0, 1) : 1;
|
||||
const cx = rx0 + rvx * u;
|
||||
const cy = ry0 + rvy * u;
|
||||
const d = Math.max(0.001, Math.hypot(cx, cy));
|
||||
const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74;
|
||||
if (d > hitDistance) continue;
|
||||
const score = (1 - u) * 4 + Math.max(0, hitDistance - d) * 0.12 + Math.min(5, relSpeed / 120);
|
||||
candidates.push({ a, b, key, relSpeed, u, cx, cy, d, hitDistance, score });
|
||||
if (candidates.length >= maxCandidates) break;
|
||||
}
|
||||
if (candidates.length >= maxCandidates) break;
|
||||
}
|
||||
candidates.sort((p, q) => (p.u - q.u) || (q.score - p.score) || String(p.key).localeCompare(String(q.key)));
|
||||
const maxImpacts = Math.max(8, Math.min(64, Number(worldRef.maxTarinaiCollisionImpacts || 32) || 32));
|
||||
let solved = 0;
|
||||
for (const c of candidates) {
|
||||
if (solved >= maxImpacts) break;
|
||||
const { a, b, key, relSpeed, hitDistance } = c;
|
||||
if (!a || !b || a.dead || b.dead) continue;
|
||||
if (now - (worldRef.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
|
||||
worldRef.tarinaiCollisionMemo.set(key, now);
|
||||
solved += 1;
|
||||
const as = Math.hypot(a.vx || 0, a.vy || 0);
|
||||
const bs = Math.hypot(b.vx || 0, b.vy || 0);
|
||||
const impactor = as >= bs ? a : b;
|
||||
const target = impactor === a ? b : a;
|
||||
const ivx = impactor.vx || 0;
|
||||
const ivy = impactor.vy || 0;
|
||||
const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1;
|
||||
let nx = impactSpeed > 0.001 ? ivx / impactSpeed : 0;
|
||||
let ny = impactSpeed > 0.001 ? ivy / impactSpeed : 0;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
const dx = target.x - impactor.x;
|
||||
const dy = target.y - impactor.y;
|
||||
const dd = Math.hypot(dx, dy);
|
||||
if (dd > 0.001) { nx = dx / dd; ny = dy / dd; }
|
||||
else {
|
||||
const angle = (stableUnit(key, "tarinai-collision-normal")) * Math.PI * 2;
|
||||
nx = Math.cos(angle);
|
||||
ny = Math.sin(angle);
|
||||
}
|
||||
}
|
||||
const overlap = Math.max(0, hitDistance - Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0)));
|
||||
if (overlap > 0.5) {
|
||||
const sep = Math.min(18, overlap * 0.45 + 0.8);
|
||||
impactor.x = clamp((impactor.x || 0) - nx * sep * 0.45, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
impactor.y = clamp((impactor.y || 0) - ny * sep * 0.45, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
target.x = clamp((target.x || 0) + nx * sep * 0.55, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
target.y = clamp((target.y || 0) + ny * sep * 0.55, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
worldRef.markSpatialDirty?.("tarinai-highspeed-separation");
|
||||
}
|
||||
const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 });
|
||||
worldRef.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, {
|
||||
panic: true,
|
||||
target: { x: impactor.x, y: impactor.y },
|
||||
fearTimer: 0.55,
|
||||
thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b",
|
||||
});
|
||||
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
|
||||
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
|
||||
worldRef.applyImpactDamage(target, damage, "\u885d\u7a81", { source: impactor });
|
||||
if (!impactor.dead) worldRef.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81", { source: target });
|
||||
for (const t of [target, impactor]) {
|
||||
if (!t || t.dead) continue;
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
|
||||
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true });
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
|
||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
|
||||
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||||
t.fallDir = nx >= 0 ? 1 : -1;
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
|
||||
}
|
||||
worldRef.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" }));
|
||||
if (worldRef.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) worldRef.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] });
|
||||
}
|
||||
if (solved && worldRef.spatialDirty) worldRef.rebuildSpatial?.(true, "post-tarinai-highspeed-collision");
|
||||
|
||||
}
|
||||
|
||||
|
||||
global.TarinaiCollisionResponseSystem = Object.freeze({
|
||||
resolveBallBallCollisions,
|
||||
resolveBallInteractions,
|
||||
resolveTarinaiHighSpeedCollisions,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
94
js/colony_situation_system.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: simulation/colony-situation
|
||||
// Owns colony mood/situation signal collection and priority arbitration.
|
||||
(function (global) {
|
||||
function avgOf(list, fn) { return list.length ? list.reduce((sum, t) => sum + (Number(fn(t)) || 0), 0) / list.length : 0; }
|
||||
function countOf(list, fn) { return list.reduce((sum, t) => sum + (fn(t) ? 1 : 0), 0); }
|
||||
function nearestAverage(alive) {
|
||||
if (alive.length < 2) return Infinity;
|
||||
let total = 0;
|
||||
for (const a of alive) {
|
||||
let best = Infinity;
|
||||
for (const b of alive) {
|
||||
if (a === b) continue;
|
||||
const dx = (a.x || 0) - (b.x || 0);
|
||||
const dy = (a.y || 0) - (b.y || 0);
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < best) best = d2;
|
||||
}
|
||||
if (Number.isFinite(best)) total += Math.sqrt(best);
|
||||
}
|
||||
return total / alive.length;
|
||||
}
|
||||
function collect(worldRef) {
|
||||
const now = worldRef?.time || 0;
|
||||
const alive = (worldRef?.tarinai || []).filter(t => t && !t.dead);
|
||||
const n = alive.length;
|
||||
const ratio = value => n ? value / n : 0;
|
||||
const panicCount = countOf(alive, t => t.state === "panic" || (t.fearTimer || 0) > 0.2 || (t.panicTargetUntil || 0) > now);
|
||||
const safetyHighCount = countOf(alive, t => (t.needs?.safety || 0) >= 52 || (t.fearTimer || 0) > 0.35);
|
||||
const weakCount = countOf(alive, t => (t.energy || 0) < 45 || (t.hunger || 0) > 88 || (t.needs?.health || 0) > 58);
|
||||
const diseasedCount = countOf(alive, t => t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease);
|
||||
const breedingCount = countOf(alive, t => (t.loveMochiTimer || 0) > 0.1 || t.state === "birth_ritual" || (t.birthRitualTimer || 0) > 0.1);
|
||||
const recentDeaths = (Array.isArray(worldRef?.recentDeathTimes) ? worldRef.recentDeathTimes : []).filter(t => now - (Number(t) || -999) <= 30).length;
|
||||
const nearestAvg = nearestAverage(alive);
|
||||
const fieldId = String(worldRef?.fieldType || "garden");
|
||||
const overcrowdLimit = fieldId === "cage" ? 20 : (fieldId === "park" ? 100 : 50);
|
||||
return {
|
||||
now,
|
||||
alive,
|
||||
n,
|
||||
avgEnergy: avgOf(alive, t => t.energy || 0),
|
||||
avgStress: avgOf(alive, t => t.stress || 0),
|
||||
avgHunger: avgOf(alive, t => t.hunger || 0),
|
||||
avgSafety: avgOf(alive, t => t.needs?.safety || 0),
|
||||
panicCount,
|
||||
safetyHighCount,
|
||||
weakCount,
|
||||
diseasedCount,
|
||||
breedingCount,
|
||||
recentDeaths,
|
||||
nearestAvg,
|
||||
recentShock: (now - (worldRef?.lastShootAt || -999) <= 8) || (now - (worldRef?.lastGenkotsuAt || -999) <= 8),
|
||||
panicRatio: ratio(panicCount),
|
||||
safetyRatio: ratio(safetyHighCount),
|
||||
weakRatio: ratio(weakCount),
|
||||
diseaseRatio: ratio(diseasedCount),
|
||||
breedingRatio: ratio(breedingCount),
|
||||
overcrowdLimit,
|
||||
denseCrowd: Number.isFinite(nearestAvg) && nearestAvg < 84 && n >= Math.max(8, Math.floor(overcrowdLimit * 0.55)),
|
||||
};
|
||||
}
|
||||
function choose(worldRef, m) {
|
||||
const candidates = [];
|
||||
const add = (id, priority, ok) => { if (ok) candidates.push({ id, priority }); };
|
||||
const devastationSignal = m.recentDeaths >= 3 || (m.n <= 2 && (worldRef?.deadCount || 0) > 0 && (m.recentDeaths > 0 || m.weakRatio >= 0.35 || m.panicRatio >= 0.20));
|
||||
const crisisExhaustionSignal = m.avgEnergy < 30 && m.weakRatio >= 0.48;
|
||||
add("devastation", 120, m.n <= 10 && devastationSignal);
|
||||
add("crisis", 110, m.recentDeaths >= 2 || crisisExhaustionSignal || (m.avgHunger > 92 && m.weakRatio >= 0.48));
|
||||
add("confusion", 100, (m.recentShock && (m.panicRatio >= 0.10 || m.safetyRatio >= 0.18)) || m.panicRatio >= 0.25);
|
||||
add("fearful", 90, (m.avgSafety >= 50 && m.safetyRatio >= 0.28) || (m.recentShock && m.safetyRatio >= 0.18));
|
||||
add("disease_spread", 82, m.diseaseRatio >= 0.25 || (m.diseasedCount >= 3 && m.diseaseRatio >= 0.16));
|
||||
add("weakened", 74, (m.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34);
|
||||
add("breeding", 66, m.breedingRatio >= 0.10 || m.breedingCount >= 2 || m.now - (worldRef?.lastBirthAt || -999) <= 45);
|
||||
add("happy", 46, m.avgStress < 28 && m.panicRatio < 0.08 && m.diseaseRatio < 0.12 && m.weakRatio < 0.18);
|
||||
add("overcrowded", 34, m.n >= m.overcrowdLimit || m.denseCrowd);
|
||||
add("isolated", 28, m.n <= 1 || (m.n <= 3 && m.nearestAvg > Math.max(180, Math.min(worldRef?.w || 900, worldRef?.h || 600) * 0.32)));
|
||||
if (!candidates.length) return "relaxed";
|
||||
candidates.sort((a, b) => b.priority - a.priority);
|
||||
return candidates[0].id;
|
||||
}
|
||||
function evaluate(worldRef, force = false) {
|
||||
const now = worldRef?.time || 0;
|
||||
if (!worldRef) return { id: "relaxed", label: "安定", effects: { personality: {} } };
|
||||
if (!force && worldRef.colonyMood && now < (worldRef.nextColonyMoodEvalAt || 0)) return worldRef.colonyMood;
|
||||
const metrics = collect(worldRef);
|
||||
const id = choose(worldRef, metrics);
|
||||
const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } };
|
||||
worldRef.colonyMood = next;
|
||||
worldRef.nextColonyMoodEvalAt = now + 1.25;
|
||||
return next;
|
||||
}
|
||||
global.TarinaiColonySituationSystem = Object.freeze({ evaluate });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
function emit(worldRef, type, payload) {
|
||||
worldRef?.emit?.(type, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function ok(payload = {}) {
|
||||
|
|
@ -25,6 +24,11 @@
|
|||
const id = String(toolId || "observe");
|
||||
if (worldRef.tool === id) return ok({ toolId: id, changed: false });
|
||||
worldRef.tool = id;
|
||||
if (id !== "shoot") {
|
||||
worldRef.shootCursorActive = false;
|
||||
worldRef.shootCursorOffsetX = 0;
|
||||
worldRef.shootCursorOffsetY = 0;
|
||||
}
|
||||
emit(worldRef, "tool:selected", { toolId: id });
|
||||
return ok({ toolId: id, changed: true });
|
||||
}
|
||||
|
|
@ -103,12 +107,6 @@
|
|||
return ok({ paused: Boolean(worldRef.paused) });
|
||||
}
|
||||
|
||||
function setPaused(worldRef, paused) {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
worldRef.paused = Boolean(paused);
|
||||
emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) });
|
||||
return ok({ paused: Boolean(worldRef.paused) });
|
||||
}
|
||||
|
||||
function cycleSpeed(worldRef, speeds = DEFAULT_SPEEDS) {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
|
|
@ -125,7 +123,7 @@
|
|||
const py = Number(y);
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py)) return fail("invalid_point", { x, y });
|
||||
if (typeof worldRef.handleClick !== "function") return fail("missing_pointer_handler");
|
||||
if (worldRef.tool && worldRef.tool !== "observe") global.TarinaiHistory?.capture?.(worldRef, `tool:${worldRef.tool}`);
|
||||
if (worldRef.tool && worldRef.tool !== "observe") global.TarinaiHistory.capture(worldRef, `tool:${worldRef.tool}`);
|
||||
worldRef.handleClick(px, py);
|
||||
emit(worldRef, "pointer:primary", { x: px, y: py, toolId: worldRef.tool || "" });
|
||||
return ok({ x: px, y: py, toolId: worldRef.tool || "" });
|
||||
|
|
@ -135,20 +133,14 @@
|
|||
switch (command.type) {
|
||||
case "tool.select":
|
||||
return selectTool(worldRef, command.toolId);
|
||||
case "tool.size.set":
|
||||
return setToolSize(worldRef, command.toolId, command.size);
|
||||
case "tool.size.cycle":
|
||||
return cycleToolSize(worldRef, command.toolId, command.order);
|
||||
case "selection.set":
|
||||
return setSelection(worldRef, command.target || null);
|
||||
case "selection.clear":
|
||||
return clearSelection(worldRef);
|
||||
case "selection.focus":
|
||||
return focusSelection(worldRef, command.target || null, command.options || {});
|
||||
case "selection.favorite.toggle":
|
||||
return toggleSelectedFavorite(worldRef, command.target || worldRef?.selected || null);
|
||||
case "simulation.paused.set":
|
||||
return setPaused(worldRef, command.paused);
|
||||
case "simulation.paused.toggle":
|
||||
return togglePaused(worldRef);
|
||||
case "simulation.speed.cycle":
|
||||
|
|
@ -156,11 +148,11 @@
|
|||
case "pointer.primary":
|
||||
return primaryPointer(worldRef, command.x, command.y);
|
||||
case "history.undo": {
|
||||
const result = global.TarinaiHistory?.undo?.(worldRef) || { ok: false, reason: "missing_history" };
|
||||
const result = global.TarinaiHistory.undo(worldRef);
|
||||
return result.ok ? ok({ action: "undo" }) : fail(result.reason || "undo_failed");
|
||||
}
|
||||
case "history.redo": {
|
||||
const result = global.TarinaiHistory?.redo?.(worldRef) || { ok: false, reason: "missing_history" };
|
||||
const result = global.TarinaiHistory.redo(worldRef);
|
||||
return result.ok ? ok({ action: "redo" }) : fail(result.reason || "redo_failed");
|
||||
}
|
||||
default:
|
||||
|
|
@ -169,35 +161,8 @@
|
|||
}
|
||||
|
||||
|
||||
function installWorldFacade() {
|
||||
const World = global.World;
|
||||
if (!World?.prototype || World.prototype.dispatchCommand) return;
|
||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
dispatchCommand(command = {}) { return dispatch(this, command); },
|
||||
selectToolCommand(toolId) { return selectTool(this, toolId); },
|
||||
setSelectionCommand(target) { return setSelection(this, target); },
|
||||
clearSelectionCommand() { return clearSelection(this); },
|
||||
focusSelectionCommand(target, options = {}) { return focusSelection(this, target, options); },
|
||||
primaryPointerCommand(x, y) { return primaryPointer(this, x, y); },
|
||||
}));
|
||||
}
|
||||
|
||||
installWorldFacade();
|
||||
|
||||
global.TarinaiCommands = Object.freeze({
|
||||
dispatch,
|
||||
selectTool,
|
||||
setSelection,
|
||||
clearSelection,
|
||||
focusSelection,
|
||||
toggleSelectedFavorite,
|
||||
setToolSize,
|
||||
cycleToolSize,
|
||||
setPaused,
|
||||
togglePaused,
|
||||
cycleSpeed,
|
||||
primaryPointer,
|
||||
undo(worldRef = global.world) { return global.TarinaiHistory?.undo?.(worldRef) || fail("missing_history"); },
|
||||
redo(worldRef = global.world) { return global.TarinaiHistory?.redo?.(worldRef) || fail("missing_history"); },
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
function ls(item, key, fallback = 0) { return bodyApi()?.linkScalar?.(item, key, fallback) ?? fallback; }
|
||||
function lset(item, key, value) { return bodyApi()?.setLinkScalar?.(item, key, value) || false; }
|
||||
function ep(item, index) { return bodyApi()?.endpoint?.(item, index) || null; }
|
||||
function setEp(item, index, value) { return bodyApi()?.setEndpoint?.(item, index, value) || false; }
|
||||
function parts(item) { return bodyApi()?.particles?.(item) || null; }
|
||||
function setParts(item, list) { return bodyApi()?.setParticles?.(item, list) || false; }
|
||||
function ensureConstraint(item, worldRef = null, opts = {}) {
|
||||
|
|
@ -26,7 +25,7 @@
|
|||
return c;
|
||||
}
|
||||
function commitMechanicalBody(item, reason = "constraint-moved-body") {
|
||||
if (!item || !global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) return false;
|
||||
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
|
||||
// Constraint pulls sometimes mutate item.x/y as a scratch pose. Preserve
|
||||
// that pose in physicsBody before normalizing, otherwise normalizeBody()
|
||||
// can restore the old body pose and silently cancel the correction.
|
||||
|
|
@ -36,13 +35,6 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function constraintKick(move, dt, scale = 0.055, limit = 90) {
|
||||
// Constraint projection is primarily positional. Convert only a small,
|
||||
// capped part of the correction back to velocity so links do not inject
|
||||
// energy when the frame time spikes or endpoints start deeply overlapped.
|
||||
const step = Math.max(0.028, Math.min(0.055, Number(dt || 0.016) || 0.016));
|
||||
return clamp(move / step * scale, -limit, limit);
|
||||
}
|
||||
|
||||
function rectNearPointAabb(rect, x, y, padding = 0) {
|
||||
if (!rect) return false;
|
||||
|
|
@ -81,7 +73,8 @@
|
|||
item._lastConstraintSpatialDirtyY = y;
|
||||
item._lastConstraintSpatialDirtyR = r;
|
||||
item._nextConstraintSpatialDirtyAt = now + Math.max(0.016, Number(minInterval || 0.065) || 0.065);
|
||||
world.markSpatialDirty?.(reason);
|
||||
const marked = global.TarinaiPhysicsProjectionSystem.markSpatialDirty(world, item, reason, threshold, minInterval) ?? false;
|
||||
if (!marked) world.markSpatialDirty?.(reason);
|
||||
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.marked = (world.constraintDirtyStatsThisFrame.marked || 0) + 1;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -90,11 +83,11 @@
|
|||
}
|
||||
|
||||
function mechanicalPowered(item) {
|
||||
return Boolean(global.TarinaiMechanicalSystem?.isPowered?.(item));
|
||||
return Boolean(global.TarinaiMechanicalSystem.isPowered(item));
|
||||
}
|
||||
|
||||
function commitConstraintMovedMechanical(item, reason = "constraint-projection") {
|
||||
if (!item || !global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) return false;
|
||||
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
|
||||
bodyApi()?.syncPoseFromItem?.(item);
|
||||
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
|
||||
bodyApi()?.markBodyChanged?.(item, reason);
|
||||
|
|
@ -103,9 +96,9 @@
|
|||
return true;
|
||||
}
|
||||
function reciprocatorAxis(item) {
|
||||
if (global.TarinaiMechanicalSystem?.axis) return global.TarinaiMechanicalSystem.axis(item);
|
||||
const fallback = typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
|
||||
const a = typeof normalizedItemAngle === "function" ? normalizedItemAngle(ps(item, "railAxis", fallback), fallback) : ps(item, "railAxis", fallback);
|
||||
if (global.TarinaiMechanicalSystem.axis) return global.TarinaiMechanicalSystem.axis(item);
|
||||
const fallback = itemAngleFor(item);
|
||||
const a = normalizedItemAngle(ps(item, "railAxis", fallback), fallback);
|
||||
return { x: Math.cos(a), y: Math.sin(a), angle: a };
|
||||
}
|
||||
|
||||
|
|
@ -118,15 +111,6 @@
|
|||
if (!Number.isFinite(Number(ps(item, "railAnchorY", NaN)))) pset(item, "railAnchorY", item.y || 0, "constraint-anchor-init");
|
||||
}
|
||||
|
||||
function railPhaseFromPosition(item) {
|
||||
resetReciprocatorAnchorIfMissing(item);
|
||||
const axis = reciprocatorAxis(item);
|
||||
const travel = reciprocatorHalfTravel(item);
|
||||
const dx = (Number(item.x || 0) || 0) - ps(item, "railAnchorX", 0);
|
||||
const dy = (Number(item.y || 0) || 0) - ps(item, "railAnchorY", 0);
|
||||
return clamp((dx * axis.x + dy * axis.y) / Math.max(10, travel), -1, 1);
|
||||
}
|
||||
|
||||
function resolveEndpointObject(endpoint, worldRef) {
|
||||
if (!endpoint || !worldRef) return null;
|
||||
const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.fuzzyFallback === true;
|
||||
|
|
@ -170,11 +154,11 @@
|
|||
}
|
||||
|
||||
function itemAngle(item) {
|
||||
return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
|
||||
return itemAngleFor(item);
|
||||
}
|
||||
|
||||
function isMechanicalLinkTarget(item) {
|
||||
return Boolean(item && !item.dead && global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type));
|
||||
return Boolean(item && !item.dead && global.TarinaiMechanicalSystem.isMechanicalType(item.type));
|
||||
}
|
||||
|
||||
function localToWorld(item, lx, ly) {
|
||||
|
|
@ -196,7 +180,7 @@
|
|||
|
||||
function nearestMechanicalLocalPoint(item, lx, ly) {
|
||||
if (!isMechanicalLinkTarget(item)) return null;
|
||||
const segments = global.TarinaiMechanicalSystem?.sanitizeSegments?.(item) || [];
|
||||
const segments = global.TarinaiMechanicalSystem.sanitizeSegments(item) || [];
|
||||
let best = null;
|
||||
for (const seg of segments) {
|
||||
if (!Array.isArray(seg) || seg.length < 4) continue;
|
||||
|
|
@ -221,7 +205,7 @@
|
|||
|
||||
function mechanicalLocalPointSupported(item, lx, ly, endpoint = null) {
|
||||
if (!isMechanicalLinkTarget(item)) return true;
|
||||
const version = Number(item?._mechanicalShapeVersion || item?._segmentsVersion || 0) || 0;
|
||||
const version = Number(item?._mechanicalShapeVersion || 0) || 0;
|
||||
if (endpoint && endpoint._supportItemId === item.id && endpoint._supportCheckedVersion === version) return endpoint._supportAlive !== false;
|
||||
const nearest = nearestMechanicalLocalPoint(item, lx, ly);
|
||||
const alive = Boolean(nearest && nearest.d <= mechanicalSupportTolerance(item));
|
||||
|
|
@ -327,6 +311,17 @@
|
|||
return { x: (obj.x || 0) + lx * c - ly * s, y: (obj.y || 0) + lx * s + ly * c, obj };
|
||||
}
|
||||
|
||||
|
||||
function endpointCanRemainAnchored(endpoint, point) {
|
||||
if (!endpoint || !point) return false;
|
||||
if (point.obj && !point.obj.dead) return true;
|
||||
// When a linked Tarinai dies, its live id is released before link cleanup.
|
||||
// Keep the affected endpoint as a frozen world-space anchor instead of
|
||||
// deleting the rope/rod item; otherwise unrelated links can be swept up by
|
||||
// the lifecycle pass that removes zero-amount link items.
|
||||
return endpoint.kind === "tarinai" && Number.isFinite(Number(point.x)) && Number.isFinite(Number(point.y));
|
||||
}
|
||||
|
||||
function endpointMass(obj) {
|
||||
if (!obj || obj.dead) return Infinity;
|
||||
if (obj._heldByPlayer || obj.playerHeld) return Infinity;
|
||||
|
|
@ -334,10 +329,10 @@
|
|||
if (obj.type === "ball") return 0.7;
|
||||
if (obj.type === "zunchi") return 0.55;
|
||||
if (obj.type === "rope" || obj.type === "rod") return 1.6;
|
||||
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) {
|
||||
if (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) {
|
||||
if (obj.type === "rotator") return Infinity; // anchored pivot: links may torque it, not translate it
|
||||
if (obj.type === "reciprocator") return mechanicalPowered(obj) ? 18.0 : 5.8;
|
||||
if (obj.type === "poison_block") return Math.max(2.4, ps(obj, "mass", 4.8));
|
||||
if (obj.type === "poison_block") return Math.max(0.35, ps(obj, "mass", 0.45));
|
||||
return 8.0;
|
||||
}
|
||||
if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5;
|
||||
|
|
@ -361,14 +356,12 @@
|
|||
return changed;
|
||||
}
|
||||
if (obj.radius && obj.name) {
|
||||
obj.prevX = Number.isFinite(Number(obj.x)) ? Number(obj.x) : obj.prevX;
|
||||
obj.prevY = Number.isFinite(Number(obj.y)) ? Number(obj.y) : obj.prevY;
|
||||
obj.x += moveX;
|
||||
obj.y += moveY;
|
||||
const kickScale = mode === "rod" ? 0.038 : 0.052;
|
||||
const kickLimit = mode === "rod" ? 88 : 105;
|
||||
obj.vx = clamp((obj.vx || 0) + constraintKick(moveX, dt, kickScale, kickLimit), -150, 150);
|
||||
obj.vy = clamp((obj.vy || 0) + constraintKick(moveY, dt, kickScale, kickLimit), -150, 150);
|
||||
if (obj.state === "sleep" || obj.sleeping) {
|
||||
global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(obj, dt, `constraint-${mode}`);
|
||||
global.TarinaiMovementUpdateStep.markSleepExternalMotion(obj, dt, `constraint-${mode}`);
|
||||
obj._sleepExternalMotionUntil = Math.max(Number(obj._sleepExternalMotionUntil || 0) || 0, (Number(obj.world?.time || 0) || 0) + (mode === "rod" ? 1.1 : 0.6));
|
||||
}
|
||||
obj.target = null;
|
||||
|
|
@ -384,8 +377,8 @@
|
|||
// Project the rail body along its single legal axis. Powered motors are
|
||||
// intentionally stiff, so a rope/rod can slow them but not yank them far
|
||||
// off their drive path in one frame.
|
||||
const railScale = mode === "rod" ? (powered ? 0.58 : 0.94) : (powered ? 0.22 : 0.78);
|
||||
const railCap = mode === "rod" ? (powered ? 34 : 42) : 18;
|
||||
const railScale = mode === "rod" ? (powered ? 0.46 : 0.76) : (powered ? 0.22 : 0.78);
|
||||
const railCap = mode === "rod" ? (powered ? 22 : 30) : 18;
|
||||
const along = clamp(alongRaw * railScale, -railCap, railCap);
|
||||
if (Math.abs(along) < 0.001) return false;
|
||||
const beforePhase = ps(obj, "railPhase", 0);
|
||||
|
|
@ -398,17 +391,14 @@
|
|||
obj.y = next.y;
|
||||
}
|
||||
const changed = Math.abs(ps(obj, "railPhase", 0) - beforePhase) > 0.0001;
|
||||
const current = ps(obj, "slideSpeed", 0);
|
||||
const kick = constraintKick(along, dt, mode === "rod" ? (powered ? 0.0035 : 0.009) : (powered ? 0.0045 : 0.012), mode === "rod" ? (powered ? 18 : 44) : (powered ? 22 : 58));
|
||||
pset(obj, "slideSpeed", clamp(current * (powered ? 0.74 : 0.88) + kick, -120, 120), "constraint-slide-project");
|
||||
if (changed) commitConstraintMovedMechanical(obj, "constraint-reciprocator-project");
|
||||
return changed;
|
||||
}
|
||||
if (obj.type === "poison_block") {
|
||||
const beforeX = Number(obj.x || 0) || 0;
|
||||
const beforeY = Number(obj.y || 0) || 0;
|
||||
const blockScale = mode === "rod" ? 0.92 : 0.72;
|
||||
const blockCap = mode === "rod" ? 34 : 18;
|
||||
const blockScale = mode === "rod" ? 0.74 : 0.72;
|
||||
const blockCap = mode === "rod" ? 24 : 18;
|
||||
const px = clamp(moveX * blockScale, -blockCap, blockCap);
|
||||
const py = clamp(moveY * blockScale, -blockCap, blockCap);
|
||||
if (Math.hypot(px, py) < 0.001) return false;
|
||||
|
|
@ -416,26 +406,8 @@
|
|||
obj.prevY = beforeY;
|
||||
obj.x = beforeX + px;
|
||||
obj.y = beforeY + py;
|
||||
const len = Math.max(0.001, Math.hypot(px, py));
|
||||
const nx = px / len;
|
||||
const ny = py / len;
|
||||
const vx = ps(obj, "xv", obj.vx || 0);
|
||||
const vy = ps(obj, "yv", obj.vy || 0);
|
||||
const intoCorrection = vx * nx + vy * ny;
|
||||
let nextVx = vx;
|
||||
let nextVy = vy;
|
||||
if (intoCorrection < 0) {
|
||||
nextVx -= nx * intoCorrection * 0.50;
|
||||
nextVy -= ny * intoCorrection * 0.50;
|
||||
}
|
||||
nextVx += constraintKick(px, dt, 0.012, 36);
|
||||
nextVy += constraintKick(py, dt, 0.012, 36);
|
||||
pset(obj, "xv", clamp(nextVx, -165, 165), "constraint-poison-project");
|
||||
pset(obj, "yv", clamp(nextVy, -165, 165), "constraint-poison-project");
|
||||
obj.vx = ps(obj, "xv", obj.vx || 0);
|
||||
obj.vy = ps(obj, "yv", obj.vy || 0);
|
||||
commitConstraintMovedMechanical(obj, "constraint-poison-project");
|
||||
global.TarinaiMechanicalSystem?.wakeItem?.(obj, "constraint-poison-project");
|
||||
global.TarinaiMechanicalSystem.wakeItem(obj, "constraint-poison-project");
|
||||
return true;
|
||||
}
|
||||
if (obj.type === "rotator") {
|
||||
|
|
@ -456,56 +428,9 @@
|
|||
obj.prevY = obj.y;
|
||||
obj.x += moveX;
|
||||
obj.y += moveY;
|
||||
obj.vx = clamp((obj.vx || 0) + constraintKick(moveX, dt, mode === "rod" ? 0.032 : 0.042, mode === "rod" ? 62 : 78), -125, 125);
|
||||
obj.vy = clamp((obj.vy || 0) + constraintKick(moveY, dt, mode === "rod" ? 0.032 : 0.042, mode === "rod" ? 62 : 78), -125, 125);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateFlexibleMidParticle(item, a, b, dt, length) {
|
||||
if (!a || !b || item.type !== "rope") return false;
|
||||
const ax = a.x, ay = a.y, bx = b.x, by = b.y;
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const d = Math.max(0.001, Math.hypot(dx, dy));
|
||||
if (!Number.isFinite(ls(item, "midX", NaN)) || !Number.isFinite(ls(item, "midY", NaN))) {
|
||||
lset(item, "midX", (ax + bx) * 0.5);
|
||||
lset(item, "midY", (ay + by) * 0.5 + Math.max(4, (length - d) * 0.25));
|
||||
lset(item, "midVx", 0);
|
||||
lset(item, "midVy", 0);
|
||||
}
|
||||
const prevX = ls(item, "midX", item.x || 0);
|
||||
const prevY = ls(item, "midY", item.y || 0);
|
||||
const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016));
|
||||
lset(item, "midVx", ls(item, "midVx", 0) * Math.pow(0.62, step * 60));
|
||||
lset(item, "midVy", ls(item, "midVy", 0) * Math.pow(0.62, step * 60) + 90 * step);
|
||||
lset(item, "midX", ls(item, "midX", 0) + ls(item, "midVx", 0) * step);
|
||||
lset(item, "midY", ls(item, "midY", 0) + ls(item, "midVy", 0) * step);
|
||||
const half = Math.max(12, length * 0.5);
|
||||
if (d >= length * 0.985) {
|
||||
lset(item, "midX", (ax + bx) * 0.5);
|
||||
lset(item, "midY", (ay + by) * 0.5);
|
||||
lset(item, "midVx", ls(item, "midVx", 0) * 0.25);
|
||||
lset(item, "midVy", ls(item, "midVy", 0) * 0.25);
|
||||
} else {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
for (const pnt of [[ax, ay], [bx, by]]) {
|
||||
const vx = ls(item, "midX", 0) - pnt[0];
|
||||
const vy = ls(item, "midY", 0) - pnt[1];
|
||||
const dd = Math.max(0.001, Math.hypot(vx, vy));
|
||||
if (dd > half) {
|
||||
lset(item, "midX", pnt[0] + vx / dd * half);
|
||||
lset(item, "midY", pnt[1] + vy / dd * half);
|
||||
}
|
||||
}
|
||||
}
|
||||
const minY = Math.min(ay, by) - length * 0.35;
|
||||
const maxY = Math.max(ay, by) + length * 0.70;
|
||||
lset(item, "midY", clamp(ls(item, "midY", 0), minY, maxY));
|
||||
}
|
||||
lset(item, "midVx", clamp((ls(item, "midX", 0) - prevX) / step, -220, 220));
|
||||
lset(item, "midVy", clamp((ls(item, "midY", 0) - prevY) / step, -220, 220));
|
||||
return Math.hypot(ls(item, "midX", 0) - prevX, ls(item, "midY", 0) - prevY) > 0.01;
|
||||
}
|
||||
|
||||
function applyDistanceConstraint(item, a, b, dt, length, mode) {
|
||||
const solveOnce = (pa, pb, strength, maxFraction) => {
|
||||
if (!pa || !pb || !pa.obj || !pb.obj) return false;
|
||||
|
|
@ -514,7 +439,7 @@
|
|||
const d = Math.max(0.001, Math.hypot(dx, dy));
|
||||
const delta = d - length;
|
||||
if (mode === "rope" && delta <= 0) return false;
|
||||
if (mode === "rod" && Math.abs(delta) < 0.35) return false;
|
||||
if (mode === "rod" && Math.abs(delta) < 0.85) return false;
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
const ma = endpointMass(pa.obj);
|
||||
|
|
@ -523,7 +448,7 @@
|
|||
const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0;
|
||||
const sum = ia + ib || 1;
|
||||
const step = Math.max(0.012, Math.min(0.05, Number(dt || 0.016) || 0.016));
|
||||
const compliance = mode === "rod" ? 0.035 / (step * 60) : 0.46 / (step * 60);
|
||||
const compliance = mode === "rod" ? 0.085 / (step * 60) : 0.46 / (step * 60);
|
||||
const target = delta / (1 + compliance);
|
||||
const cap = mode === "rod"
|
||||
? Math.max(6.0, Math.min(46, length * maxFraction))
|
||||
|
|
@ -538,11 +463,11 @@
|
|||
if (mode === "rope") {
|
||||
changed = solveOnce(a, b, 0.58, 0.044) || changed;
|
||||
} else {
|
||||
const iterations = 7;
|
||||
const iterations = 5;
|
||||
for (let i = 0; i < iterations; i += 1) {
|
||||
const aa = endpointWorld(ep(item, 0), item.world || null) || a;
|
||||
const bb = endpointWorld(ep(item, 1), item.world || null) || b;
|
||||
changed = solveOnce(aa, bb, 0.96, 0.18) || changed;
|
||||
changed = solveOnce(aa, bb, 0.82, 0.10) || changed;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
|
|
@ -561,7 +486,7 @@
|
|||
}
|
||||
|
||||
function ropeLodSettings(worldRef, length) {
|
||||
const tier = global.TarinaiPerf?.renderQualityTier?.() || "high";
|
||||
const tier = global.TarinaiPerf.renderQualityTier() || "high";
|
||||
const ropeCount = Number(worldRef?.itemCounts?.rope || 0) || 0;
|
||||
let spacing = 34;
|
||||
let maxParticles = 24;
|
||||
|
|
@ -614,11 +539,7 @@
|
|||
}
|
||||
|
||||
function rectLocalPointForConstraint(r, x, y) {
|
||||
const dx = x - (r.cx ?? 0);
|
||||
const dy = y - (r.cy ?? 0);
|
||||
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
||||
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
||||
return { x: dx * c + dy * s, y: -dx * s + dy * c };
|
||||
return global.TarinaiCollisionFootprints.rectLocalPoint(r, x, y);
|
||||
}
|
||||
|
||||
function rectWorldNormalForConstraint(r, nx, ny) {
|
||||
|
|
@ -661,7 +582,7 @@
|
|||
if (d < 0.001) { dx = 0; dy = -1; d = 1; }
|
||||
nx = dx / d; ny = dy / d; overlap = radius - d;
|
||||
}
|
||||
const push = Math.min(Math.max(0, overlap) + 0.35, Math.max(4, radius * 0.9));
|
||||
const push = Math.min(Math.max(0, overlap), Math.max(2, radius * 0.9));
|
||||
p.x += nx * push;
|
||||
p.y += ny * push;
|
||||
// Do not let collision projection become a full Verlet velocity on the next frame.
|
||||
|
|
@ -782,15 +703,10 @@
|
|||
if (d >= minD) continue;
|
||||
if (d < 0.001) { d = 1; }
|
||||
const nx = dx / d || 0, ny = dy / d || -1;
|
||||
const push = Math.min(minD - d + 0.4, Math.max(6, minD * 0.56));
|
||||
p.x += nx * push * 0.82;
|
||||
p.y += ny * push * 0.82;
|
||||
t.x -= nx * push * 0.10;
|
||||
t.y -= ny * push * 0.10;
|
||||
t.vx = clamp((t.vx || 0) - nx * 16, -160, 160);
|
||||
t.vy = clamp((t.vy || 0) - ny * 16, -160, 160);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.08);
|
||||
c = true;
|
||||
const push = Math.min(Math.max(0, minD - d), Math.max(3, minD * 0.48));
|
||||
p.x += nx * push;
|
||||
p.y += ny * push;
|
||||
c = push > 0.001 || c;
|
||||
}
|
||||
for (const rect of getRopeObstacleRects()) {
|
||||
if (rect?.item?.id && endpointIds.has(rect.item.id)) continue;
|
||||
|
|
@ -821,8 +737,6 @@
|
|||
for (let i = 0; i < ps.length - 1; i += Math.max(1, lod.particleStride)) {
|
||||
const p1 = ps[i], p2 = ps[i + 1];
|
||||
if (!p1 || !p2) continue;
|
||||
const mx = (p1.x + p2.x) * 0.5;
|
||||
const my = (p1.y + p2.y) * 0.5;
|
||||
for (const rect of getRopeObstacleRects()) {
|
||||
if (rect?.item?.id && endpointIds.has(rect.item.id)) continue;
|
||||
const pad = rect?.mechanical ? 8.8 : 7.0;
|
||||
|
|
@ -855,7 +769,7 @@
|
|||
item.world = worldRef || item.world || null;
|
||||
const a = endpointWorld(ep(item, 0), worldRef);
|
||||
const b = endpointWorld(ep(item, 1), worldRef);
|
||||
if (!a || !b || !a.obj || !b.obj) {
|
||||
if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) {
|
||||
item.amount = 0;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return true;
|
||||
|
|
@ -867,7 +781,7 @@
|
|||
let changed = applyDistanceConstraint(item, a, b, dt, length, "rope");
|
||||
const aa = changed ? endpointWorld(ep(item, 0), worldRef) : a;
|
||||
const bb = changed ? endpointWorld(ep(item, 1), worldRef) : b;
|
||||
if (!aa || !bb || !aa.obj || !bb.obj) {
|
||||
if (!endpointCanRemainAnchored(ep(item, 0), aa) || !endpointCanRemainAnchored(ep(item, 1), bb)) {
|
||||
item.amount = 0;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return true;
|
||||
|
|
@ -890,7 +804,7 @@
|
|||
item.world = worldRef || item.world || null;
|
||||
const a = endpointWorld(ep(item, 0), worldRef);
|
||||
const b = endpointWorld(ep(item, 1), worldRef);
|
||||
if (!a || !b || !a.obj || !b.obj) {
|
||||
if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) {
|
||||
item.amount = 0;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return true;
|
||||
|
|
@ -906,7 +820,7 @@
|
|||
const ca = endpointWorld(ep(item, 0), worldRef) || aa;
|
||||
const cb = endpointWorld(ep(item, 1), worldRef) || bb;
|
||||
const stretch = ca && cb ? Math.abs(distXY(ca.x, ca.y, cb.x, cb.y) - length) : 0;
|
||||
if (stretch > Math.max(0.75, length * 0.006)) {
|
||||
if (stretch > Math.max(1.6, length * 0.012)) {
|
||||
item._rodStretchCorrectionCount = (item._rodStretchCorrectionCount || 0) + 1;
|
||||
item._rodLastStretch = stretch;
|
||||
changed = applyDistanceConstraint(item, ca, cb, Math.min(0.033, dt || 0.016), length, "rod") || changed;
|
||||
|
|
@ -946,13 +860,18 @@
|
|||
|
||||
function endpointRuntimeStamp(endpoint, worldRef) {
|
||||
const obj = resolveEndpointObject(endpoint, worldRef);
|
||||
if (!obj || obj.dead) return { stamp: "missing", missing: true, obj: null };
|
||||
const q = (v, scale = 10) => Math.round((Number(v) || 0) * scale);
|
||||
if (!obj || obj.dead) {
|
||||
if (endpoint?.kind === "tarinai" && Number.isFinite(Number(endpoint.x)) && Number.isFinite(Number(endpoint.y))) {
|
||||
return { stamp: `tarinai-anchor:${endpoint.id || "?"}:${q(endpoint.x)}:${q(endpoint.y)}`, missing: false, obj: null };
|
||||
}
|
||||
return { stamp: "missing", missing: true, obj: null };
|
||||
}
|
||||
let stamp = `${endpoint?.kind || "item"}:${obj.id || "?"}:${obj.type || (obj.name ? "tarinai" : "entity")}:${q(obj.x)}:${q(obj.y)}`;
|
||||
if (obj.radius && obj.name) stamp += `:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${obj.state || ""}`;
|
||||
else if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) {
|
||||
const a = typeof itemAngleFor === "function" ? itemAngleFor(obj) : (Number(obj.angle) || 0);
|
||||
const shape = Number(obj._mechanicalShapeVersion || obj._segmentsVersion || 0) || 0;
|
||||
else if (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) {
|
||||
const a = itemAngleFor(obj);
|
||||
const shape = Number(obj._mechanicalShapeVersion || 0) || 0;
|
||||
stamp += `:${q(a, 1000)}:${shape}:${ps(obj, "motorOn", true) !== false ? 1 : 0}:${ps(obj, "railOn", true) !== false ? 1 : 0}:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${q(ps(obj, "spin", 0), 1000)}:${q(ps(obj, "slideSpeed", 0), 4)}`;
|
||||
} else stamp += `:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${Number(obj.amount || 0) > 0 ? 1 : 0}`;
|
||||
if (Number.isFinite(Number(endpoint?.localX)) || Number.isFinite(Number(endpoint?.localY))) stamp += `:l${q(endpoint.localX)}:${q(endpoint.localY)}`;
|
||||
|
|
@ -1022,7 +941,7 @@
|
|||
function updateWorld(worldRef, dt = 0.016, opts = {}) {
|
||||
if (!worldRef?.itemsOfType) return 0;
|
||||
const profiler = global.TarinaiPerf;
|
||||
const end = profiler?.begin?.("update.constraints") || null;
|
||||
const end = profiler.begin("update.constraints") || null;
|
||||
try {
|
||||
worldRef.ensureItemBuckets?.("constraint-world");
|
||||
const maxLinks = Math.max(8, Number(opts.maxLinks || 160) || 160);
|
||||
|
|
@ -1033,6 +952,8 @@
|
|||
worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 };
|
||||
return 0;
|
||||
}
|
||||
// Rotate the starting point each frame so a large rope/rod set does not
|
||||
// starve low-score links that are always beyond the per-frame budget.
|
||||
const cursor = Math.max(0, Math.min(links.length - 1, Number(worldRef._constraintBudgetCursor || 0) || 0));
|
||||
const ordered = links.slice(cursor).concat(links.slice(0, cursor));
|
||||
let ran = 0;
|
||||
|
|
@ -1052,6 +973,8 @@
|
|||
}
|
||||
pending.push({ item, score: linkScheduleScore(item, worldRef) });
|
||||
}
|
||||
// Active/strained links run first, but cursor rotation above preserves
|
||||
// fairness when maxLinks prevents solving every pending link.
|
||||
pending.sort((a, b) => (b.score - a.score) || ((a.item.id || 0) - (b.item.id || 0)));
|
||||
for (const entry of pending) {
|
||||
if (ran >= maxLinks) break;
|
||||
|
|
@ -1083,17 +1006,9 @@
|
|||
}
|
||||
|
||||
const api = Object.freeze({
|
||||
endpointWorld,
|
||||
resolveEndpointObject,
|
||||
endpointMass,
|
||||
moveEndpointObject,
|
||||
snapEndpointToTarget,
|
||||
remapLinksForEditedMechanicalItem,
|
||||
updateFlexibleLink,
|
||||
updateRigidLink,
|
||||
updateLink,
|
||||
updateWorld,
|
||||
pointSegmentDistance,
|
||||
});
|
||||
|
||||
global.TarinaiConstraintSystem = api;
|
||||
|
|
|
|||
92
js/data.js
|
|
@ -26,73 +26,23 @@ const SPRITES = [
|
|||
{ id: "stress_dizzy", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3050\u308b\u3050\u308b", path: "assets/sprites/tarinai_24_stress_dizzy.webp", actionOnly: true },
|
||||
{ id: "stress_sweat", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u6c57", path: "assets/sprites/tarinai_25_stress_sweat.webp", actionOnly: true },
|
||||
{ id: "intimidate", label: "\u5a01\u5687\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_26_intimidate.webp", actionOnly: true },
|
||||
{ id: "birth_ritual", label: "へこへこたりない", path: "assets/sprites/tarinai_27_birth_ritual.webp", actionOnly: true },
|
||||
{ id: "birth_ritual", label: "\u3078\u3053\u3078\u3053\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_27_birth_ritual.webp", actionOnly: true },
|
||||
{ id: "zunchi_slave", label: "\u305a\u3093\u3061\u3069\u308c\u3044", path: "assets/sprites/tarinai_28_zunchi_slave.webp", displayOnly: true },
|
||||
{ id: "hungry_70", label: "\u7a7a\u817970\u4ee5\u4e0a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_29_hungry_70.webp", displayOnly: true },
|
||||
{ id: "zunchi_slave_alt", label: "\u305a\u3093\u3061\u3069\u308c\u3044 2", path: "assets/sprites/tarinai_30_zunchi_slave_alt.webp", displayOnly: true },
|
||||
{ id: "sunbath_1", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 1", path: "assets/sprites/tarinai_31_sunbath_01.webp", actionOnly: true },
|
||||
{ id: "sunbath_2", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 2", path: "assets/sprites/tarinai_32_sunbath_02.webp", actionOnly: true },
|
||||
{ id: "sunbath_3", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 3", path: "assets/sprites/tarinai_33_sunbath_03.webp", actionOnly: true },
|
||||
|
||||
{ id: "hot_1", label: "\u6691\u3044\u305f\u308a\u306a\u3044 1", path: "assets/sprites/tarinai_34_hot_01.webp", displayOnly: true },
|
||||
{ id: "hot_2", label: "\u6691\u3044\u305f\u308a\u306a\u3044 2", path: "assets/sprites/tarinai_35_hot_02.webp", displayOnly: true },
|
||||
{ id: "hot_3", label: "\u6691\u3044\u305f\u308a\u306a\u3044 3", path: "assets/sprites/tarinai_36_hot_03.webp", displayOnly: true },
|
||||
{ id: "cold_1", label: "\u5bd2\u3044\u305f\u308a\u306a\u3044 1", path: "assets/sprites/tarinai_37_cold_01.webp", displayOnly: true },
|
||||
{ id: "cold_2", label: "\u5bd2\u3044\u305f\u308a\u306a\u3044 2", path: "assets/sprites/tarinai_38_cold_02.webp", displayOnly: true },
|
||||
{ id: "cold_3", label: "\u5bd2\u3044\u305f\u308a\u306a\u3044 3", path: "assets/sprites/tarinai_39_cold_03.webp", displayOnly: true },
|
||||
];
|
||||
const DECOR_ASSETS = [
|
||||
{ id: "zunchi", path: "assets/objects/zunchi.webp" },
|
||||
{ id: "zunchi_02", path: "assets/objects/zunchi_02.webp" },
|
||||
{ id: "ant_queen", path: "assets/objects/ant_queen.webp" },
|
||||
{ id: "ant_worker", path: "assets/objects/ant_worker.webp" },
|
||||
{ id: "genkotsu", path: "assets/objects/genkotsu.webp" },
|
||||
{ id: "pushpin", path: "assets/objects/pushpin.webp" },
|
||||
{ id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" },
|
||||
{ id: "oshibyo", path: "assets/objects/oshibyo.webp" },
|
||||
{ id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" },
|
||||
{ id: "plushie_bear", path: "assets/objects/plushie_bear.png" },
|
||||
];
|
||||
|
||||
const ALPHA_BOUNDS = {
|
||||
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 },
|
||||
angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
|
||||
teary: { x: 17, y: 17, w: 478, h: 438, imageW: 512, imageH: 472 },
|
||||
jito: { x: 18, y: 18, w: 476, h: 451, imageW: 512, imageH: 487 },
|
||||
drool: { x: 18, y: 18, w: 476, h: 425, imageW: 512, imageH: 461 },
|
||||
cry: { x: 15, y: 15, w: 482, h: 448, imageW: 512, imageH: 478 },
|
||||
sleep: { x: 18, y: 18, w: 475, h: 439, imageW: 511, imageH: 475 },
|
||||
pokan: { x: 18, y: 18, w: 476, h: 433, imageW: 512, imageH: 469 },
|
||||
stretch: { x: 15, y: 15, w: 482, h: 354, imageW: 512, imageH: 384 },
|
||||
hurt: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
weak: { x: 53, y: 18, w: 405, h: 435, imageW: 512, imageH: 468 },
|
||||
back: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
flee: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
zunda_eat: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
|
||||
hurt2: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
normal_smirk: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
normal_tongue: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
fear: { x: 16, y: 20, w: 480, h: 434, imageW: 512, imageH: 468 },
|
||||
flee_fear2: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
sleep2: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
normal_happy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
fear_blue: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
fear_cry: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
|
||||
stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
|
||||
intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 },
|
||||
zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 },
|
||||
hungry_70: { x: 0, y: 0, w: 430, h: 366, imageW: 430, imageH: 366 },
|
||||
zunchi_slave_alt: { x: 42, y: 22, w: 425, h: 426, imageW: 512, imageH: 468 },
|
||||
zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 },
|
||||
zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 },
|
||||
ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 },
|
||||
ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 },
|
||||
genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 },
|
||||
sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
|
||||
sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 },
|
||||
sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
|
||||
plushie_bear: { x: 0, y: 0, w: 150, h: 192, imageW: 150, imageH: 192 },
|
||||
};
|
||||
|
||||
const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"];
|
||||
|
||||
const CONFIG = {
|
||||
initialPopulation: 18,
|
||||
maxPopulation: Number.MAX_SAFE_INTEGER,
|
||||
maxFenceCollisionChecks: 18,
|
||||
effectLimit: 78,
|
||||
grassLimit: 99,
|
||||
|
|
@ -101,22 +51,34 @@ const CONFIG = {
|
|||
hungerPerMinute: 36.0,
|
||||
lonelinessPerMinute: 7.6,
|
||||
energyPerMinute: 8.2,
|
||||
stressDecayPerMinute: 10.2,
|
||||
reproductionCooldown: 18,
|
||||
dayLength: 120,
|
||||
standardTemperature: 15,
|
||||
climateTemperatureMin: -20,
|
||||
climateTemperatureMax: 50,
|
||||
temperatureMin: -Infinity,
|
||||
temperatureMax: Infinity,
|
||||
temperatureComfortMin: 10,
|
||||
temperatureComfortMax: 25,
|
||||
temperatureStressMargin: 5,
|
||||
temperatureEnergyMargin: 10,
|
||||
temperatureItemRange: 127,
|
||||
childGrowTime: 58,
|
||||
fightCooldown: 8,
|
||||
lightingQuality: "cinematic",
|
||||
weatherChangeMin: 46,
|
||||
weatherChangeMax: 96,
|
||||
bubbleLimit: 32,
|
||||
highPopulationMode: 70,
|
||||
ultraPopulationMode: 120,
|
||||
};
|
||||
const FIELD_LAYOUT = Object.freeze({
|
||||
height: "calc(100vh - 140px)",
|
||||
minHeight: "620px",
|
||||
mobileHeight: "66vh",
|
||||
mobileMinHeight: "460px",
|
||||
});
|
||||
const FIELD_TYPES = {
|
||||
cage: { id: "cage", label: "\u30b1\u30fc\u30b8", sizeLabel: "\u5c0f", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 0.46, population: 12, grass: 16 },
|
||||
garden: { id: "garden", label: "\u5ead", sizeLabel: "\u4e2d", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 34 },
|
||||
park: { id: "park", label: "\u516c\u5712", sizeLabel: "\u5927", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 2.35, population: 24, grass: 62 },
|
||||
cage: { id: "cage", worldScale: 0.46, population: 12, grass: 16 },
|
||||
garden: { id: "garden", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 34 },
|
||||
park: { id: "park", worldScale: 2.35, population: 24, grass: 62 },
|
||||
};
|
||||
const LIGHTING_TUNING = {
|
||||
rayAlpha: 0.040,
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@
|
|||
|
||||
let lastEvents = [];
|
||||
let lastAudio = [];
|
||||
window.TarinaiEvents?.on("*", ev => { lastEvents.unshift(ev.type); if (lastEvents.length > 10) lastEvents.length = 10; });
|
||||
window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; });
|
||||
window.TarinaiEvents.on("*", ev => { lastEvents.unshift(ev.type); if (lastEvents.length > 10) lastEvents.length = 10; });
|
||||
window.TarinaiEvents.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; });
|
||||
|
||||
function fmtMs(v) {
|
||||
const n = Number(v || 0);
|
||||
|
|
@ -131,13 +131,14 @@
|
|||
return hints.join(" ") || "-";
|
||||
}
|
||||
|
||||
const DEBUG_REFRESH_MS = 750;
|
||||
window.setInterval(() => {
|
||||
const w = window.world;
|
||||
const input = window.TarinaiInputMode?.snapshot?.() || {};
|
||||
const input = window.TarinaiInputMode.snapshot() || {};
|
||||
const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {};
|
||||
const behaviorCounts = diag.behavior?.counts || {};
|
||||
const topBehaviors = topMap(behaviorCounts, 5);
|
||||
const perf = diag.perf || window.TarinaiPerf?.snapshot?.() || {};
|
||||
const perf = diag.perf || window.TarinaiPerf.snapshot() || {};
|
||||
const metrics = perf.metrics || {};
|
||||
const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?";
|
||||
const creature = diag.creatures || {};
|
||||
|
|
@ -154,7 +155,7 @@
|
|||
`spatial dirtyReasons ${topMap(diag.spatial?.dirtyReasons, 4)} rebuildReasons ${topMap(diag.spatial?.rebuildReasons, 4)}`,
|
||||
`nearby obstacleRects hit:${diag.nearby?.obstacleRectHits ?? 0} miss:${diag.nearby?.obstacleRectMisses ?? 0} built:${diag.nearby?.obstacleRectBuilt ?? 0} bypass:${diag.nearby?.obstacleRectBypass ?? 0} constraintDirty mark:${diag.constraintDirty?.marked ?? 0} coal:${diag.constraintDirty?.coalesced ?? 0}`,
|
||||
`scheduler heap:${scheduler.heap ?? 0} realtime:${scheduler.activeRealtime ?? scheduler.realtime ?? 0} ran:${scheduler.lastRan ?? 0} itemRt:${scheduler.realtimeRan ?? 0} itemDue:${scheduler.dueRan ?? 0} constraints:${scheduler.constraints ?? 0} postPairs:${scheduler.postConstraintPairs ?? 0} rodStretch:${fmtMs(scheduler.constraintStats?.rodStretchMax || 0)} corr:${scheduler.constraintStats?.rodCorrections ?? 0}`,
|
||||
`items active:${scheduler.activeRealtime ?? 0} asleep:${scheduler.sleeping ?? scheduler.heap ?? 0} budgetPairs:${scheduler.physicsBudget?.maxPairs ?? "?"} substeps:${scheduler.physics?.mechanical?.substeps ?? scheduler.mechanicalStats?.substeps ?? "?"}`,
|
||||
`items active:${scheduler.activeRealtime ?? 0} asleep:${scheduler.sleeping ?? scheduler.heap ?? 0} budgetPairs:${scheduler.physicsBudget?.maxPairs ?? "?"} substeps:${scheduler.physics?.mechanical?.substeps ?? scheduler.mechanicalStats?.substeps ?? "?"} fenceCCD:${Boolean((scheduler.physics?.mechanical || scheduler.mechanicalStats)?.fenceSensitive)}`,
|
||||
`terrain dirty raw:${terrain.raw ?? 0} global:${terrain.global ?? 0} chunk:${terrain.chunk ?? 0} coalesced:${terrain.coalesced ?? 0} reasons:${topMap(terrain.reasons, 4)}`,
|
||||
`work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`,
|
||||
`hints ${diagnosticHints(diag, perf)}`,
|
||||
|
|
@ -164,7 +165,7 @@
|
|||
`item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} bucketRebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`,
|
||||
`dpr ${currentDpr} cache ${window.TARINAI_APP?.cacheName || "?"} sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"}`,
|
||||
`input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)}`,
|
||||
`audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`,
|
||||
`audio ${lastAudio.join(", ") || window.TarinaiAudio.lastPlayedId || "-"}`,
|
||||
`events ${lastEvents.join(", ")}`,
|
||||
];
|
||||
lastText = lines.join("\n");
|
||||
|
|
@ -173,5 +174,5 @@
|
|||
: `tarinai debug fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms`;
|
||||
if (Date.now() >= copiedMessageUntil && copyBtn.textContent !== "Copy") copyBtn.textContent = "Copy";
|
||||
if (!minimized) body.textContent = lastText;
|
||||
}, 500);
|
||||
}, DEBUG_REFRESH_MS);
|
||||
})();
|
||||
|
|
|
|||
44
js/deterministic_helpers.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: core/deterministic
|
||||
// Shared deterministic pseudo-random helpers used by behavior, item runtime,
|
||||
// and visual effects. Previously this bootstrap lived inline in several
|
||||
// modules; keeping it here prevents helper drift and load-order surprises.
|
||||
(function ensureDeterministicHelpers(global) {
|
||||
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
|
||||
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
|
||||
const keyPart = value => {
|
||||
if (value == null) return "null";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
|
||||
if (typeof value === "string" || typeof value === "boolean") return String(value);
|
||||
if (typeof value === "object") {
|
||||
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
|
||||
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
|
||||
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
|
||||
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
|
||||
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const hashUnit = (seed, salt = "") => {
|
||||
const str = `${seed}:${salt}`;
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||
return ((h >>> 0) % 100000) / 100000;
|
||||
};
|
||||
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
|
||||
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
|
||||
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
|
||||
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
|
||||
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
|
||||
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
// Keep the historical bare helper names available to legacy scripts while the
|
||||
// implementation lives on globalThis.
|
||||
var deterministicFrame = (typeof window !== "undefined" ? window : globalThis).deterministicFrame;
|
||||
var deterministicUnit = (typeof window !== "undefined" ? window : globalThis).deterministicUnit;
|
||||
var deterministicRange = (typeof window !== "undefined" ? window : globalThis).deterministicRange;
|
||||
var deterministicSigned = (typeof window !== "undefined" ? window : globalThis).deterministicSigned;
|
||||
var deterministicChance = (typeof window !== "undefined" ? window : globalThis).deterministicChance;
|
||||
var deterministicAngle = (typeof window !== "undefined" ? window : globalThis).deterministicAngle;
|
||||
|
|
@ -5,59 +5,34 @@
|
|||
disease_zunchi: {
|
||||
id: "disease_zunchi",
|
||||
flag: "zunchiDisease",
|
||||
label: "\u305a\u3093\u3061\u75c5",
|
||||
symptoms: ["stress", "energy_loss", "infection"],
|
||||
displayText: "\u305a\u3093\u3061\u75c5",
|
||||
color: "rgba(113,93,59,0.74)",
|
||||
mysteryEligible: true,
|
||||
cureRules: ["water", "natural_recovery"]
|
||||
},
|
||||
disease_sleep: {
|
||||
id: "disease_sleep",
|
||||
flag: "sleepDisease",
|
||||
label: "\u306d\u3080\u308a\u75c5",
|
||||
symptoms: ["sleep_lock", "no_counterattack"],
|
||||
displayText: "\u306d\u3080\u308a\u75c5",
|
||||
color: "rgba(104,84,168,0.78)",
|
||||
mysteryEligible: true,
|
||||
cureRules: ["damage"]
|
||||
},
|
||||
disease_explosion: {
|
||||
id: "disease_explosion",
|
||||
flag: "explosionDisease",
|
||||
label: "\u7206\u767a\u75c5",
|
||||
symptoms: ["explodes_after_timer", "water_seeking"],
|
||||
displayText: "\u7206\u767a\u75c5",
|
||||
color: "rgba(210,90,44,0.78)",
|
||||
baseInfectionChance: 0.15,
|
||||
mysteryEligible: true,
|
||||
cureRules: ["water", "zunda_mochi"]
|
||||
},
|
||||
disease_fight: {
|
||||
id: "disease_fight",
|
||||
flag: "fightDisease",
|
||||
label: "\u304d\u305a\u3064\u304d\u75c5",
|
||||
symptoms: ["fight_lash_out", "zunda_seeking"],
|
||||
displayText: "\u304d\u305a\u3064\u304d\u75c5",
|
||||
color: "rgba(180,86,52,0.80)",
|
||||
mysteryEligible: true,
|
||||
cureRules: ["zunda_mochi", "natural_recovery"]
|
||||
}
|
||||
};
|
||||
|
||||
class DiseaseRegistry extends global.TarinaiRegistryBase {
|
||||
byFlag(flag = "") { return [...this.defs.values()].find(def => def.flag === flag) || null; }
|
||||
label(id = "") { return super.label(id, id || ""); }
|
||||
color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
|
||||
infectionChance(id = "", fallback = 0) {
|
||||
const value = this.get(id)?.baseInfectionChance;
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
mysteryPool() { return [...this.defs.values()].filter(def => def.mysteryEligible).map(def => def.id); }
|
||||
debugSnapshot() { return super.debugSnapshot((id, def) => ({ id: def.id || id, label: def.label, flag: def.flag, cures: def.cureRules || [] })); }
|
||||
}
|
||||
|
||||
const registry = new DiseaseRegistry(rawDefinitions);
|
||||
global.TarinaiDiseaseRegistry = registry;
|
||||
global.TARINAI_DISEASE_DEFINITIONS = Object.freeze(rawDefinitions);
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -1,35 +1,23 @@
|
|||
"use strict";
|
||||
|
||||
class TarinaiEventBus {
|
||||
constructor({ historyLimit = 180 } = {}) {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.history = [];
|
||||
this.historyLimit = historyLimit;
|
||||
}
|
||||
|
||||
on(type, handler) {
|
||||
if (!type || typeof handler !== "function") return () => {};
|
||||
if (!type || typeof handler !== "function") return;
|
||||
let list = this.handlers.get(type);
|
||||
if (!list) {
|
||||
list = new Set();
|
||||
this.handlers.set(type, list);
|
||||
}
|
||||
list.add(handler);
|
||||
return () => this.off(type, handler);
|
||||
}
|
||||
|
||||
off(type, handler) {
|
||||
const list = this.handlers.get(type);
|
||||
if (!list) return;
|
||||
list.delete(handler);
|
||||
if (!list.size) this.handlers.delete(type);
|
||||
}
|
||||
|
||||
emit(type, detail = {}) {
|
||||
if (!type) return null;
|
||||
const event = { type, detail: detail || {}, at: performance?.now?.() ?? Date.now() };
|
||||
this.history.unshift(event);
|
||||
if (this.history.length > this.historyLimit) this.history.length = this.historyLimit;
|
||||
const direct = this.handlers.get(type);
|
||||
if (direct) for (const handler of [...direct]) this.safeCall(handler, event);
|
||||
const wildcard = this.handlers.get("*");
|
||||
|
|
@ -41,12 +29,6 @@ class TarinaiEventBus {
|
|||
try { handler(event); }
|
||||
catch (err) { console.error("event handler failed", event.type, err); }
|
||||
}
|
||||
|
||||
recent(filter = null, limit = 40) {
|
||||
const list = filter ? this.history.filter(ev => ev.type === filter) : this.history;
|
||||
return list.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
window.TarinaiEventBus = TarinaiEventBus;
|
||||
window.TarinaiEvents = window.TarinaiEvents || new TarinaiEventBus();
|
||||
|
|
|
|||
|
|
@ -45,5 +45,5 @@
|
|||
return comps;
|
||||
}
|
||||
|
||||
global.TarinaiFamilyGraph = { buildGraph, connectedComponents };
|
||||
global.TarinaiFamilyGraph = { connectedComponents };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
|
||||
|
||||
function spritePathFor(type = "smile") {
|
||||
const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []));
|
||||
const meta = sprites.find(s => s.id === type) || sprites[0];
|
||||
return meta?.path || "assets/sprites/tarinai_01_smile.webp";
|
||||
}
|
||||
|
||||
function ensureStyles() {
|
||||
if (document.getElementById("freezeSystemStyles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "freezeSystemStyles";
|
||||
style.textContent = `
|
||||
.frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; }
|
||||
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; }
|
||||
.frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; }
|
||||
.frozen-panel-toggle::after { content:"\u6298\u308a\u7573\u307f"; font-size:11px; opacity:.62; font-weight:700; }
|
||||
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"\u5c55\u958b"; }
|
||||
.frozen-panel-count { font-size:12px; opacity:.72; }
|
||||
.frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; }
|
||||
.frozen-panel.collapsed .frozen-list { display:none; }
|
||||
.frozen-panel.collapsed { padding-bottom:7px; }
|
||||
.frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); }
|
||||
.frozen-card:active { cursor:grabbing; }
|
||||
.frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); }
|
||||
.frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); }
|
||||
.frozen-card-main { min-width:0; flex:1; }
|
||||
.frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; }
|
||||
canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function ensurePanel() {
|
||||
ensureStyles();
|
||||
let panel = document.getElementById("frozenPanel");
|
||||
if (panel) return panel;
|
||||
panel = document.createElement("section");
|
||||
panel.id = "frozenPanel";
|
||||
panel.className = "frozen-panel";
|
||||
panel.innerHTML = `
|
||||
<div class="frozen-panel-title"><button id="frozenPanelToggle" class="frozen-panel-toggle" type="button" aria-expanded="false"><span>\u51b7\u51cd\u5eab</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></button></div>
|
||||
<div id="frozenList" class="frozen-list"></div>
|
||||
`;
|
||||
const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed");
|
||||
const collapsedByDefault = storedCollapse !== "0";
|
||||
if (collapsedByDefault) {
|
||||
panel.classList.add("collapsed");
|
||||
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false");
|
||||
} else {
|
||||
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body');
|
||||
const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette");
|
||||
if (operateBody) operateBody.appendChild(panel);
|
||||
else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
|
||||
else document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function render(entries = []) {
|
||||
const panel = ensurePanel();
|
||||
const listEl = panel.querySelector("#frozenList");
|
||||
const countEl = panel.querySelector("#frozenPanelCount");
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
if (countEl) countEl.textContent = `${list.length}`;
|
||||
if (!listEl) return;
|
||||
if (!list.length) {
|
||||
listEl.innerHTML = `<div class="frozen-empty">\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002</div>`;
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = list.map(entry => {
|
||||
const data = entry.data || entry;
|
||||
const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : "";
|
||||
const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || "");
|
||||
return `<button class="frozen-card" type="button" draggable="true" data-frozen-id="${id}" title="\u30c9\u30e9\u30c3\u30b0\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3059">
|
||||
<img src="${htmlEscape(spritePathFor(data.type || "smile"))}" alt="">
|
||||
<span class="frozen-card-main"><span class="frozen-card-name">${htmlEscape(data.name || "\u305f\u308a\u306a\u3044")}</span><span class="frozen-card-meta">${htmlEscape(gen)}</span></span>
|
||||
</button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function markPending(id = "") {
|
||||
const panel = ensurePanel();
|
||||
for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c.dataset.frozenId === id);
|
||||
}
|
||||
|
||||
function clearPending() {
|
||||
document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw"));
|
||||
}
|
||||
|
||||
function bind({ onSelect, onDrop } = {}) {
|
||||
const panel = ensurePanel();
|
||||
if (panel.dataset.freezeBound !== "1") {
|
||||
panel.dataset.freezeBound = "1";
|
||||
panel.addEventListener("dragstart", (e) => {
|
||||
const card = e.target.closest(".frozen-card[data-frozen-id]");
|
||||
if (!card) return;
|
||||
const id = card.dataset.frozenId || "";
|
||||
e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`);
|
||||
e.dataTransfer?.setData("application/x-tarinai-frozen", id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready");
|
||||
});
|
||||
panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready"));
|
||||
panel.addEventListener("click", (e) => {
|
||||
const toggle = e.target.closest("#frozenPanelToggle");
|
||||
if (toggle) {
|
||||
const collapsed = panel.classList.toggle("collapsed");
|
||||
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {}
|
||||
global.audio?.uiFold?.();
|
||||
return;
|
||||
}
|
||||
const card = e.target.closest(".frozen-card[data-frozen-id]");
|
||||
if (!card) return;
|
||||
const id = card.dataset.frozenId || "";
|
||||
markPending(id);
|
||||
onSelect?.(id);
|
||||
});
|
||||
}
|
||||
const canvas = global.canvas || document.getElementById("gameCanvas");
|
||||
if (canvas && canvas.dataset.freezeDropBound !== "1") {
|
||||
canvas.dataset.freezeDropBound = "1";
|
||||
canvas.addEventListener("dragover", (e) => {
|
||||
const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain");
|
||||
if (!text) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
canvas.classList.add("freeze-drop-ready");
|
||||
});
|
||||
canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready"));
|
||||
canvas.addEventListener("drop", (e) => {
|
||||
const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, "");
|
||||
if (!id) return;
|
||||
e.preventDefault();
|
||||
canvas.classList.remove("freeze-drop-ready");
|
||||
const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const worldRef = global.world;
|
||||
return worldRef?.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true };
|
||||
})();
|
||||
onDrop?.(id, p.x, p.y);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
global.TarinaiFreezePanel = {
|
||||
ensurePanel,
|
||||
render,
|
||||
bind,
|
||||
markPending,
|
||||
clearPending,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const Snapshot = global.TarinaiSnapshot;
|
||||
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_snapshot_adapter.js");
|
||||
const clonePlain = Snapshot.clonePlain;
|
||||
|
||||
function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) {
|
||||
const data = Snapshot.copyOwnData(entity, skip);
|
||||
const targetRef = Snapshot.refFor(worldRef, entity?.target);
|
||||
const panicTargetRef = Snapshot.refFor(worldRef, entity?.panicTarget);
|
||||
const targetRefObject = Snapshot.refFor(worldRef, entity?.targetRef);
|
||||
if (targetRef) data.__targetRef = targetRef;
|
||||
if (panicTargetRef) data.__panicTargetRef = panicTargetRef;
|
||||
if (targetRefObject) data.__targetRefObject = targetRefObject;
|
||||
return data;
|
||||
}
|
||||
|
||||
function restoreTarinaiData(worldRef, data, opts = {}) {
|
||||
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
|
||||
if (!TarinaiClass) throw new Error("Tarinai is not available");
|
||||
const t = new TarinaiClass(worldRef, data || {});
|
||||
Snapshot.applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"]));
|
||||
t.world = worldRef;
|
||||
if (opts.clearTransient !== false) {
|
||||
t.target = null;
|
||||
t.panicTarget = null;
|
||||
t.sleeping = false;
|
||||
t.insideNestBoxId = "";
|
||||
}
|
||||
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
|
||||
return t;
|
||||
}
|
||||
|
||||
function makeEntry(t, worldRef = global.world) {
|
||||
const data = snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"]));
|
||||
data.sleeping = false;
|
||||
data.insideNestBoxId = "";
|
||||
data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle");
|
||||
data.targetKey = "";
|
||||
const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`);
|
||||
return {
|
||||
freezeId: id,
|
||||
frozenAt: Date.now(),
|
||||
worldTime: worldRef?.time || 0,
|
||||
name: t.name || "\u305f\u308a\u306a\u3044",
|
||||
familyKey: t.familyKey || "",
|
||||
liveId: t.id || "",
|
||||
liveToken: t.liveToken || 0,
|
||||
type: t.type || "smile",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function updateLiveIdAfterThaw(worldRef, t) {
|
||||
if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map();
|
||||
const id = t.id || "";
|
||||
const conflict = id && worldRef.liveTarinai.has(id);
|
||||
if (!id || conflict) {
|
||||
worldRef.assignTarinaiLiveId?.(t);
|
||||
return;
|
||||
}
|
||||
worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t });
|
||||
const m = /^t(\d+)$/.exec(id);
|
||||
if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1);
|
||||
worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0);
|
||||
}
|
||||
|
||||
function thawEntry(entry, x, y, worldRef = global.world) {
|
||||
const data = clonePlain(entry?.data || entry) || {};
|
||||
const t = restoreTarinaiData(worldRef, data, { clearTransient: true });
|
||||
t.dead = false;
|
||||
t.deathReason = "";
|
||||
t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
t.vx = 0;
|
||||
t.vy = 0;
|
||||
t.target = null;
|
||||
t.panicTarget = null;
|
||||
t.insideNestBoxId = "";
|
||||
t.sleeping = false;
|
||||
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b");
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
|
||||
t.thought = "\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b";
|
||||
t.frozenAt = 0;
|
||||
updateLiveIdAfterThaw(worldRef, t);
|
||||
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
|
||||
return t;
|
||||
}
|
||||
|
||||
global.TarinaiFreezeSnapshotAdapter = {
|
||||
snapshotEntity,
|
||||
restoreTarinaiData,
|
||||
makeEntry,
|
||||
thawEntry,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const Snapshot = global.TarinaiSnapshot;
|
||||
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_store.js");
|
||||
const STORAGE_KEY = "tarinai_frozen_storage_v1";
|
||||
const clonePlain = Snapshot.clonePlain;
|
||||
|
||||
function frozenList(worldRef = global.world) {
|
||||
if (!worldRef) return [];
|
||||
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
|
||||
return worldRef.frozenTarinai;
|
||||
}
|
||||
|
||||
function save(worldRef = global.world) {
|
||||
try {
|
||||
global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function load(worldRef = global.world) {
|
||||
if (!worldRef) return [];
|
||||
try {
|
||||
const raw = global.localStorage?.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || [];
|
||||
} catch (_) {
|
||||
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
|
||||
}
|
||||
return frozenList(worldRef);
|
||||
}
|
||||
|
||||
function ensure(worldRef = global.world) {
|
||||
if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = [];
|
||||
return frozenList(worldRef);
|
||||
}
|
||||
|
||||
global.TarinaiFreezeStore = {
|
||||
STORAGE_KEY,
|
||||
frozenList,
|
||||
load,
|
||||
save,
|
||||
ensure,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
function deps() {
|
||||
return {
|
||||
Store: global.TarinaiFreezeStore,
|
||||
Adapter: global.TarinaiFreezeSnapshotAdapter,
|
||||
Panel: global.TarinaiFreezePanel,
|
||||
};
|
||||
}
|
||||
function depsReady() {
|
||||
const d = deps();
|
||||
return Boolean(d.Store && d.Adapter && d.Panel);
|
||||
}
|
||||
function warnMissing(context) {
|
||||
if (global.console?.warn) console.warn(`freeze_system.js dependencies are not available yet (${context})`);
|
||||
}
|
||||
|
||||
let pendingThawId = "";
|
||||
|
||||
function frozenList(worldRef = global.world) {
|
||||
const { Store } = deps();
|
||||
if (!Store?.frozenList) return [];
|
||||
return Store.frozenList(worldRef);
|
||||
}
|
||||
|
||||
function renderFrozenPanel(worldRef = global.world) {
|
||||
const { Panel } = deps();
|
||||
if (!Panel?.render) return;
|
||||
Panel.render(frozenList(worldRef));
|
||||
}
|
||||
|
||||
function saveFrozenStore(worldRef = global.world) {
|
||||
const { Store } = deps();
|
||||
if (!Store?.save) return;
|
||||
Store.save(worldRef);
|
||||
}
|
||||
|
||||
function loadFrozenStore(worldRef = global.world) {
|
||||
const { Store } = deps();
|
||||
return Store?.load ? Store.load(worldRef) : [];
|
||||
}
|
||||
|
||||
function refreshWorldAfterFreezeChange(worldRef = global.world, selected = null) {
|
||||
if (!worldRef) return;
|
||||
if (selected !== undefined) worldRef.selected = selected;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.familyTreeDirty = true;
|
||||
worldRef.updateItemCounts?.();
|
||||
worldRef.rebuildSpatial?.(true);
|
||||
saveFrozenStore(worldRef);
|
||||
worldRef.emit?.("freeze:changed", { world: worldRef, selected, renderWorld: true });
|
||||
}
|
||||
|
||||
function freezeTarinai(t, worldRef = global.world) {
|
||||
const { Adapter } = deps();
|
||||
if (!Adapter?.makeEntry) { warnMissing("freezeTarinai"); return false; }
|
||||
if (!t || t.dead || !worldRef) return false;
|
||||
const entry = Adapter.makeEntry(t, worldRef);
|
||||
worldRef.clearLiveReferencesTo?.(t);
|
||||
if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id);
|
||||
worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t);
|
||||
if (worldRef.selected === t) worldRef.selected = null;
|
||||
frozenList(worldRef).push(entry);
|
||||
refreshWorldAfterFreezeChange(worldRef, worldRef.selected);
|
||||
global.showToast?.(`${t.name}を冷凍保存しました。`);
|
||||
worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] });
|
||||
worldRef.emit?.("tarinai:frozen", { entry, tarinai: t });
|
||||
return true;
|
||||
}
|
||||
|
||||
function thawFrozen(freezeId, x, y, worldRef = global.world) {
|
||||
const { Adapter } = deps();
|
||||
if (!Adapter?.thawEntry) { warnMissing("thawFrozen"); return false; }
|
||||
const list = frozenList(worldRef);
|
||||
const index = list.findIndex(e => (e.freezeId || e.id) === freezeId);
|
||||
if (index < 0 || !worldRef) return false;
|
||||
const entry = list[index];
|
||||
let t = null;
|
||||
try {
|
||||
t = Adapter.thawEntry(entry, x, y, worldRef);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
worldRef.tarinai.push(t);
|
||||
worldRef.recordFamily?.(t);
|
||||
list.splice(index, 1);
|
||||
refreshWorldAfterFreezeChange(worldRef, t);
|
||||
global.showToast?.(`${t.name}を解凍しました。`);
|
||||
worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] });
|
||||
worldRef.emit?.("tarinai:thawed", { entry, tarinai: t });
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindFreezeSystem() {
|
||||
if (!depsReady()) {
|
||||
warnMissing("bindFreezeSystem");
|
||||
global.setTimeout?.(() => { if (depsReady()) bindFreezeSystem(); }, 0);
|
||||
return false;
|
||||
}
|
||||
const { Panel } = deps();
|
||||
const worldRef = global.world;
|
||||
loadFrozenStore(worldRef);
|
||||
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
|
||||
Panel.bind({
|
||||
onSelect(id) {
|
||||
pendingThawId = id || "";
|
||||
global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。");
|
||||
},
|
||||
onDrop(id, x, y) {
|
||||
thawFrozen(id, x, y, global.world);
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryPendingThawAt(x, y, worldRef = global.world) {
|
||||
if (!pendingThawId) return false;
|
||||
const id = pendingThawId;
|
||||
pendingThawId = "";
|
||||
deps().Panel?.clearPending?.();
|
||||
return thawFrozen(id, x, y, worldRef);
|
||||
}
|
||||
|
||||
function afterWorldRestored(worldRef = global.world) {
|
||||
deps().Store?.ensure?.(worldRef);
|
||||
saveFrozenStore(worldRef);
|
||||
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
|
||||
}
|
||||
|
||||
function patchWorldPrototype() {
|
||||
const apply = (proto) => {
|
||||
const originalHandleClick = proto.handleClick;
|
||||
proto.handleClick = function (x, y) {
|
||||
if (tryPendingThawAt(x, y, this)) return;
|
||||
return originalHandleClick.call(this, x, y);
|
||||
};
|
||||
};
|
||||
if (global.TarinaiPatcher?.patchWorld?.("freezeSystem", apply)) return;
|
||||
const World = global.World;
|
||||
if (!World || World.prototype.__freezeSystemPatched) return;
|
||||
World.prototype.__freezeSystemPatched = true;
|
||||
apply(World.prototype);
|
||||
}
|
||||
|
||||
patchWorldPrototype();
|
||||
|
||||
global.TarinaiFreezeSystem = {
|
||||
bindFreezeSystem,
|
||||
renderFrozenPanel,
|
||||
freezeTarinai,
|
||||
thawFrozen,
|
||||
frozenList,
|
||||
loadFrozenStore,
|
||||
saveFrozenStore,
|
||||
afterWorldRestored,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
|
||||
const GROUND_TYPES = Object.freeze({
|
||||
// デフォルト地面ID。表示名は「砂」。
|
||||
soil: Object.freeze({ id: "soil", label: "砂", description: "普通の地面。", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
dirt: Object.freeze({ id: "dirt", label: "土", description: "草がよく育つ地面。", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }),
|
||||
concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
|
||||
blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "青いまだら地面。ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
ice: Object.freeze({ id: "ice", label: "氷", description: "つるつる滑る。草は生えにくい", stressMultiplier: 1.1, grassMultiplier: 0.15, grassGrowthMultiplier: 0.35 }),
|
||||
laboratory: Object.freeze({ id: "laboratory", label: "実験室", description: "常に晴れ。草は生えず、ずんちはすぐ消える。", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0, fixedWeather: "sunny", zunchiStageMultiplier: 8.0, zunchiDecayMultiplier: 520.0 }),
|
||||
// \u30c7\u30d5\u30a9\u30eb\u30c8\u5730\u9762ID\u3002\u8868\u793a\u540d\u306f\u300c\u7802\u300d\u3002
|
||||
soil: Object.freeze({ id: "soil", label: "\u7802", description: "\u666e\u901a\u306e\u5730\u9762\u3002", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
dirt: Object.freeze({ id: "dirt", label: "\u571f", description: "\u8349\u304c\u3088\u304f\u80b2\u3064\u5730\u9762\u3002", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }),
|
||||
concrete: Object.freeze({ id: "concrete", label: "\u30b3\u30f3\u30af\u30ea\u30fc\u30c8", description: "\u8349\u304c\u751f\u3048\u306a\u3044", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
|
||||
blanket: Object.freeze({ id: "blanket", label: "\u3075\u308f\u3075\u308f\u6bdb\u5e03", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u306b\u304f\u3044", stressMultiplier: 0.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
foot_massage: Object.freeze({ id: "foot_massage", label: "\u8db3\u3064\u307c", description: "\u9752\u3044\u307e\u3060\u3089\u5730\u9762\u3002\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u3084\u3059\u3044", stressMultiplier: 1.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
ice: Object.freeze({ id: "ice", label: "\u6c37", description: "\u3064\u308b\u3064\u308b\u6ed1\u308b\u3002\u8349\u306f\u751f\u3048\u306b\u304f\u3044", stressMultiplier: 1.1, grassMultiplier: 0.15, grassGrowthMultiplier: 0.35 }),
|
||||
laboratory: Object.freeze({ id: "laboratory", label: "\u5b9f\u9a13\u5ba4", description: "\u5e38\u306b\u6674\u308c\u3002\u8349\u306f\u751f\u3048\u305a\u3001\u305a\u3093\u3061\u306f\u3059\u3050\u6d88\u3048\u308b\u3002", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0, fixedWeather: "sunny", zunchiStageMultiplier: 8.0, zunchiDecayMultiplier: 520.0 }),
|
||||
});
|
||||
|
||||
function groundDefinition(id = "soil") {
|
||||
|
|
@ -21,11 +21,7 @@ function groundExists(id = "soil") {
|
|||
}
|
||||
|
||||
function groundLabel(id = "soil") {
|
||||
return groundDefinition(id).label || "砂";
|
||||
}
|
||||
|
||||
function groundDescription(id = "soil") {
|
||||
return groundDefinition(id).description || "";
|
||||
return groundDefinition(id).label || "\u7802";
|
||||
}
|
||||
|
||||
function normalizedGroundStressMultiplier(id = "soil") {
|
||||
|
|
@ -67,17 +63,14 @@ function nextGroundTypeId(current = "soil") {
|
|||
|
||||
function groundTooltipText(id = "soil") {
|
||||
const def = groundDefinition(id);
|
||||
const desc = def.description || "クリックで切り替え";
|
||||
return `${def.label || "じめん"}\n${desc}`;
|
||||
const desc = def.description || "\u30af\u30ea\u30c3\u30af\u3067\u5207\u308a\u66ff\u3048";
|
||||
return `${def.label || "\u3058\u3081\u3093"}\n${desc}`;
|
||||
}
|
||||
|
||||
window.TarinaiGround = Object.freeze({
|
||||
definitions: GROUND_TYPES,
|
||||
order: GROUND_TYPE_ORDER,
|
||||
definition: groundDefinition,
|
||||
exists: groundExists,
|
||||
label: groundLabel,
|
||||
description: groundDescription,
|
||||
stressMultiplier: normalizedGroundStressMultiplier,
|
||||
grassMultiplier: normalizedGroundGrassMultiplier,
|
||||
grassGrowthMultiplier: normalizedGroundGrassGrowthMultiplier,
|
||||
|
|
|
|||
180
js/health.js
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const HEALTH = (() => {
|
||||
const MAJOR_DAMAGE_WINDOW = 24;
|
||||
const MAJOR_DAMAGE_REACTION_WINDOW = 1.0;
|
||||
const WEAKENED_TRACE_WINDOW = 45;
|
||||
const WEAKENED_SUFFIX = "\u3067\u8870\u5f31";
|
||||
const CAUSES = Object.freeze({
|
||||
|
|
@ -11,7 +12,10 @@ const HEALTH = (() => {
|
|||
poke: "\u3064\u3064\u304b\u308c\u3059\u304e",
|
||||
firecracker: "\u7206\u7af9",
|
||||
genkotsu: "\u3052\u3093\u3053\u3064",
|
||||
poisonBlock: "毒ブロック",
|
||||
shooting: "\u5c04\u6483",
|
||||
stoneCrush: "\u77f3\u306b\u6f70\u3055\u308c\u305f",
|
||||
weakness: "\u8870\u5f31",
|
||||
poisonBlock: "\u6bd2\u30d6\u30ed\u30c3\u30af",
|
||||
accident: "\u4e8b\u6545",
|
||||
collision: "\u885d\u7a81",
|
||||
outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664",
|
||||
|
|
@ -23,6 +27,8 @@ const HEALTH = (() => {
|
|||
antEaten: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f",
|
||||
giantDrug: "\u5de8\u5927\u85ac",
|
||||
dwarfDrug: "\u77ee\u5c0f\u85ac",
|
||||
freeze: "凍結",
|
||||
heatstroke: "熱中症",
|
||||
});
|
||||
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
|
||||
|
||||
|
|
@ -30,14 +36,20 @@ const HEALTH = (() => {
|
|||
const text = String(reason || "");
|
||||
if (!text) return "";
|
||||
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
|
||||
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/);
|
||||
if (collisionWith) return `${collisionWith[1]}\u3068\u306e\u885d\u7a81`;
|
||||
if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight;
|
||||
if (/\u3052\u3093\u3053\u3064|genkotsu/.test(text)) return CAUSES.genkotsu;
|
||||
if (/毒ブロック|poison.?block/.test(text)) return CAUSES.poisonBlock;
|
||||
if (/\u6bd2\u30d6\u30ed\u30c3\u30af|poison.?block/.test(text)) return CAUSES.poisonBlock;
|
||||
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 (/\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/.test(text)) return CAUSES.collision;
|
||||
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;
|
||||
if (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease;
|
||||
if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease;
|
||||
|
|
@ -50,7 +62,8 @@ const HEALTH = (() => {
|
|||
}
|
||||
if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger;
|
||||
if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress;
|
||||
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return CAUSES.accident;
|
||||
if (/\u77f3|stone/.test(text)) return CAUSES.stoneCrush;
|
||||
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|accident/.test(text)) return CAUSES.accident;
|
||||
if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return CAUSES.lifespan;
|
||||
return "";
|
||||
}
|
||||
|
|
@ -69,7 +82,6 @@ const HEALTH = (() => {
|
|||
t.lastDamageAt = now;
|
||||
if (t.lastDamageAmount >= 6 || t.lastDamageAmount >= Math.max(4, (t.energy || 0) * 0.22)) {
|
||||
t.lastMajorDamageCause = cause;
|
||||
t.lastMajorDamageAmount = t.lastDamageAmount;
|
||||
t.lastMajorDamageAt = now;
|
||||
}
|
||||
return cause;
|
||||
|
|
@ -96,24 +108,40 @@ const HEALTH = (() => {
|
|||
const recentMajor = hasRecentMajorDamage(t);
|
||||
const recent = recentDamageCause(t, 20);
|
||||
const fighting = isFightingContext(t, recent);
|
||||
if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision;
|
||||
if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) {
|
||||
|
||||
// Direct damage death: use the immediate/direct source, not a weakened suffix.
|
||||
if (opts.fromDamage) {
|
||||
if (direct) return direct;
|
||||
if (recentMajor && t.lastMajorDamageCause) return t.lastMajorDamageCause;
|
||||
if (recent) return recent;
|
||||
return CAUSES.accident;
|
||||
}
|
||||
|
||||
if (recentMajor && t.lastMajorDamageCause === CAUSES.collision && !(t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) return CAUSES.collision;
|
||||
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);
|
||||
const traceCanWeaken = weakenedTrace && weakenedTrace !== CAUSES.stress && weakenedTrace !== CAUSES.hunger && weakenedTrace !== CAUSES.lifespan && weakenedTrace !== CAUSES.outOfBounds;
|
||||
if (traceCanWeaken && (!direct || direct === CAUSES.stress || direct === CAUSES.hunger || direct === weakenedTrace || opts.fromDamage) && (t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 114)) {
|
||||
if (traceCanWeaken && (!direct || direct === CAUSES.stress || direct === CAUSES.hunger || direct === weakenedTrace) && (t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 114)) {
|
||||
return weakenedDeathReasonFor(weakenedTrace);
|
||||
}
|
||||
if (direct && direct !== CAUSES.stress) return direct;
|
||||
|
||||
const tempStatus = t.temperatureStatus || t.world?.temperatureStatusFor?.(t.feltTemperature ?? t.tempComfort ?? (CONFIG.standardTemperature ?? 15), t) || null;
|
||||
const recentTemperatureDamageAt = Number(t.lastTemperatureDamageAt || -999);
|
||||
const recentTemperatureDamage = (t.world?.time || 0) - recentTemperatureDamageAt <= 36;
|
||||
if (recentTemperatureDamage && tempStatus && (tempStatus.harmExcess || 0) > 0 && (t.energy <= 26 || t.stress >= 112 || t.mood <= 18)) {
|
||||
return tempStatus.direction === "cold" ? CAUSES.freeze : CAUSES.heatstroke;
|
||||
}
|
||||
if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease;
|
||||
if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger;
|
||||
if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight;
|
||||
if (recent && recent !== CAUSES.stress && (t.energy <= 22 || t.mood <= 14 || t.stress >= 126 || opts.fromDamage)) return weakenedDeathReasonFor(recent);
|
||||
if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22)) return CAUSES.fight;
|
||||
if (recent && recent !== CAUSES.stress && (t.energy <= 22 || t.mood <= 14 || t.stress >= 126)) return weakenedDeathReasonFor(recent);
|
||||
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 (t.energy <= 0.5) return CAUSES.accident;
|
||||
if (opts.lowHealth || t.energy <= 0.5) return CAUSES.weakness;
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
@ -122,11 +150,128 @@ const HEALTH = (() => {
|
|||
if (text.includes(WEAKENED_SUFFIX)) return text;
|
||||
const cause = dominantDeathCause(t, text, opts) || CAUSES.accident;
|
||||
if (cause.includes(WEAKENED_SUFFIX)) 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;
|
||||
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.weakness && cause !== CAUSES.freeze && cause !== CAUSES.heatstroke;
|
||||
if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause);
|
||||
return cause;
|
||||
}
|
||||
|
||||
|
||||
const MAJOR_DAMAGE_REACTIONS = Object.freeze([
|
||||
{ id: "voice_major_damage_01", text: "\u3042\u3042\u3042\u3042\uff01" },
|
||||
{ id: "voice_major_damage_02", text: "\u3048\u3048\u3048\u3048\uff01" },
|
||||
{ id: "voice_major_damage_03", text: "\u306f\u3046\u3046\u3046\uff01" },
|
||||
{ id: "voice_major_damage_04", text: "\u306f\u3046\u306f\u3046\uff01" },
|
||||
]);
|
||||
|
||||
function spawnSilentDamageBubble(t, text = "!", color = "rgba(142,58,58,0.86)") {
|
||||
const world = t?.world;
|
||||
if (!world) return false;
|
||||
// Use the normal Tarinai bubble path first so positioning, draw order, and
|
||||
// throttling match other player-visible speech bubbles. Major-damage
|
||||
// bubbles are forced because they must win over incidental bubbles such as
|
||||
// \u300c\u3076\u308a\u3085\u3063\u300d fired by the same damage event.
|
||||
if (typeof t.bubble === "function" && t.bubble(text, 1.65, color, { force: true })) {
|
||||
t._damageBubblePriorityUntil = Math.max(Number(t._damageBubblePriorityUntil || 0), (world.time || 0) + 1.55);
|
||||
return true;
|
||||
}
|
||||
if (typeof Effect === "undefined") return false;
|
||||
if ((world.effectCounts?.bubble || 0) >= (CONFIG?.bubbleLimit || 80)) return false;
|
||||
const r = Math.max(16, t.radius || 22);
|
||||
world.effects?.push(new Effect("bubble", t.x, t.y - r * 1.38, {
|
||||
vx: (Math.random() - 0.5) * 2.4,
|
||||
vy: -4.8 - Math.random() * 2.8,
|
||||
life: 2.4 + Math.random() * 0.6,
|
||||
size: 9,
|
||||
text,
|
||||
color,
|
||||
}));
|
||||
world.effectCounts.bubble = (world.effectCounts.bubble || 0) + 1;
|
||||
t.nextBubbleAt = Math.max(t.nextBubbleAt || 0, (world.time || 0) + 1.55);
|
||||
t._damageBubblePriorityUntil = Math.max(Number(t._damageBubblePriorityUntil || 0), (world.time || 0) + 1.55);
|
||||
world.drawListDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeTriggerMajorDamageReaction(t, actualLoss = 0, cause = "") {
|
||||
if (!t || !t.world || t.dead) return false;
|
||||
const maxEnergy = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100));
|
||||
const loss = Math.max(0, Number(actualLoss) || 0);
|
||||
if (loss <= 0.01) return false;
|
||||
const now = t.world.time || 0;
|
||||
const cutoff = now - MAJOR_DAMAGE_REACTION_WINDOW;
|
||||
const entries = Array.isArray(t.recentDamageWindow) ? t.recentDamageWindow : [];
|
||||
entries.push({ at: now, amount: loss, cause: cause || "" });
|
||||
const kept = [];
|
||||
let total = 0;
|
||||
let dominant = { amount: 0, cause: cause || "" };
|
||||
for (const entry of entries) {
|
||||
const at = Number(entry?.at || 0);
|
||||
const amount = Math.max(0, Number(entry?.amount) || 0);
|
||||
if (at < cutoff || amount <= 0) continue;
|
||||
kept.push({ at, amount, cause: entry?.cause || "" });
|
||||
total += amount;
|
||||
if (amount >= dominant.amount) dominant = { amount, cause: entry?.cause || "" };
|
||||
}
|
||||
t.recentDamageWindow = kept.slice(-20);
|
||||
if (total < maxEnergy * 0.30) return false;
|
||||
const majorCause = causeLabel(dominant.cause || cause) || causeLabel(cause) || CAUSES.accident;
|
||||
t.lastMajorDamageCause = majorCause;
|
||||
t.lastMajorDamageAt = now;
|
||||
if (now < Number(t.nextMajorDamageVoiceAt || -999)) return false;
|
||||
t.nextMajorDamageVoiceAt = now + 1.0;
|
||||
const list = MAJOR_DAMAGE_REACTIONS;
|
||||
const reaction = list[Math.floor(Math.random() * list.length)] || list[0];
|
||||
audio.play?.(reaction.id, { category: "voice", minGap: 0.85 });
|
||||
spawnSilentDamageBubble(t, reaction.text, "rgba(146,58,58,0.88)");
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.55);
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 2.6);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.55);
|
||||
if (t.enterPanic) t.enterPanic({
|
||||
threat: null,
|
||||
reason: "\u5927\u304d\u306a\u30c0\u30e1\u30fc\u30b8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
|
||||
fear: 1.12,
|
||||
wake: true,
|
||||
hurtTimer: 1.45,
|
||||
surpriseTimer: 0.55,
|
||||
cause: "major_damage",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeReleaseZunchiFromDamage(t, actualLoss = 0, cause = "") {
|
||||
if (!t || !t.world || t.dead) return false;
|
||||
const loss = Math.max(0, Number(actualLoss) || 0);
|
||||
if (loss <= 0.1) return false;
|
||||
const now = t.world.time || 0;
|
||||
if (now < Number(t.nextDamageZunchiAt || -999)) return false;
|
||||
const probability = Math.max(0.06, Math.min(0.82, 0.06 + loss / 48));
|
||||
if (Math.random() >= probability) return false;
|
||||
t.nextDamageZunchiAt = now + Math.max(0.55, 1.55 - Math.min(0.9, loss / 38));
|
||||
if (t.hasLodgedPinEffect?.("blocksZunchi")) {
|
||||
t.oshiriByoZunchiStock = Math.min(24, Math.max(0, t.oshiriByoZunchiStock || 0) + 1);
|
||||
t.thought = "\u30c0\u30e1\u30fc\u30b8\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b";
|
||||
return false;
|
||||
}
|
||||
const side = t.facingDir ? t.facingDir() : (t.facing || 1);
|
||||
const jitter = (Math.random() - 0.5) * Math.max(4, (t.radius || 22) * 0.25);
|
||||
const x = t.x - side * (t.radius || 22) * (0.72 + Math.random() * 0.24);
|
||||
const y = t.y + (t.radius || 22) * (0.24 + Math.random() * 0.25) + jitter;
|
||||
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)) {
|
||||
t.bubble?.("\u3076\u308a\u3085\u3063", 1.1, "rgba(74,91,50,0.78)", { force: true });
|
||||
}
|
||||
if (now > Number(t.nextDamageZunchiLogAt || -999)) {
|
||||
t.nextDamageZunchiLogAt = now + 10;
|
||||
t.world.log?.(`${t.name}\u306f\u30c0\u30e1\u30fc\u30b8\u3067\u305a\u3093\u3061\u3092\u653e\u51fa\u3057\u305f\u3002`, "accident", { participants: [t] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function applyDamage(t, amount, reason = "") {
|
||||
const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100);
|
||||
t.maxEnergy = maxEnergy;
|
||||
|
|
@ -141,15 +286,16 @@ const HEALTH = (() => {
|
|||
const plushie = (t.world?.itemsOfType?.("plushie") || t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
|
||||
if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null);
|
||||
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure();
|
||||
audio.damage?.(actualLoss);
|
||||
const majorDamageReacted = maybeTriggerMajorDamageReaction(t, actualLoss, cause || reason);
|
||||
if (!majorDamageReacted) audio.damage?.(actualLoss);
|
||||
maybeReleaseZunchiFromDamage(t, actualLoss, cause || reason);
|
||||
if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
if (t.wakeFromDamage) t.wakeFromDamage(cause || reason);
|
||||
if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason);
|
||||
t.showHpBar();
|
||||
}
|
||||
if (t.energy <= 0.5) {
|
||||
const majorNow = actualLoss >= Math.max(10, before * 0.42);
|
||||
t.die(normalizeDeathReason(t, reason || cause, { fromDamage: true, weakened: majorNow }));
|
||||
t.die(reason || cause, { fromDamage: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,10 +307,6 @@ const HEALTH = (() => {
|
|||
}
|
||||
|
||||
return Object.freeze({
|
||||
MAJOR_DAMAGE_WINDOW,
|
||||
WEAKENED_TRACE_WINDOW,
|
||||
CAUSES,
|
||||
causeLabel,
|
||||
recentDamageCause,
|
||||
rememberDamage,
|
||||
weakenedDeathReasonFor,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
function snapshotKey(snapshot) {
|
||||
try {
|
||||
const clone = cloneSnapshot(snapshot);
|
||||
if (clone) clone.c = 0;
|
||||
return JSON.stringify(clone);
|
||||
} catch (_) {
|
||||
return "";
|
||||
|
|
@ -30,7 +29,7 @@
|
|||
function capture(worldRef = global.world, label = "action") {
|
||||
const world = ensure(worldRef);
|
||||
if (!world || world._historyRestoring) return false;
|
||||
const snap = global.TarinaiSnapshot?.createSnapshot?.(world);
|
||||
const snap = global.TarinaiSnapshot.createSnapshot(world);
|
||||
if (!snap) return false;
|
||||
const key = snapshotKey(snap);
|
||||
if (key && key === world._lastUndoSnapshotKey) return false;
|
||||
|
|
@ -41,12 +40,12 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function restore(worldRef, entry, source) {
|
||||
if (!worldRef || !entry?.snapshot || !global.TarinaiRestoreCoordinator?.restoreSnapshot) return false;
|
||||
function restore(worldRef, entry) {
|
||||
if (!worldRef || !entry?.snapshot) return false;
|
||||
worldRef._historyRestoring = true;
|
||||
try {
|
||||
global.TarinaiRestoreCoordinator.restoreSnapshot(cloneSnapshot(entry.snapshot), worldRef, { source, syncUi: true });
|
||||
worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot?.createSnapshot?.(worldRef));
|
||||
global.TarinaiRestoreCoordinator.restoreSnapshot(cloneSnapshot(entry.snapshot), worldRef, { syncUi: true });
|
||||
worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot.createSnapshot(worldRef));
|
||||
return true;
|
||||
} finally {
|
||||
worldRef._historyRestoring = false;
|
||||
|
|
@ -56,36 +55,25 @@
|
|||
function undo(worldRef = global.world) {
|
||||
const world = ensure(worldRef);
|
||||
if (!world || !world._undoStack.length) return { ok: false, reason: "empty" };
|
||||
const current = global.TarinaiSnapshot?.createSnapshot?.(world);
|
||||
const current = global.TarinaiSnapshot.createSnapshot(world);
|
||||
const prev = world._undoStack.pop();
|
||||
if (current) world._redoStack.push({ snapshot: cloneSnapshot(current), key: snapshotKey(current), label: "redo", time: world.time || 0 });
|
||||
if (world._redoStack.length > MAX_HISTORY) world._redoStack.splice(0, world._redoStack.length - MAX_HISTORY);
|
||||
const ok = restore(world, prev, "undo");
|
||||
const ok = restore(world, prev);
|
||||
return { ok, label: prev.label || "" };
|
||||
}
|
||||
|
||||
function redo(worldRef = global.world) {
|
||||
const world = ensure(worldRef);
|
||||
if (!world || !world._redoStack.length) return { ok: false, reason: "empty" };
|
||||
const current = global.TarinaiSnapshot?.createSnapshot?.(world);
|
||||
const current = global.TarinaiSnapshot.createSnapshot(world);
|
||||
const next = world._redoStack.pop();
|
||||
if (current) world._undoStack.push({ snapshot: cloneSnapshot(current), key: snapshotKey(current), label: "undo", time: world.time || 0 });
|
||||
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
|
||||
const ok = restore(world, next, "redo");
|
||||
const ok = restore(world, next);
|
||||
return { ok, label: next.label || "" };
|
||||
}
|
||||
|
||||
function clear(worldRef = global.world) {
|
||||
const world = ensure(worldRef);
|
||||
if (!world) return false;
|
||||
world._undoStack.length = 0;
|
||||
world._redoStack.length = 0;
|
||||
world._lastUndoSnapshotKey = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function canUndo(worldRef = global.world) { return !!worldRef?._undoStack?.length; }
|
||||
function canRedo(worldRef = global.world) { return !!worldRef?._redoStack?.length; }
|
||||
|
||||
global.TarinaiHistory = Object.freeze({ capture, undo, redo, clear, canUndo, canRedo });
|
||||
global.TarinaiHistory = Object.freeze({ capture, undo, redo });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
51
js/impact_core_system.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: physics/impact-core
|
||||
// Shared impact constants and link-attachment helpers used by drop impacts,
|
||||
// ball collisions, and high-speed tarinai collisions.
|
||||
(function (global) {
|
||||
const PHYSICAL_DAMAGE_SPEED_THRESHOLD = 165;
|
||||
|
||||
function physicalDamageFromImpactSpeed(speed = 0, { min = 0, max = 58, scale = 8.6 } = {}) {
|
||||
const v = Math.max(0, Number(speed) || 0);
|
||||
if (v < PHYSICAL_DAMAGE_SPEED_THRESHOLD) return 0;
|
||||
const raw = (v - PHYSICAL_DAMAGE_SPEED_THRESHOLD) / Math.max(1, scale);
|
||||
return clamp(raw, min, max);
|
||||
}
|
||||
|
||||
function linkEndpointMatchesItem(endpoint, item) {
|
||||
if (!endpoint || !item) return false;
|
||||
if (endpoint.kind && endpoint.kind !== "item") return false;
|
||||
if (endpoint.id && item.id && endpoint.id === item.id) return true;
|
||||
const dx = Number(endpoint.x) - Number(item.x || 0);
|
||||
const dy = Number(endpoint.y) - Number(item.y || 0);
|
||||
return Number.isFinite(dx) && Number.isFinite(dy) && Math.hypot(dx, dy) <= Math.max(14, (item.r || 18) * 0.75);
|
||||
}
|
||||
|
||||
function itemHasAttachedLink(worldRef, item) {
|
||||
if (!worldRef || !item || !item.id) return false;
|
||||
const pb = global.TarinaiPhysicsBodySystem || null;
|
||||
const links = [];
|
||||
if (worldRef.itemsOfType) {
|
||||
for (const linkType of ["rope", "rod"]) {
|
||||
for (const link of worldRef.itemsOfType(linkType) || []) if (link && !link.dead) links.push(link);
|
||||
}
|
||||
} else {
|
||||
for (const link of worldRef.items || []) if (link && !link.dead && (link.type === "rope" || link.type === "rod")) links.push(link);
|
||||
}
|
||||
for (const link of links) {
|
||||
const endpoints = [pb?.endpoint?.(link, 0), pb?.endpoint?.(link, 1)];
|
||||
const fallback = link.physicsConstraint?.endpoints || link.constraint?.endpoints || [];
|
||||
if (!endpoints[0] && fallback[0]) endpoints[0] = fallback[0];
|
||||
if (!endpoints[1] && fallback[1]) endpoints[1] = fallback[1];
|
||||
if (linkEndpointMatchesItem(endpoints[0], item) || linkEndpointMatchesItem(endpoints[1], item)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
global.TarinaiImpactCoreSystem = Object.freeze({
|
||||
PHYSICAL_DAMAGE_SPEED_THRESHOLD,
|
||||
physicalDamageFromImpactSpeed,
|
||||
itemHasAttachedLink,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
112
js/impact_response_system.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const ImpactCore = global.TarinaiImpactCoreSystem;
|
||||
const PHYSICAL_DAMAGE_SPEED_THRESHOLD = ImpactCore.PHYSICAL_DAMAGE_SPEED_THRESHOLD || 165;
|
||||
const physicalDamageFromImpactSpeed = ImpactCore.physicalDamageFromImpactSpeed;
|
||||
|
||||
function itemDropImpact(worldRef, it) {
|
||||
if (!worldRef) return undefined;
|
||||
if (!it || it.dead || it.type === "water") return;
|
||||
const isStone = it.type === "stone";
|
||||
const isGenkotsu = it.type === "genkotsu";
|
||||
const dropFallHeight = isGenkotsu ? Math.max(360, (it.r || 88) * 5.8) : 170;
|
||||
const dropDuration = Math.max(0.08, Number(it.dropMax || 0.42) || 0.42);
|
||||
const impactSpeed = dropFallHeight / dropDuration;
|
||||
const radius = Math.max(32, Math.min(220, (it.r || 14) * 1.35 + impactSpeed * 0.055));
|
||||
const damage = physicalDamageFromImpactSpeed(impactSpeed, { min: 1.2, max: isGenkotsu ? 58 : 34, scale: 8.6 });
|
||||
const push = clamp((impactSpeed - 120) * 0.62, 42, isGenkotsu ? 1040 : 260);
|
||||
const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3\u306b\u6f70\u3055\u308c\u305f" : (impactSpeed >= PHYSICAL_DAMAGE_SPEED_THRESHOLD ? "\u9ad8\u901f\u3067\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u885d\u7a81" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"));
|
||||
if (isGenkotsu) { worldRef.lastGenkotsuAt = worldRef.time || 0; it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); worldRef.triggerGroundShake?.(2.0, 10); }
|
||||
let hit = 0;
|
||||
for (const t of worldRef.nearbyTarinai(it.x, it.y, radius + 54)) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox(t)) continue;
|
||||
const d = Math.max(1, distXY(t.x, t.y, it.x, it.y));
|
||||
const p = clamp(1 - d / (radius + t.radius), 0, 1);
|
||||
if (p <= 0) continue;
|
||||
let nx = (t.x - it.x) / d;
|
||||
let ny = (t.y - it.y) / d;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
const a = deterministicAngle(worldRef, "drop-impact-overlap-normal", it, t);
|
||||
nx = Math.cos(a);
|
||||
ny = Math.sin(a);
|
||||
}
|
||||
t.vx += nx * push * (0.35 + p);
|
||||
t.vy += ny * push * (0.35 + p) - Math.min(210, push * 0.22) * p;
|
||||
const dealtDamage = damage * (0.35 + p * 0.65);
|
||||
if (dealtDamage > 0) t.damage(dealtDamage, reason);
|
||||
if (isGenkotsu) {
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
|
||||
t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu.");
|
||||
t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044";
|
||||
} else {
|
||||
t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b";
|
||||
}
|
||||
t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25));
|
||||
t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26));
|
||||
t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer);
|
||||
t.fallDir = nx >= 0 ? 1 : -1;
|
||||
hit += 1;
|
||||
}
|
||||
let ballHit = 0;
|
||||
if (isGenkotsu && 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") continue;
|
||||
const d = Math.max(1, distXY(ball.x, ball.y, it.x, it.y));
|
||||
const p = clamp(1 - d / (ballRange + (ball.r || 18)), 0, 1);
|
||||
if (p <= 0) continue;
|
||||
let nx = (ball.x - it.x) / d;
|
||||
let ny = (ball.y - it.y) / d;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
const a = deterministicAngle(worldRef, "genkotsu-ball-impact-normal", it, ball);
|
||||
nx = Math.cos(a);
|
||||
ny = Math.sin(a);
|
||||
}
|
||||
const ballPush = clamp(push * (0.44 + p * 0.86), 160, 1080);
|
||||
ball.prevX = Number.isFinite(ball.x) ? ball.x : (ball.prevX || 0);
|
||||
ball.prevY = Number.isFinite(ball.y) ? ball.y : (ball.prevY || 0);
|
||||
ball.vx = clamp((ball.vx || 0) + nx * ballPush, -1180, 1180);
|
||||
ball.vy = clamp((ball.vy || 0) + ny * ballPush - Math.min(180, ballPush * 0.12) * p, -1180, 1180);
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (10 + p * 12), -48, 48);
|
||||
ball.lastPokedAt = worldRef.time || 0;
|
||||
ballHit += 1;
|
||||
worldRef.effects?.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, (ball.r || 18) * 1.1), life: 0.20, color: "rgba(255,255,255,0.52)" }));
|
||||
}
|
||||
if (ballHit > 0) worldRef.markSpatialDirty?.("genkotsu-ball-impact");
|
||||
}
|
||||
const antHit = damage > 0 ? worldRef.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
|
||||
push: p => push * (0.62 + p * 1.25),
|
||||
}) : 0;
|
||||
if (isGenkotsu) {
|
||||
worldRef.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75, { limit: 96, effectLimit: 6 });
|
||||
worldRef.blastLoosePinsFrom?.(it.x, it.y, radius + 92, it, 2.75, "genkotsu-impact");
|
||||
const existingEffects = (worldRef.effects || []).length;
|
||||
const ringCount = existingEffects > 180 ? 2 : 3;
|
||||
const burstCount = existingEffects > 180 ? 4 : 7;
|
||||
for (let r = 0; r < ringCount; r++) worldRef.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 42, life: 0.32 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.68)" : "rgba(74,57,42,0.38)" }));
|
||||
for (let i = 0; i < burstCount; i++) worldRef.effects.push(new Effect("fight", it.x + deterministicRange(worldRef, "genkotsu-impact-effect-x", -38, 38, it, i), it.y + deterministicRange(worldRef, "genkotsu-impact-effect-y", -22, 28, it, i), { size: deterministicRange(worldRef, "genkotsu-impact-effect-size", 12, 22, it, i), life: deterministicRange(worldRef, "genkotsu-impact-effect-life", 0.22, 0.42, it, i), color: "rgba(255,205,46,0.78)" }));
|
||||
} else {
|
||||
worldRef.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" }));
|
||||
}
|
||||
if (hit > 0 || antHit > 0 || isGenkotsu) worldRef.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45));
|
||||
if (isGenkotsu) audio.genkotsuImpact?.();
|
||||
else if (isStone) audio.stoneImpact?.();
|
||||
else if (hit > 0 || antHit > 0) audio.poke?.();
|
||||
else audio.drop?.();
|
||||
if (hit > 0 || antHit > 0) {
|
||||
const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177");
|
||||
const victims = [];
|
||||
if (hit > 0) victims.push(`${hit}\u5339\u306e\u305f\u308a\u306a\u3044`);
|
||||
if (antHit > 0) victims.push(`${antHit}\u5339\u306e\u30a2\u30ea`);
|
||||
worldRef.log(`${label}\u304c\u843d\u3061\u3001${victims.join("\u3068")}\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
global.TarinaiImpactResponseSystem = Object.freeze({
|
||||
itemDropImpact,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,19 +1,12 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const MODES = Object.freeze(["auto", "camera", "tool", "family"]);
|
||||
const MODES = Object.freeze(["auto", "camera", "tool"]);
|
||||
const STORAGE_KEY = "tarinai_mobile_input_mode_v1";
|
||||
|
||||
class InputModeManager {
|
||||
constructor() {
|
||||
this.storageKey = STORAGE_KEY;
|
||||
this.currentMode = this.loadMode();
|
||||
this.gestureState = {
|
||||
active: false,
|
||||
kind: "",
|
||||
startedAt: 0,
|
||||
pointerCount: 0,
|
||||
};
|
||||
this.classification = this.classify();
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +22,6 @@
|
|||
setMode(mode = "auto") {
|
||||
this.currentMode = MODES.includes(mode) ? mode : "auto";
|
||||
try { global.localStorage?.setItem(STORAGE_KEY, this.currentMode); } catch (_) {}
|
||||
this.emit("input:mode", this.snapshot());
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
|
|
@ -45,9 +37,12 @@
|
|||
const hasHover = Boolean(mm?.("(hover: hover)")?.matches);
|
||||
const noHover = Boolean(mm?.("(hover: none)")?.matches);
|
||||
const isTouchDevice = Number(nav.maxTouchPoints || 0) > 0 || "ontouchstart" in global;
|
||||
const isCompactViewport = shortSide <= 720 && longSide <= 1100;
|
||||
const isCompactViewport = shortSide <= 760 && longSide <= 1200;
|
||||
const userAgentMobile = /Android|iPhone|iPod|iPad|Mobile|Silk|Tablet/i.test(ua);
|
||||
const touchFirst = Boolean(pointerCoarse && noHover && !hasHover && isTouchDevice && (isCompactViewport || userAgentMobile));
|
||||
// v39.15.35: Some mobile browsers report hover support or a desktop-like
|
||||
// viewport. Treat compact touch devices as touch-first so the on-canvas
|
||||
// mobile mode UI is reliably available.
|
||||
const touchFirst = Boolean(isTouchDevice && (isCompactViewport || userAgentMobile) && (pointerCoarse || noHover || userAgentMobile || shortSide <= 820));
|
||||
this.classification = Object.freeze({
|
||||
isTouchDevice,
|
||||
isCompactViewport,
|
||||
|
|
@ -62,58 +57,17 @@
|
|||
}
|
||||
|
||||
refresh() {
|
||||
const previous = this.classification;
|
||||
const next = this.classify();
|
||||
if (JSON.stringify(previous) !== JSON.stringify(next)) this.emit("input:classification", this.snapshot());
|
||||
return next;
|
||||
}
|
||||
|
||||
beginGesture(kind = "", pointerCount = 1) {
|
||||
this.gestureState = {
|
||||
active: true,
|
||||
kind: String(kind || ""),
|
||||
startedAt: performance?.now?.() ?? Date.now(),
|
||||
pointerCount: Math.max(0, Number(pointerCount) || 0),
|
||||
};
|
||||
return this.gestureState;
|
||||
}
|
||||
|
||||
endGesture() {
|
||||
this.gestureState = { ...this.gestureState, active: false, pointerCount: 0 };
|
||||
return this.gestureState;
|
||||
}
|
||||
|
||||
canPanCanvas() {
|
||||
if (!this.classification?.touchFirst) return true;
|
||||
return this.currentMode === "auto" || this.currentMode === "camera";
|
||||
}
|
||||
|
||||
canPlaceTools() {
|
||||
if (!this.classification?.touchFirst) return true;
|
||||
return this.currentMode === "auto" || this.currentMode === "tool";
|
||||
}
|
||||
|
||||
canFamilyTreeCaptureTouch() {
|
||||
if (!this.classification?.touchFirst) return true;
|
||||
return this.currentMode === "auto" || this.currentMode === "family";
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return Object.freeze({
|
||||
...this.classification,
|
||||
currentMode: this.currentMode,
|
||||
gestureState: { ...this.gestureState },
|
||||
canPanCanvas: this.canPanCanvas(),
|
||||
canPlaceTools: this.canPlaceTools(),
|
||||
canFamilyTreeCaptureTouch: this.canFamilyTreeCaptureTouch(),
|
||||
});
|
||||
}
|
||||
|
||||
emit(type, detail) {
|
||||
global.TarinaiEvents?.emit?.(type, detail);
|
||||
}
|
||||
}
|
||||
|
||||
global.InputModeManager = InputModeManager;
|
||||
global.TarinaiInputMode = global.TarinaiInputMode || new InputModeManager();
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@
|
|||
if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; }
|
||||
if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.72; item.spinVelocity *= -0.72; }
|
||||
resolveObstacleCollisions(item, dt, worldRef);
|
||||
global.TarinaiItemDynamicPinSystem.tryBallPickup(item, worldRef);
|
||||
item.x = clamp(item.x, p, worldRef.w - p);
|
||||
item.y = clamp(item.y, p, worldRef.h - p);
|
||||
const groundFriction = Math.pow(0.72, dt);
|
||||
item.vx *= groundFriction;
|
||||
item.vy *= groundFriction;
|
||||
}
|
||||
global.TarinaiItemDynamicPinSystem.tryBallPickup(item, worldRef);
|
||||
item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt + (item.vx || 0) * dt / Math.max(8, item.r || 18);
|
||||
item.spinVelocity *= Math.pow(0.65, dt);
|
||||
if (Math.hypot(item.vx || 0, item.vy || 0) < 0.18) { item.vx = 0; item.vy = 0; }
|
||||
|
|
@ -36,12 +38,12 @@
|
|||
const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
|
||||
const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || [];
|
||||
const seen = new Set(items);
|
||||
// 大きな機構は中心が近傍セル外でも当たる可能性がある。
|
||||
// 中心ではなく外接半径で追加走査して、長い棒・往復幅にも衝突させる。
|
||||
// \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;
|
||||
const reach = global.TarinaiMechanicalSystem?.reach?.(it) || worldRef.rotatorExtent?.(it) || Math.max(64, it.r || 64);
|
||||
if (!it || it.dead || it === item || !global.TarinaiMechanicalSystem.isMechanicalType(it.type) || seen.has(it)) continue;
|
||||
const reach = global.TarinaiMechanicalSystem.reach(it) || worldRef.rotatorExtent?.(it) || Math.max(64, it.r || 64);
|
||||
if (distXY(item.x, item.y, it.x, it.y) <= searchRadius + reach + 36) {
|
||||
items.push(it);
|
||||
seen.add(it);
|
||||
|
|
@ -132,7 +134,7 @@
|
|||
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78;
|
||||
const velocityDamp = rect.bounce ? 1.0 : 0.94;
|
||||
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
|
||||
if (rect.oriented && global.TarinaiPhysics?.bounceCircleOffRect) {
|
||||
if (rect.oriented && global.TarinaiPhysics.bounceCircleOffRect) {
|
||||
const bounced = global.TarinaiPhysics.bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { minBounceSpeed });
|
||||
if (!bounced) return;
|
||||
item.vx *= velocityDamp;
|
||||
|
|
@ -241,9 +243,5 @@
|
|||
global.TarinaiItemDynamicBallSystem = Object.freeze({
|
||||
update,
|
||||
resolveObstacleCollisions,
|
||||
applyHayDrag,
|
||||
resolveCircleBounce,
|
||||
resolveRectBounce,
|
||||
emitBounce,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/dynamic-system
|
||||
// Owns duplicator item loading/reloading behavior. Direct stored-type helpers
|
||||
// remain exposed from TarinaiDuplicatorRuntime for UI/tool commands and
|
||||
// save/restore stability.
|
||||
// Owns duplicator item loading/reloading behavior.
|
||||
(function (global) {
|
||||
function support() {
|
||||
return global.TarinaiItemLifecycleRuntimeSupport || {};
|
||||
|
|
@ -12,91 +10,14 @@
|
|||
function update(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "duplicator") return false;
|
||||
const s = support();
|
||||
const syncRoles = s.syncDuplicatorRoles || global.TarinaiDuplicatorRuntime?.syncRoles;
|
||||
const syncRoles = s.syncDuplicatorRoles;
|
||||
const applyStoredPin = s.duplicatorApplyStoredPin;
|
||||
const canLoadNow = s.duplicatorCanLoadNow;
|
||||
const loadPointFor = s.duplicatorLoadPoint || global.TarinaiDuplicatorRuntime?.loadPoint;
|
||||
const loadTypeForItem = s.duplicatorLoadTypeForItem || global.TarinaiDuplicatorRuntime?.loadTypeForItem;
|
||||
const candidateReach = s.duplicatorCandidateReach;
|
||||
const setStoredType = s.duplicatorSetStoredType || global.TarinaiDuplicatorRuntime?.setStoredType;
|
||||
if (typeof syncRoles !== "function" || typeof loadPointFor !== "function" || typeof loadTypeForItem !== "function" || typeof setStoredType !== "function") return false;
|
||||
if (typeof syncRoles !== "function") return false;
|
||||
|
||||
item.amount = 999;
|
||||
syncRoles(item);
|
||||
if (typeof applyStoredPin === "function") applyStoredPin(item, worldRef);
|
||||
item.duplicatorPinFlash = Math.max(0, (item.duplicatorPinFlash || 0) - dt);
|
||||
if (!worldRef?.nearbyItems) return true;
|
||||
if (typeof canLoadNow === "function" && !canLoadNow(item, worldRef)) {
|
||||
item._duplicatorCandidateKey = "";
|
||||
item._duplicatorCandidateSince = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
const loadPoint = loadPointFor(item);
|
||||
const pickupRadius = Math.max(68, (item.r || 34) + 44);
|
||||
const candidates = Array.from(worldRef.nearbyItems(item.x, item.y, pickupRadius, true) || []);
|
||||
// SpatialGrid の分類漏れを補う。ただし、複製機からかなり近いものだけに限定し、
|
||||
// 移動中の複製機が周辺アイテムを吸い込む挙動を避ける。
|
||||
if (worldRef.items) {
|
||||
for (const candidate of worldRef.items) {
|
||||
if (!candidate || candidates.includes(candidate) || candidate === item || candidate.dead) continue;
|
||||
const approxReach = pickupRadius + Math.max(16, candidate.r || 12);
|
||||
if (distXY(item.x, item.y, candidate.x, candidate.y) <= approxReach) candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
let best = null, bestType = "", bestSlotD = Infinity, bestScore = Infinity;
|
||||
for (const it of candidates) {
|
||||
if (!it || it === item || it.dead || it.playerHeld || it._heldByPlayer) continue;
|
||||
const loadType = loadTypeForItem(it);
|
||||
if (!loadType) continue;
|
||||
if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue;
|
||||
const slotD = distXY(loadPoint.x, loadPoint.y, it.x, it.y);
|
||||
const centerD = distXY(item.x, item.y, it.x, it.y);
|
||||
const slotReach = typeof candidateReach === "function" ? candidateReach(it) : Math.max(20, Math.min(36, (it?.r || 12) * 0.82 + 13));
|
||||
const bodyReach = Math.max(22, (item.r || 34) * 0.46 + (it.r || 12) * 0.36);
|
||||
if (slotD > slotReach && centerD > bodyReach) continue;
|
||||
// 基本は投入口に近いものを優先。同距離なら置かれたばかりのものを優先し、
|
||||
// 複数候補があるときに意図しない古いアイテムへ寄りにくくする。
|
||||
const score = slotD + Math.max(0, Number(it.age || 0)) * 0.012;
|
||||
if (score < bestScore) {
|
||||
best = it;
|
||||
bestType = loadType;
|
||||
bestSlotD = slotD;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || !bestType) {
|
||||
item._duplicatorCandidateKey = "";
|
||||
item._duplicatorCandidateSince = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
const candidateKey = `${best.id || ""}:${bestType}`;
|
||||
const now = Number(worldRef.time || 0);
|
||||
if (item._duplicatorCandidateKey !== candidateKey) {
|
||||
item._duplicatorCandidateKey = candidateKey;
|
||||
item._duplicatorCandidateSince = now;
|
||||
return true;
|
||||
}
|
||||
const dwell = item.storedFoodType ? (s.DUPLICATOR_REPLACE_DWELL_SECONDS ?? 0.70) : (s.DUPLICATOR_LOAD_DWELL_SECONDS ?? 0.38);
|
||||
if (now - Number(item._duplicatorCandidateSince || 0) < dwell) return true;
|
||||
|
||||
const changed = item.storedFoodType !== bestType;
|
||||
if (!changed && item.storedFoodType) {
|
||||
item._duplicatorCandidateKey = "";
|
||||
item._duplicatorCandidateSince = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
setStoredType(item, bestType, worldRef, { consumeItem: best, direct: false, force: false, log: false, emit: false });
|
||||
// Item.dead is a getter based on amount. Do not assign to it.
|
||||
worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
|
||||
worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
|
||||
worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
|
||||
worldRef.emit?.("duplicator:loaded", { duplicator: item, foodType: item.storedFoodType, replaced: changed, slotDistance: bestSlotD });
|
||||
worldRef.log?.(`複製機に${item.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(item.storedFoodType) ? "observe" : "food");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,4 @@
|
|||
"use strict";
|
||||
(function ensureDeterministicHelpers(global) {
|
||||
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
|
||||
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
|
||||
const keyPart = value => {
|
||||
if (value == null) return "null";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
|
||||
if (typeof value === "string" || typeof value === "boolean") return String(value);
|
||||
if (typeof value === "object") {
|
||||
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
|
||||
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
|
||||
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
|
||||
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
|
||||
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const hashUnit = (seed, salt = "") => {
|
||||
const str = `${seed}:${salt}`;
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||
return ((h >>> 0) % 100000) / 100000;
|
||||
};
|
||||
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
|
||||
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
|
||||
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
|
||||
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
|
||||
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
|
||||
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
|
||||
// Layer: entity-runtime/items/dynamic-system
|
||||
|
|
@ -35,7 +6,121 @@
|
|||
// behavior. Prototype methods delegate here.
|
||||
(function (global) {
|
||||
function isPin(item) {
|
||||
return Boolean(item && typeof global.isPinType === "function" && global.isPinType(item.type));
|
||||
return Boolean(global.TarinaiPinAttachmentSystem.isPin(item) || (item && typeof global.isPinType === "function" && global.isPinType(item.type)));
|
||||
}
|
||||
|
||||
function ballById(worldRef, id) {
|
||||
if (!worldRef || !id) return null;
|
||||
return (worldRef.items || []).find(it => it && !it.dead && it.type === "ball" && it.id === id) || null;
|
||||
}
|
||||
|
||||
function removeFromBall(item, worldRef = null) {
|
||||
const ball = ballById(worldRef, item?.pinBallId) || null;
|
||||
if (ball?.stuckPinIds) ball.stuckPinIds = ball.stuckPinIds.filter(id => id !== item.id);
|
||||
if (ball && ball.stuckPinId === item?.id) ball.stuckPinId = "";
|
||||
if (item) item.pinBallId = "";
|
||||
return ball;
|
||||
}
|
||||
|
||||
function updateBallLodged(item, dt, worldRef) {
|
||||
const ball = ballById(worldRef, item.pinBallId);
|
||||
if (!ball) {
|
||||
detach(item, worldRef, "ball-lost");
|
||||
return;
|
||||
}
|
||||
if (global.TarinaiPinAttachmentSystem.applyBallPose(item, ball, worldRef)) return;
|
||||
const angle = (ball.spin || 0) + (item.pinBallOffsetAngle || 0);
|
||||
const distance = Math.max(4, Number(item.pinBallOffsetDistance || (ball.r || 18) * 0.55) || (ball.r || 18) * 0.55);
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
item.x = ball.x + Math.cos(angle) * distance;
|
||||
item.y = ball.y + Math.sin(angle) * distance;
|
||||
item.vx = ball.vx || 0;
|
||||
item.vy = ball.vy || 0;
|
||||
item.pinVisualSide = Math.cos(angle) >= 0 ? 1 : -1;
|
||||
item.spin = angle + (item.type === "oshibyo" ? 0.18 : Math.PI * 0.5);
|
||||
item.deletable = false;
|
||||
if (Number.isFinite(ball.spinVelocity)) item.spinVelocity = ball.spinVelocity;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
}
|
||||
|
||||
function attachToBall(item, ball, worldRef) {
|
||||
if (!isPin(item) || !ball || ball.dead || ball.type !== "ball" || item.dead) return false;
|
||||
if (item.pinState === "lodged" || item.pinState === "ball_lodged") return false;
|
||||
const now = worldRef?.time || 0;
|
||||
if ((item.noStickUntil || 0) > now) return false;
|
||||
const angle = deterministicAngle(worldRef, "pin-ball-stick-angle", item, ball, now);
|
||||
const dist = Math.max(4, (ball.r || 18) * deterministicRange(worldRef, "pin-ball-stick-distance", 0.24, 0.78, item, ball, now));
|
||||
item.pinState = "ball_lodged";
|
||||
item.pinBallId = ball.id;
|
||||
item.pinTargetId = "";
|
||||
item.pinBallOffsetAngle = angle - (ball.spin || 0);
|
||||
item.pinBallOffsetDistance = dist;
|
||||
item.pinVisualSide = Math.cos(angle) >= 0 ? 1 : -1;
|
||||
item.deletable = false;
|
||||
item.noStickUntil = now + 0.16;
|
||||
ball.stuckPinIds = Array.isArray(ball.stuckPinIds) ? ball.stuckPinIds : [];
|
||||
if (!ball.stuckPinIds.includes(item.id)) ball.stuckPinIds.push(item.id);
|
||||
ball.stuckPinId = ball.stuckPinIds[0] || item.id;
|
||||
updateBallLodged(item, 0, worldRef);
|
||||
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(12, (item.r || 8) * 1.4), life: 0.16, color: item.type === "oshibyo" ? "rgba(73,119,205,0.42)" : "rgba(214,72,72,0.42)" }));
|
||||
return true;
|
||||
}
|
||||
|
||||
function transferFromBallToTarinai(item, ball, t, worldRef) {
|
||||
if (!item || item.dead || item.pinState !== "ball_lodged" || !ball || !t || t.dead) return false;
|
||||
const now = worldRef?.time || 0;
|
||||
if ((item._ballPinTransferredAt || -999) + 0.18 > now) return false;
|
||||
removeFromBall(item, worldRef);
|
||||
item.pinState = "loose";
|
||||
item.pinTargetId = "";
|
||||
item.noStickUntil = 0;
|
||||
item.noStickTargetId = "";
|
||||
item.noStickTargetUntil = 0;
|
||||
item.x = ball.x + deterministicRange(worldRef, "pin-transfer-x", -(ball.r || 18) * 0.25, (ball.r || 18) * 0.25, item, ball, t);
|
||||
item.y = ball.y + deterministicRange(worldRef, "pin-transfer-y", -(ball.r || 18) * 0.25, (ball.r || 18) * 0.25, item, ball, t);
|
||||
item._ballPinTransferredAt = now;
|
||||
const ok = attach(item, t, worldRef, distXY(item.x, item.y, t.x, t.y));
|
||||
if (!ok) attachToBall(item, ball, worldRef);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function transferBallPinsToTarinai(ball, t, worldRef) {
|
||||
if (!ball || !t || t.dead) return 0;
|
||||
const ids = Array.isArray(ball.stuckPinIds) ? [...ball.stuckPinIds] : (ball.stuckPinId ? [ball.stuckPinId] : []);
|
||||
let moved = 0;
|
||||
for (const id of ids) {
|
||||
const pin = (worldRef?.items || []).find(it => it && !it.dead && it.id === id && isPin(it));
|
||||
if (pin && transferFromBallToTarinai(pin, ball, t, worldRef)) moved += 1;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
function tryBallPickup(ball, worldRef) {
|
||||
if (!ball || ball.dead || ball.type !== "ball" || !worldRef?.nearbyItems) return 0;
|
||||
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
|
||||
const range = (ball.r || 18) + speed * Math.max(0.016, Number(worldRef.dt || 0.016) || 0.016) + 28;
|
||||
const candidates = worldRef.nearbyItems(ball.x, ball.y, range) || [];
|
||||
let picked = 0;
|
||||
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
|
||||
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
|
||||
const sx = ball.x - px;
|
||||
const sy = ball.y - py;
|
||||
const len2 = sx * sx + sy * sy;
|
||||
for (const pin of candidates) {
|
||||
if (!isPin(pin) || pin.dead || pin.pinState === "lodged" || pin.pinState === "ball_lodged") continue;
|
||||
let hitX = ball.x;
|
||||
let hitY = ball.y;
|
||||
if (len2 > 1) {
|
||||
const u = clamp(((pin.x - px) * sx + (pin.y - py) * sy) / len2, 0, 1);
|
||||
hitX = px + sx * u;
|
||||
hitY = py + sy * u;
|
||||
}
|
||||
const hit = (ball.r || 18) + Math.max(5, pin.r || 8) + 2;
|
||||
if (distXY(hitX, hitY, pin.x, pin.y) > hit) continue;
|
||||
if (attachToBall(pin, ball, worldRef)) picked += 1;
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
function update(item, dt, worldRef) {
|
||||
|
|
@ -45,8 +130,13 @@
|
|||
updateLodged(item, dt, worldRef);
|
||||
return true;
|
||||
}
|
||||
if (global.TarinaiPhysics?.applyKinematicItemMotion) {
|
||||
if (item.pinState === "ball_lodged") {
|
||||
updateBallLodged(item, dt, worldRef);
|
||||
return true;
|
||||
}
|
||||
if (global.TarinaiPhysics.applyKinematicItemMotion) {
|
||||
global.TarinaiPhysics.applyKinematicItemMotion(item, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 });
|
||||
global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef);
|
||||
} else {
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
|
|
@ -77,10 +167,21 @@
|
|||
return attach(item, best, worldRef, bestD);
|
||||
}
|
||||
|
||||
function oshibyoAnchorFor(t, visualSide, item = { type: "oshibyo" }) {
|
||||
return global.TarinaiPinAttachmentSystem.tarinaiAnchor(item, t, { visualSide }) || {
|
||||
x: t.x + visualSide * (t?.radius || 16) * 0.96,
|
||||
y: t.y + (t?.radius || 16) * 0.50,
|
||||
angle: visualSide * 0.48,
|
||||
distance: (t?.radius || 16) * 0.96,
|
||||
offsetY: (t?.radius || 16) * 0.48,
|
||||
visualSide,
|
||||
};
|
||||
}
|
||||
|
||||
function attach(item, t, worldRef, d = null) {
|
||||
if (!t || t.dead) return false;
|
||||
if (t.stuckPushpinId && t.stuckPushpinId !== item.id) return false;
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null;
|
||||
const behavior = pinBehaviorFor(item.type);
|
||||
const isOshibyo = Boolean(behavior?.blocksZunchi);
|
||||
const dx = item.x - t.x;
|
||||
const dy = item.y - t.y;
|
||||
|
|
@ -88,12 +189,16 @@
|
|||
item.pinState = "lodged";
|
||||
item.deletable = false;
|
||||
item.pinTargetId = t.id;
|
||||
removeFromBall(item, worldRef);
|
||||
const sharedAnchor = global.TarinaiPinAttachmentSystem.tarinaiAnchor(item, t, { dx, dy, distance: distToTarget });
|
||||
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
|
||||
const visualSide = isOshibyo ? -faceDir : faceDir;
|
||||
const faceSide = faceDir >= 0 ? 1 : -1;
|
||||
const visualSide = sharedAnchor?.visualSide ?? (isOshibyo ? -faceSide : faceSide);
|
||||
item.pinVisualSide = visualSide;
|
||||
item.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide);
|
||||
item.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
|
||||
item.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
|
||||
const oshibyoAnchor = isOshibyo ? (sharedAnchor || oshibyoAnchorFor(t, visualSide, item)) : null;
|
||||
item.pinAttachAngle = isOshibyo ? oshibyoAnchor.angle : (sharedAnchor?.angle ?? Math.atan2(dy || -1, dx || visualSide));
|
||||
item.pinAttachDistance = isOshibyo ? oshibyoAnchor.distance : (sharedAnchor?.distance ?? clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74));
|
||||
item.pinOffsetY = isOshibyo ? oshibyoAnchor.offsetY : (sharedAnchor?.offsetY ?? clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38));
|
||||
item.pinDamageTick = 0;
|
||||
item.pinFallCheckTimer = 0;
|
||||
item.vx = 0;
|
||||
|
|
@ -102,8 +207,8 @@
|
|||
item.spin = item.pinAttachAngle;
|
||||
t.stuckPushpinId = item.id;
|
||||
t.sleeping = false;
|
||||
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
|
||||
if (!isOshibyo) t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
|
||||
if (!isOshibyo) t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.8);
|
||||
if (isOshibyo) {
|
||||
t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f";
|
||||
worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
||||
|
|
@ -118,7 +223,7 @@
|
|||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
|
||||
}
|
||||
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
|
||||
if (!isOshibyo && t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
|
||||
if ((worldRef.time || 0) >= (item.pinLogAt || -999) + 1.2) {
|
||||
item.pinLogAt = worldRef.time || 0;
|
||||
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
||||
|
|
@ -133,20 +238,25 @@
|
|||
detach(item, worldRef, "owner-lost");
|
||||
return;
|
||||
}
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null;
|
||||
const behavior = pinBehaviorFor(item.type);
|
||||
const isOshibyo = Boolean(behavior?.blocksZunchi);
|
||||
t.stuckPushpinId = item.id;
|
||||
item.deletable = false;
|
||||
const sharedPose = global.TarinaiPinAttachmentSystem.applyTarinaiPose(item, t, worldRef);
|
||||
if (!sharedPose) {
|
||||
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
|
||||
const visualSide = isOshibyo ? -faceDir : faceDir;
|
||||
const faceSide = faceDir >= 0 ? 1 : -1;
|
||||
const visualSide = isOshibyo ? -faceSide : faceSide;
|
||||
item.pinVisualSide = visualSide;
|
||||
item.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36;
|
||||
item.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04;
|
||||
const oshibyoAnchor = isOshibyo ? oshibyoAnchorFor(t, visualSide, item) : null;
|
||||
item.x = isOshibyo ? oshibyoAnchor.x : t.x + visualSide * (t.radius || 16) * 0.36;
|
||||
item.y = isOshibyo ? oshibyoAnchor.y : t.y - (t.radius || 16) * 0.04;
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
item.spin = visualSide * (isOshibyo ? 0.52 : 0.28);
|
||||
item.spin = isOshibyo ? oshibyoAnchor.angle : visualSide * 0.28;
|
||||
item.vx = t.vx || 0;
|
||||
item.vy = t.vy || 0;
|
||||
}
|
||||
if (isOshibyo) {
|
||||
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044";
|
||||
return;
|
||||
|
|
@ -154,7 +264,7 @@
|
|||
item.pinDamageTick = (item.pinDamageTick || 0) + dt;
|
||||
while (item.pinDamageTick >= 0.55) {
|
||||
item.pinDamageTick -= 0.55;
|
||||
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
|
||||
if (!isOshibyo && t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
|
||||
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
|
||||
if (deterministicChance(worldRef, "pin-pain-bubble", 0.22, item, t)) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
|
||||
|
|
@ -178,8 +288,9 @@
|
|||
}
|
||||
|
||||
function detach(item, worldRef, reason = "released") {
|
||||
const carriedBall = removeFromBall(item, worldRef);
|
||||
const t = worldRef?.liveTarinaiById?.(item.pinTargetId) || null;
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(item.type) : null;
|
||||
const behavior = pinBehaviorFor(item.type);
|
||||
const wasOshibyo = Boolean(behavior?.blocksZunchi);
|
||||
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
|
||||
if (t && t.stuckPushpinId === item.id) t.stuckPushpinId = null;
|
||||
|
|
@ -204,22 +315,46 @@
|
|||
item.vy = 0;
|
||||
item.spinVelocity = 0;
|
||||
} else {
|
||||
item.vx = deterministicRange(worldRef, "pin-detach-vx", -24, 24, item, t, reason);
|
||||
item.vy = deterministicRange(worldRef, "pin-detach-vy", -10, 18, item, t, reason);
|
||||
item.spinVelocity = deterministicRange(worldRef, "pin-detach-spin-velocity", -1.6, 1.6, item, t, reason);
|
||||
item.spin += deterministicRange(worldRef, "pin-detach-spin", -0.25, 0.25, item, t, reason);
|
||||
let nx = 0;
|
||||
let ny = 1;
|
||||
if (t) {
|
||||
item.x = clamp(t.x + deterministicRange(worldRef, "pin-detach-x", -(t.radius || 16) * 0.75, (t.radius || 16) * 0.75, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30));
|
||||
item.y = clamp(t.y + deterministicRange(worldRef, "pin-detach-y", (t.radius || 16) * 0.12, (t.radius || 16) * 0.72, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30));
|
||||
const dx = Number(item.x || 0) - Number(t.x || 0);
|
||||
const dy = Number(item.y || 0) - Number(t.y || 0);
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 0.001) { nx = dx / d; ny = dy / d; }
|
||||
else { nx = Math.sign(item.pinVisualSide || 0) || (t.facingDir?.() || 1); ny = 0.28; }
|
||||
const len = Math.hypot(nx, ny) || 1;
|
||||
nx /= len; ny /= len;
|
||||
const dropR = Math.max(8, (item.r || 8) + (t.radius || 16) * 0.58);
|
||||
const pad = CONFIG.worldPadding || 30;
|
||||
item.x = clamp((t.x || 0) + nx * dropR, pad, Math.max(pad, (worldRef?.w || item.x) - pad));
|
||||
item.y = clamp((t.y || 0) + ny * dropR + (wasOshibyo ? 4 : 0), pad, Math.max(pad, (worldRef?.h || item.y) - pad));
|
||||
} else if (carriedBall) {
|
||||
const dx = Number(item.x || 0) - Number(carriedBall.x || 0);
|
||||
const dy = Number(item.y || 0) - Number(carriedBall.y || 0);
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
nx = dx / d;
|
||||
ny = dy / d;
|
||||
}
|
||||
const baseVx = Number(carriedBall?.vx || t?.vx || 0) || 0;
|
||||
const baseVy = Number(carriedBall?.vy || t?.vy || 0) || 0;
|
||||
item.vx = clamp(baseVx * 0.45 + nx * 18, -90, 90);
|
||||
item.vy = clamp(baseVy * 0.45 + Math.max(12, ny * 18), -60, 110);
|
||||
item.spinVelocity = clamp((item.spinVelocity || 0) * 0.35 + (Math.sign(nx) || 1) * 0.9, -2.2, 2.2);
|
||||
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }));
|
||||
if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] });
|
||||
if (reason === "fall" && t) worldRef?.log?.(`${t.name}の画鋲が抜け落ちた。`, "observe", { participants: [t] });
|
||||
}
|
||||
|
||||
if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) {
|
||||
worldRef.spawnBurstZunchi(t || item, storedZunchi, item);
|
||||
}
|
||||
}
|
||||
|
||||
global.TarinaiItemDynamicPinSystem = Object.freeze({ update, isPin, tryStick, attach, updateLodged, detach });
|
||||
global.TarinaiItemDynamicPinSystem = Object.freeze({
|
||||
update,
|
||||
isPin,
|
||||
attach,
|
||||
detach,
|
||||
transferBallPinsToTarinai,
|
||||
tryBallPickup,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -13,17 +13,18 @@
|
|||
case "poison_block":
|
||||
case "reciprocator":
|
||||
case "gate_fence":
|
||||
case "robot_cleaner":
|
||||
case "rope":
|
||||
case "rod":
|
||||
return global.TarinaiItemDynamicToolSystem?.update(item, dt, worldRef) !== false;
|
||||
return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false;
|
||||
case "ball":
|
||||
return global.TarinaiItemDynamicBallSystem?.update(item, dt, worldRef) !== false;
|
||||
return global.TarinaiItemDynamicBallSystem.update(item, dt, worldRef) !== false;
|
||||
case "duplicator":
|
||||
return global.TarinaiItemDynamicDuplicatorSystem?.update(item, dt, worldRef) !== false;
|
||||
return global.TarinaiItemDynamicDuplicatorSystem.update(item, dt, worldRef) !== false;
|
||||
case "zunchi":
|
||||
return global.TarinaiItemDynamicZunchiSystem?.updateMotion(item, dt, worldRef) !== false;
|
||||
return global.TarinaiItemDynamicZunchiSystem.updateMotion(item, dt, worldRef) !== false;
|
||||
default:
|
||||
if (global.TarinaiItemDynamicPinSystem?.isPin(item)) {
|
||||
if (global.TarinaiItemDynamicPinSystem.isPin(item)) {
|
||||
return global.TarinaiItemDynamicPinSystem.update(item, dt, worldRef) !== false;
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@
|
|||
// links, and static gate-fence ticking. Item.prototype methods delegate here for
|
||||
// direct Item.prototype calls; lifecycle dispatch also calls this boundary directly.
|
||||
(function (global) {
|
||||
function support() {
|
||||
return global.TarinaiItemLifecycleRuntimeSupport || {};
|
||||
}
|
||||
|
||||
function updateFirecracker(item, dt, worldRef) {
|
||||
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
|
||||
|
|
@ -28,32 +25,353 @@
|
|||
}
|
||||
|
||||
function updateRotator(item, dt, worldRef) {
|
||||
return Boolean(global.TarinaiMechanicalSystem?.updateRotator?.(item, dt, worldRef));
|
||||
return Boolean(global.TarinaiMechanicalSystem.updateRotator(item, dt, worldRef));
|
||||
}
|
||||
|
||||
function updateReciprocator(item, dt, worldRef) {
|
||||
return Boolean(global.TarinaiMechanicalSystem?.updateReciprocator?.(item, dt, worldRef));
|
||||
return Boolean(global.TarinaiMechanicalSystem.updateReciprocator(item, dt, worldRef));
|
||||
}
|
||||
|
||||
function updatePoisonBlock(item, dt, worldRef) {
|
||||
return Boolean(global.TarinaiMechanicalSystem?.updatePoisonBlock?.(item, dt, worldRef));
|
||||
}
|
||||
|
||||
function resolveMechanicalInteractions(item, dt, worldRef) {
|
||||
return Boolean(global.TarinaiMechanicalSystem?.resolveInteractions?.(item, dt, worldRef));
|
||||
return Boolean(global.TarinaiMechanicalSystem.updatePoisonBlock(item, dt, worldRef));
|
||||
}
|
||||
|
||||
|
||||
|
||||
function updateFlexibleLink(item, dt, worldRef) {
|
||||
return Boolean(global.TarinaiConstraintSystem?.updateFlexibleLink?.(item, dt, worldRef));
|
||||
return Boolean(global.TarinaiConstraintSystem.updateFlexibleLink(item, dt, worldRef));
|
||||
}
|
||||
|
||||
function updateRigidLink(item, dt, worldRef) {
|
||||
return Boolean(global.TarinaiConstraintSystem?.updateRigidLink?.(item, dt, worldRef));
|
||||
return Boolean(global.TarinaiConstraintSystem.updateRigidLink(item, dt, worldRef));
|
||||
}
|
||||
|
||||
|
||||
|
||||
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);
|
||||
|
|
@ -67,6 +385,7 @@
|
|||
if (item.type === "poison_block") return updatePoisonBlock(item, dt, worldRef);
|
||||
if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef);
|
||||
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
|
||||
if (item.type === "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,14 +393,17 @@
|
|||
|
||||
global.TarinaiItemDynamicToolSystem = Object.freeze({
|
||||
update,
|
||||
updateFirecracker,
|
||||
updateGenkotsu,
|
||||
updateRotator,
|
||||
updateReciprocator,
|
||||
updatePoisonBlock,
|
||||
updateFlexibleLink,
|
||||
updateRigidLink,
|
||||
resolveMechanicalInteractions,
|
||||
updateGateFence,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
const speed = Math.hypot(item.vx || 0, item.vy || 0);
|
||||
if (speed < 0.08) {
|
||||
item.vx = 0; item.vy = 0;
|
||||
global.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(item, dt, worldRef);
|
||||
global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef);
|
||||
return true;
|
||||
}
|
||||
if (global.TarinaiPhysics?.applyKinematicItemMotion) {
|
||||
if (global.TarinaiPhysics.applyKinematicItemMotion) {
|
||||
global.TarinaiPhysics.applyKinematicItemMotion(item, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
|
||||
} else {
|
||||
item.prevX = item.x;
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
item.vy *= slow;
|
||||
worldRef.drawListDirty = true;
|
||||
}
|
||||
global.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(item, dt, worldRef);
|
||||
global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef);
|
||||
resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed);
|
||||
item.spin = (item.spin || 0) + speed * dt / Math.max(8, item.r || 14);
|
||||
return true;
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
|
||||
if (speed >= 185) {
|
||||
const damage = Math.min(18, Math.max(1, (speed - 185) / 22));
|
||||
worldRef.applyImpactDamage?.(t, damage, "衝突", { source: item }) || t.damage?.(damage, "衝突");
|
||||
worldRef.applyImpactDamage?.(t, damage, "\u885d\u7a81", { source: item }) || t.damage?.(damage, "\u885d\u7a81");
|
||||
}
|
||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
|
||||
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
|
||||
|
|
@ -78,8 +78,8 @@
|
|||
const rainy = worldRef.weather === "light_rain";
|
||||
const rainBoost = rainy ? 1.28 : 1;
|
||||
const groundId = worldRef?.groundType || "soil";
|
||||
const stageMul = Math.max(0.1, Number(global.TarinaiGround?.zunchiStageMultiplier?.(groundId) || 1) || 1);
|
||||
const groundDecayMul = Math.max(0.1, Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1);
|
||||
const stageMul = Math.max(0.1, Number(global.TarinaiGround.zunchiStageMultiplier(groundId) || 1) || 1);
|
||||
const groundDecayMul = Math.max(0.1, Number(global.TarinaiGround.zunchiDecayMultiplier(groundId) || 1) || 1);
|
||||
const decayBoost = (rainy ? 1.35 : 1) * groundDecayMul;
|
||||
let waterBoost = 0;
|
||||
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(item.x, item.y, 84) : (worldRef.items || []);
|
||||
|
|
@ -108,5 +108,5 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, resolveHighSpeedTarinaiCollision, updateLifecycle });
|
||||
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, updateLifecycle });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
274
js/item_effect_definitions.js
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"use strict";
|
||||
|
||||
// Item effect behavior, timers, and display metadata.
|
||||
(function (global) {
|
||||
const rawDefinitions = {
|
||||
laxative: {
|
||||
label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60,
|
||||
tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`,
|
||||
},
|
||||
ammo: {
|
||||
label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18,
|
||||
tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`,
|
||||
},
|
||||
protein: {
|
||||
label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power",
|
||||
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d",
|
||||
},
|
||||
niteropu: {
|
||||
label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power",
|
||||
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d",
|
||||
},
|
||||
mercury: {
|
||||
label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent",
|
||||
tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`,
|
||||
},
|
||||
giant_drug: {
|
||||
label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size",
|
||||
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
|
||||
},
|
||||
dwarf_drug: {
|
||||
label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size",
|
||||
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
|
||||
},
|
||||
love_mochi: {
|
||||
label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer",
|
||||
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`,
|
||||
},
|
||||
fight_mochi: {
|
||||
label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer",
|
||||
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`,
|
||||
},
|
||||
sleep_drug: {
|
||||
label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"],
|
||||
},
|
||||
mystery_drug: {
|
||||
label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"],
|
||||
},
|
||||
zunda_juice: {
|
||||
label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"],
|
||||
},
|
||||
disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
};
|
||||
|
||||
Object.assign(rawDefinitions.laxative, {
|
||||
modifiers: { hungerReliefMultiplier: 0 },
|
||||
tickInterval: 3,
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.ammo, {
|
||||
modifiers: { speedMultiplier: 10 },
|
||||
visualEffectId: "fall",
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.protein, {
|
||||
modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 },
|
||||
visualEffectId: "fall_up_red",
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.niteropu, {
|
||||
modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 },
|
||||
visualEffectId: "fall_down_purple",
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.mercury, {
|
||||
modifiers: { lifeMultiplierPerUse: 1.3 },
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.giant_drug, {
|
||||
modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 },
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.dwarf_drug, {
|
||||
modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 },
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) {
|
||||
Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" });
|
||||
}
|
||||
Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false });
|
||||
|
||||
Object.assign(rawDefinitions.love_mochi, {
|
||||
onApply(tarinai, context = {}) {
|
||||
const scale = context.scale ?? 1;
|
||||
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + deterministicRange(tarinai.world, "love-mochi-duration", 4, 12, tarinai, context.item));
|
||||
const nutrition = Number(context.nutrition) || 0;
|
||||
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
|
||||
if (context.source === "eating") {
|
||||
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.fight_mochi, {
|
||||
onApply(tarinai, context = {}) {
|
||||
const scale = context.scale ?? 1;
|
||||
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + deterministicRange(tarinai.world, "fight-mochi-duration", 10, 18, tarinai, context.item));
|
||||
const nutrition = Number(context.nutrition) || 0;
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
|
||||
if (context.source === "eating") {
|
||||
tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32);
|
||||
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.sleep_drug, {
|
||||
onApply(tarinai, context = {}) {
|
||||
if (context.source === "eating") {
|
||||
const nutrition = Number(context.nutrition) || 0;
|
||||
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 });
|
||||
tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100));
|
||||
tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18);
|
||||
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5);
|
||||
const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075);
|
||||
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
|
||||
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
|
||||
if (!tarinai.sleepDisease && deterministicChance(tarinai.world, "sleep-drug-disease", Math.min(0.98, sleepChance), tarinai, context.item)) return tarinai.infectSleepDisease?.(context.item || null) || true;
|
||||
return true;
|
||||
}
|
||||
return tarinai.infectSleepDisease?.(context.item || null) || true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.laxative, {
|
||||
onApply(tarinai, context = {}) {
|
||||
return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1)));
|
||||
},
|
||||
onTick(tarinai) {
|
||||
tarinai.forceLaxativePoop?.();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.ammo, {
|
||||
onApply(tarinai, context = {}) {
|
||||
return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1)));
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.protein, {
|
||||
onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); },
|
||||
});
|
||||
Object.assign(rawDefinitions.niteropu, {
|
||||
onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); },
|
||||
});
|
||||
Object.assign(rawDefinitions.mercury, {
|
||||
onApply(tarinai) { return tarinai.setMercuryEffect?.(); },
|
||||
});
|
||||
Object.assign(rawDefinitions.giant_drug, {
|
||||
onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); },
|
||||
onTick(tarinai) {
|
||||
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
|
||||
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.dwarf_drug, {
|
||||
onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); },
|
||||
onTick(tarinai) {
|
||||
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
|
||||
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.zunda_juice, {
|
||||
onApply(tarinai) { return tarinai.clearAllItemEffects?.(); },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_zunchi, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_sleep, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_explosion, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_fight, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; },
|
||||
});
|
||||
|
||||
class EffectRegistry extends global.TarinaiRegistryBase {
|
||||
label(id = "") { return super.label(id, id || "\u4e0d\u660e"); }
|
||||
color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
|
||||
display(id = "", timer = 0, tarinai = null) {
|
||||
const def = this.get(id);
|
||||
if (!def) return "";
|
||||
return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || "");
|
||||
}
|
||||
apply(id = "", tarinai = null, context = {}) {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai || typeof def.onApply !== "function") return false;
|
||||
return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this }));
|
||||
}
|
||||
tick(id = "", tarinai = null, dt = 0, context = {}) {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai || typeof def.onTick !== "function") return false;
|
||||
return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this }));
|
||||
}
|
||||
remove(id = "", tarinai = null, context = {}) {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai || typeof def.onRemove !== "function") return false;
|
||||
return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this }));
|
||||
}
|
||||
modifier(id = "", key = "", fallback = 1) {
|
||||
const value = this.get(id)?.modifiers?.[key];
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
timerValue(tarinai = null, id = "") {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai) return 0;
|
||||
if (def.timerProp) return Number(tarinai[def.timerProp] || 0);
|
||||
if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0);
|
||||
return 0;
|
||||
}
|
||||
isActive(tarinai = null, id = "") {
|
||||
if (!tarinai) return false;
|
||||
const def = this.get(id);
|
||||
if (!def) return false;
|
||||
if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id;
|
||||
if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id;
|
||||
if (id === "mercury") return tarinai.lifeItemMode === "mercury";
|
||||
return this.timerValue(tarinai, id) > 0.04;
|
||||
}
|
||||
activeSummary(tarinai = null) {
|
||||
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"];
|
||||
return order
|
||||
.filter(id => this.isActive(tarinai, id))
|
||||
.map(id => ({
|
||||
id,
|
||||
label: this.label(id),
|
||||
detail: this.display(id, this.timerValue(tarinai, id), tarinai),
|
||||
}));
|
||||
}
|
||||
randomPool() {
|
||||
const itemEffects = [...this.defs.entries()]
|
||||
.filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease")))
|
||||
.map(([id]) => id)
|
||||
.filter(id => id !== "mystery_drug" && id !== "zunda_juice");
|
||||
const diseases = global.TarinaiDiseaseRegistry.mysteryPool() || [];
|
||||
return [...new Set([...itemEffects, ...diseases])];
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new EffectRegistry(rawDefinitions);
|
||||
global.TarinaiEffectRegistry = registry;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
49
js/item_food_definitions.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use strict";
|
||||
|
||||
// Food and medicine consumption metadata.
|
||||
|
||||
(function (global) {
|
||||
const rawDefinitions = {
|
||||
food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0 },
|
||||
sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82 },
|
||||
love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "love_mochi" },
|
||||
fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "fight_mochi" },
|
||||
sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "sleep_drug" },
|
||||
laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "laxative" },
|
||||
protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "protein" },
|
||||
niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "niteropu" },
|
||||
ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "ammo" },
|
||||
mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "mystery_drug" },
|
||||
mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "mercury" },
|
||||
giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "giant_drug" },
|
||||
dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "dwarf_drug" },
|
||||
zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, effectId: "zunda_juice", removesItemEffects: true },
|
||||
grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0 },
|
||||
water: { label: "\u6c34\u6ef4", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0 },
|
||||
ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0 }
|
||||
};
|
||||
|
||||
class FoodRegistry extends global.TarinaiRegistryBase {
|
||||
servingTypes() {
|
||||
return [...this.defs.entries()]
|
||||
.filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest))
|
||||
.map(([id]) => id);
|
||||
}
|
||||
paramEffectTypes() {
|
||||
return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id);
|
||||
}
|
||||
nutrition(type = "", fallback = 0) {
|
||||
const value = this.get(type)?.nutrition;
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
hungerRelief(type = "", fallback = 0) {
|
||||
const value = this.get(type)?.hungerRelief;
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; }
|
||||
}
|
||||
|
||||
const registry = new FoodRegistry(rawDefinitions);
|
||||
global.TarinaiFoodRegistry = registry;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/decay-system
|
||||
// Owns passive amount decay and spoilage side effects for water, food,
|
||||
// transient traces, splats, and ant corpses. The lifecycle step is now only a
|
||||
// Owns passive amount decay for water, food, transient traces, splats,
|
||||
// and ant corpses. The lifecycle step is now only a
|
||||
// thin pipeline adapter.
|
||||
(function (global) {
|
||||
const MEDICINE_LIKE_FOOD_TYPES = Object.freeze([
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
]);
|
||||
|
||||
function decayServingFood(item, dt, worldRef) {
|
||||
if (typeof isServingFoodType !== "function" || !isServingFoodType(item.type)) return false;
|
||||
if (!isServingFoodType(item.type)) return false;
|
||||
const interval = passiveFoodDecayInterval(worldRef);
|
||||
item.passiveFoodDecayTimer = (item.passiveFoodDecayTimer || 0) + dt;
|
||||
let changed = false;
|
||||
|
|
@ -26,8 +26,6 @@
|
|||
changed = true;
|
||||
item.foodServingsRemaining = Math.max(0, before - lost);
|
||||
item.amount = item.foodServingsRemaining;
|
||||
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING;
|
||||
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
|
||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
||||
worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true });
|
||||
if (item.foodServingsRemaining <= 0.015) item.amount = 0;
|
||||
|
|
@ -38,8 +36,6 @@
|
|||
item.amount -= lost;
|
||||
if (lost > 0) {
|
||||
changed = true;
|
||||
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? 0.002;
|
||||
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
|
||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
||||
worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true });
|
||||
}
|
||||
|
|
@ -58,9 +54,5 @@
|
|||
return { done: false };
|
||||
}
|
||||
|
||||
global.TarinaiItemDecaySystem = Object.freeze({
|
||||
MEDICINE_LIKE_FOOD_TYPES,
|
||||
decayServingFood,
|
||||
update,
|
||||
});
|
||||
global.TarinaiItemDecaySystem = Object.freeze({ update });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,9 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/growth-system
|
||||
// Owns passive grass normalization and zunchi lifecycle stage ticking. The
|
||||
// lifecycle step is kept as a pipeline adapter only.
|
||||
// Owns zunchi lifecycle stage ticking. Grass growth is handled by
|
||||
// simulation_item_ant_system.js via sparse random stage advances.
|
||||
(function (global) {
|
||||
function updateGrass(item) {
|
||||
if (!item || item.dead || item.type !== "grass") return false;
|
||||
// Grass is intentionally not simulated per item. World.update randomly
|
||||
// advances one grass by one discrete stage every few seconds.
|
||||
normalizeGrassStage(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateZunchiLifecycle(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "zunchi") return false;
|
||||
item.lifecycleTimer += dt;
|
||||
|
|
@ -19,20 +11,15 @@
|
|||
if (item.lifecycleTimer < interval) return false;
|
||||
const lifecycleDt = Math.min(item.lifecycleTimer, 2.4);
|
||||
item.lifecycleTimer = 0;
|
||||
global.TarinaiItemDynamicZunchiSystem?.updateLifecycle(item, lifecycleDt, worldRef);
|
||||
global.TarinaiItemDynamicZunchiSystem.updateLifecycle(item, lifecycleDt, worldRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
function update(item, dt, worldRef) {
|
||||
if (!item || item.dead) return { done: true };
|
||||
if (item.type === "grass") updateGrass(item);
|
||||
else if (item.type === "zunchi") updateZunchiLifecycle(item, dt, worldRef);
|
||||
if (item.type === "zunchi") updateZunchiLifecycle(item, dt, worldRef);
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
global.TarinaiItemGrowthSystem = Object.freeze({
|
||||
updateGrass,
|
||||
updateZunchiLifecycle,
|
||||
update,
|
||||
});
|
||||
global.TarinaiItemGrowthSystem = Object.freeze({ update });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/legacy-lifecycle
|
||||
// Compatibility fallback for the pre-pipeline monolithic Item update body.
|
||||
// Normal runtime goes through item_lifecycle_pipeline.js; this exists only so
|
||||
// old callers and partial-load environments have a stable fallback.
|
||||
(function (global) {
|
||||
function updateOne(item, dt, worldRef = global.world) {
|
||||
item.age += dt;
|
||||
if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt);
|
||||
if (item.dropTimer > 0) {
|
||||
const beforeDrop = item.dropTimer;
|
||||
item.dropTimer = Math.max(0, item.dropTimer - dt);
|
||||
if (beforeDrop > 0 && item.dropTimer <= 0 && !item.dropImpactDone) {
|
||||
item.dropImpactDone = true;
|
||||
if (worldRef.itemDropImpact) worldRef.itemDropImpact(item);
|
||||
}
|
||||
}
|
||||
if (item.type === "water") item.amount -= dt * 0.9;
|
||||
if (isServingFoodType(item.type)) {
|
||||
const interval = passiveFoodDecayInterval(worldRef);
|
||||
item.passiveFoodDecayTimer = (item.passiveFoodDecayTimer || 0) + dt;
|
||||
let ticks = 0;
|
||||
while (item.passiveFoodDecayTimer >= interval && ticks < 3) {
|
||||
item.passiveFoodDecayTimer -= interval;
|
||||
ticks++;
|
||||
if (Number.isFinite(item.foodServingsRemaining)) {
|
||||
const before = Math.max(0, item.foodServingsRemaining || 0);
|
||||
const lost = Math.min(before, interval * passiveFoodDecayRate(item));
|
||||
if (lost > 0) {
|
||||
item.foodServingsRemaining = Math.max(0, before - lost);
|
||||
item.amount = item.foodServingsRemaining;
|
||||
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING;
|
||||
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
|
||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
||||
worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true });
|
||||
if (item.foodServingsRemaining <= 0.015) item.amount = 0;
|
||||
}
|
||||
} else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(item.type)) {
|
||||
const before = item.amount || 0;
|
||||
const lost = Math.min(before, interval * 0.12);
|
||||
item.amount -= lost;
|
||||
if (lost > 0) {
|
||||
const penalty = global.TarinaiItemRegistry?.food?.hygienePenalty?.(item.type) ?? 0.002;
|
||||
worldRef?.registerFoodSpoilage?.(lost * penalty, item);
|
||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
||||
worldRef?.emit?.("item:decayed", { item: item, type: item.type, amount: lost, passive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.type === "trace") item.amount -= dt * 1.35;
|
||||
if (item.type === "splat") item.amount -= dt * 1.05;
|
||||
if (item.type === "ant_corpse") item.amount -= dt * 0.018;
|
||||
if (item.type === "firecracker") {
|
||||
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
|
||||
if (item.fuseTimer <= 0) {
|
||||
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(item);
|
||||
else item.amount = 0;
|
||||
}
|
||||
}
|
||||
if (item.type === "genkotsu") {
|
||||
item.impactFlash = Math.max(0, (item.impactFlash || 0) - dt);
|
||||
if (item.dropImpactDone) {
|
||||
item.lingerTimer = Math.max(0, (item.lingerTimer || 0) - dt);
|
||||
if ((item.lingerTimer || 0) <= 0) item.amount = 0;
|
||||
} else {
|
||||
item.amount = Math.max(item.amount || 0, 100);
|
||||
}
|
||||
}
|
||||
if (item.type === "ball") item.updateBall(dt, worldRef);
|
||||
if (item.type === "fan") item.updateFan(dt, worldRef);
|
||||
if (item.type === "rotator") item.updateRotator(dt, worldRef);
|
||||
if (item.type === "gate_fence") item.amount = 999;
|
||||
if (item.type === "duplicator") item.updateDuplicator(dt, worldRef);
|
||||
if (isPinType(item.type)) item.updatePushpin(dt, worldRef);
|
||||
if (item.type === "zunchi") item.updateZunchiMotion(dt, worldRef);
|
||||
if (item.type === "grass") {
|
||||
// Grass is intentionally not simulated per item. World.update randomly
|
||||
// advances one grass by one discrete stage every few seconds.
|
||||
normalizeGrassStage(item);
|
||||
} else if (item.type === "zunchi") {
|
||||
item.lifecycleTimer += dt;
|
||||
const interval = item.lifecycleInterval || (0.95 + stableUnit(item.id, "zunchi-life") * 0.45);
|
||||
if (item.lifecycleTimer >= interval) {
|
||||
const lifecycleDt = Math.min(item.lifecycleTimer, 2.4);
|
||||
item.lifecycleTimer = 0;
|
||||
item.updateZunchi(lifecycleDt, worldRef);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiItemLifecycleLegacySystem = Object.freeze({ updateOne });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -11,27 +11,24 @@
|
|||
"TarinaiItemGrowthLifecycleStep",
|
||||
]);
|
||||
|
||||
let cachedSteps = null;
|
||||
function concreteSteps() {
|
||||
return CONCRETE_STEP_NAMES.map(name => global[name]).filter(step => step && typeof step.update === "function");
|
||||
if (!cachedSteps) cachedSteps = CONCRETE_STEP_NAMES.map(name => global[name]);
|
||||
return cachedSteps;
|
||||
}
|
||||
|
||||
function canRunConcretePipeline() {
|
||||
return concreteSteps().length === CONCRETE_STEP_NAMES.length;
|
||||
}
|
||||
|
||||
function updateOne(item, dt, worldRef, options = {}) {
|
||||
if (!item || item.dead || !canRunConcretePipeline()) return false;
|
||||
if (!item || item.dead) return false;
|
||||
const steps = concreteSteps();
|
||||
if (steps.length !== CONCRETE_STEP_NAMES.length) return false;
|
||||
const context = options.context || {};
|
||||
for (const step of concreteSteps()) {
|
||||
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({
|
||||
stepNames: CONCRETE_STEP_NAMES,
|
||||
canRunConcretePipeline,
|
||||
updateOne,
|
||||
});
|
||||
global.TarinaiItemLifecyclePipeline = Object.freeze({ updateOne });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/prototype-boundary
|
||||
// Keeps Item.prototype methods available while implementation ownership
|
||||
// lives in lifecycle/dynamic systems.
|
||||
|
||||
Object.assign(Item.prototype, {
|
||||
update(dt, worldRef = world) { return window.TarinaiItemRuntime?.updateOne?.(this, dt, worldRef) ?? window.TarinaiItemLifecyclePipeline?.updateOne?.(this, dt, worldRef) ?? false; },
|
||||
|
||||
|
||||
updateRotator(dt, worldRef) { return window.TarinaiItemDynamicToolSystem?.updateRotator?.(this, dt, worldRef); },
|
||||
|
||||
updateDuplicator(dt, worldRef) { return window.TarinaiItemDynamicDuplicatorSystem?.update?.(this, dt, worldRef); },
|
||||
|
||||
updateZunchiMotion(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateMotion?.(this, dt, worldRef); },
|
||||
|
||||
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { return window.TarinaiItemDynamicZunchiSystem?.resolveHighSpeedTarinaiCollision?.(this, dt, worldRef, speed); },
|
||||
|
||||
updateBall(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.update?.(this, dt, worldRef); },
|
||||
|
||||
resolveBallObstacleCollisions(dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveObstacleCollisions?.(this, dt, worldRef); },
|
||||
|
||||
applyBallHayDrag(bed, dt, worldRef) { return window.TarinaiItemDynamicBallSystem?.applyHayDrag?.(this, bed, dt, worldRef); },
|
||||
|
||||
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) { return window.TarinaiItemDynamicBallSystem?.resolveCircleBounce?.(this, obstacle, hitRadius, restitution, worldRef); },
|
||||
|
||||
resolveBallRectBounce(rect, worldRef, source = null) { return window.TarinaiItemDynamicBallSystem?.resolveRectBounce?.(this, rect, worldRef, source); },
|
||||
|
||||
emitBallBounce(worldRef, obstacle) { return window.TarinaiItemDynamicBallSystem?.emitBounce?.(this, worldRef, obstacle); },
|
||||
|
||||
updatePushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.update?.(this, dt, worldRef); },
|
||||
|
||||
tryStickPushpin(worldRef) { return window.TarinaiItemDynamicPinSystem?.tryStick?.(this, worldRef); },
|
||||
|
||||
attachPushpin(t, worldRef, d = null) { return window.TarinaiItemDynamicPinSystem?.attach?.(this, t, worldRef, d); },
|
||||
|
||||
updateLodgedPushpin(dt, worldRef) { return window.TarinaiItemDynamicPinSystem?.updateLodged?.(this, dt, worldRef); },
|
||||
|
||||
detachPushpin(worldRef, reason = "released") { return window.TarinaiItemDynamicPinSystem?.detach?.(this, worldRef, reason); },
|
||||
|
||||
updateZunchi(dt, worldRef) { return window.TarinaiItemDynamicZunchiSystem?.updateLifecycle?.(this, dt, worldRef); }
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
(function (global) {
|
||||
function update(item, dt, worldRef) {
|
||||
if (!item || item.dead) return { done: true };
|
||||
return global.TarinaiItemDecaySystem?.update(item, dt, worldRef) || { done: false };
|
||||
return global.TarinaiItemDecaySystem.update(item, dt, worldRef) || { done: false };
|
||||
}
|
||||
|
||||
global.TarinaiItemDecayLifecycleStep = Object.freeze({ update });
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
(function (global) {
|
||||
function update(item, dt, worldRef) {
|
||||
if (!item || item.dead) return { done: true };
|
||||
global.TarinaiItemDynamicSystem?.update(item, dt, worldRef);
|
||||
global.TarinaiItemDynamicSystem.update(item, dt, worldRef);
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
(function (global) {
|
||||
function update(item, dt, worldRef) {
|
||||
if (!item || item.dead) return { done: true };
|
||||
return global.TarinaiItemGrowthSystem?.update(item, dt, worldRef) || { done: false };
|
||||
return global.TarinaiItemGrowthSystem.update(item, dt, worldRef) || { done: false };
|
||||
}
|
||||
|
||||
global.TarinaiItemGrowthLifecycleStep = Object.freeze({ update });
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/lifecycle-step
|
||||
// Fallback step for environments where the split item lifecycle pipeline cannot
|
||||
// run. The captured legacy Item.prototype.update is kept by item_runtime.js.
|
||||
(function (global) {
|
||||
function update(item, dt, worldRef, context = {}) {
|
||||
const legacyUpdate = context.legacyUpdate;
|
||||
if (!item || item.dead || typeof legacyUpdate !== "function") return { done: true, ok: false };
|
||||
legacyUpdate.call(item, dt, worldRef);
|
||||
return { done: true, ok: true };
|
||||
}
|
||||
|
||||
global.TarinaiItemLegacyLifecycleStep = Object.freeze({ update });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -14,58 +14,40 @@ const DUPLICATOR_STORABLE_TYPES = new Set([
|
|||
function duplicatorLoadTypeForItem(item) {
|
||||
if (!item || item.dead || item.type === "duplicator") return "";
|
||||
const type = String(item.type || "");
|
||||
if (!type || type === "water_bowl" || type === "grass") return "";
|
||||
if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") return "";
|
||||
// Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が
|
||||
// serving-food 判定や medicine role の揺れに左右されず複製機へ入る。
|
||||
if (!type || type === "grass") return "";
|
||||
if ((isPinType(type)) && item.pinState === "lodged") return "";
|
||||
if (type === "slave_chain" || type === "golden_crown") return "";
|
||||
// Explicit list first. \u3053\u308c\u3067\u300c\u305a\u3093\u3060\u9905\u300d\u300c\u3078\u3053\u9905\u300d\u300c\u3051\u3093\u304b\u9905\u300d\u304c
|
||||
// serving-food \u5224\u5b9a\u3084 medicine role \u306e\u63fa\u308c\u306b\u5de6\u53f3\u3055\u308c\u305a\u8907\u88fd\u6a5f\u3078\u5165\u308b\u3002
|
||||
if (DUPLICATOR_STORABLE_TYPES.has(type)) return type;
|
||||
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return type;
|
||||
if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type;
|
||||
const foodDef = global.TarinaiItemRegistry?.food?.get?.(type);
|
||||
if (isServingFoodType(type)) return type;
|
||||
if (isParamEffectItemType(type)) return type;
|
||||
const foodDef = global.TarinaiItemRegistry.food?.get?.(type);
|
||||
if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type;
|
||||
if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type;
|
||||
if (typeof roleMatches === "function" && roleMatches(item, "medicine")) return type;
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
|
||||
function itemAngleForRuntime(item) {
|
||||
return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
|
||||
}
|
||||
|
||||
const DUPLICATOR_LOAD_DWELL_SECONDS = 0.38;
|
||||
const DUPLICATOR_REPLACE_DWELL_SECONDS = 0.70;
|
||||
|
||||
function duplicatorLoadPoint(duplicator) {
|
||||
const r = duplicator?.r || 34;
|
||||
return { x: (duplicator?.x || 0) + r * 0.62, y: (duplicator?.y || 0) + r * 0.24 };
|
||||
}
|
||||
|
||||
function duplicatorCandidateReach(item) {
|
||||
return Math.max(20, Math.min(36, (item?.r || 12) * 0.82 + 13));
|
||||
}
|
||||
|
||||
function duplicatorCanLoadNow(duplicator, worldRef) {
|
||||
if (!duplicator || duplicator.type !== "duplicator") return false;
|
||||
if (duplicator.playerHeld || duplicator._heldByPlayer) return false;
|
||||
const now = Number(worldRef?.time || 0);
|
||||
if (now < Number(duplicator.duplicatorLoadSuppressedUntil || 0)) return false;
|
||||
if (Math.hypot(duplicator.vx || 0, duplicator.vy || 0) > 24) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncDuplicatorRoles(item) {
|
||||
if (!item || item.type !== "duplicator") return;
|
||||
const type = String(item.storedFoodType || "");
|
||||
const pinLoaded = Boolean(type && typeof isPinType === "function" && isPinType(type));
|
||||
const pinLoaded = Boolean(type && isPinType(type));
|
||||
item.roles.food = Boolean(type && !pinLoaded);
|
||||
item.roles.medicine = Boolean(type && !pinLoaded && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))));
|
||||
item.roles.medicine = Boolean(type && !pinLoaded && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (isParamEffectItemType(type))));
|
||||
item.roles.drink = Boolean(!pinLoaded && (type === "water" || type === "zunda_juice"));
|
||||
item.roles.danger = pinLoaded;
|
||||
}
|
||||
|
||||
function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, options = {}) {
|
||||
if (!duplicator || duplicator.type !== "duplicator") return false;
|
||||
if (options.direct !== true && options.force !== true) return false;
|
||||
const type = String(storedType || "");
|
||||
if (!type) return false;
|
||||
const probe = { type, amount: 1, foodServingsRemaining: 1, dead: false };
|
||||
|
|
@ -73,15 +55,12 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o
|
|||
if (!loadType) return false;
|
||||
const changed = duplicator.storedFoodType !== loadType;
|
||||
if (!changed && duplicator.storedFoodType && !options.force) return true;
|
||||
const def = typeof itemDefinition === "function" ? itemDefinition(loadType) : null;
|
||||
const newLabel = def?.label || (typeof toolLabel === "function" ? toolLabel(loadType) : loadType);
|
||||
const def = itemDefinition(loadType);
|
||||
const newLabel = def?.label || toolLabel(loadType);
|
||||
duplicator.storedFoodType = loadType;
|
||||
duplicator.storedFoodLabel = newLabel;
|
||||
syncDuplicatorRoles(duplicator);
|
||||
duplicator.loadedAt = Number(worldRef?.time || 0);
|
||||
duplicator._duplicatorCandidateKey = "";
|
||||
duplicator._duplicatorCandidateSince = 0;
|
||||
duplicator.duplicatorPinFlash = typeof isPinType === "function" && isPinType(loadType) ? 0.28 : (duplicator.duplicatorPinFlash || 0);
|
||||
duplicator.duplicatorPinFlash = isPinType(loadType) ? 0.28 : (duplicator.duplicatorPinFlash || 0);
|
||||
const consume = options.consumeItem;
|
||||
if (consume && typeof consume === "object") {
|
||||
consume.amount = 0;
|
||||
|
|
@ -91,20 +70,13 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o
|
|||
worldRef?.markSpatialDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
|
||||
worldRef?.markTerrainDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
|
||||
if (options.emit !== false) worldRef?.emit?.("duplicator:loaded", { duplicator, foodType: loadType, replaced: changed, direct: Boolean(options.direct) });
|
||||
if (options.log !== false) worldRef?.log?.(`複製機に${duplicator.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(loadType) ? "observe" : "food");
|
||||
if (options.log !== false) worldRef?.log?.(`\u8907\u88fd\u6a5f\u306b${duplicator.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, isPinType(loadType) ? "observe" : "food");
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiDuplicatorRuntime = Object.freeze({
|
||||
loadTypeForItem: duplicatorLoadTypeForItem,
|
||||
loadPoint: duplicatorLoadPoint,
|
||||
setStoredType: duplicatorSetStoredType,
|
||||
syncRoles: syncDuplicatorRoles,
|
||||
});
|
||||
|
||||
function duplicatorApplyStoredPin(duplicator, worldRef) {
|
||||
const storedType = String(duplicator?.storedFoodType || "");
|
||||
if (!duplicator || duplicator.type !== "duplicator" || !(typeof isPinType === "function" && isPinType(storedType))) return false;
|
||||
if (!duplicator || duplicator.type !== "duplicator" || !isPinType(storedType)) return false;
|
||||
const now = Number(worldRef?.time || 0);
|
||||
if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false;
|
||||
const dish = duplicatorLoadPoint(duplicator);
|
||||
|
|
@ -120,7 +92,6 @@ function duplicatorApplyStoredPin(duplicator, worldRef) {
|
|||
pin.noStickUntil = 0;
|
||||
pin.noStickTargetId = "";
|
||||
pin.noStickTargetUntil = 0;
|
||||
pin.duplicatedFromId = duplicator.id || "";
|
||||
const added = worldRef?.addItem?.(pin, "duplicator-pin", { terrain: false }) || null;
|
||||
if (!added && worldRef?.items) {
|
||||
pin.world = worldRef;
|
||||
|
|
@ -128,7 +99,7 @@ function duplicatorApplyStoredPin(duplicator, worldRef) {
|
|||
worldRef.markItemBucketsDirty?.("duplicator-pin");
|
||||
worldRef.markSpatialDirty?.("duplicator-pin");
|
||||
}
|
||||
const ok = pin.attachPushpin?.(t, worldRef, distXY(dish.x, dish.y, t.x, t.y));
|
||||
const ok = global.TarinaiItemDynamicPinSystem.attach(pin, t, worldRef, distXY(dish.x, dish.y, t.x, t.y));
|
||||
if (!ok) {
|
||||
pin.amount = 0;
|
||||
continue;
|
||||
|
|
@ -145,16 +116,11 @@ function duplicatorApplyStoredPin(duplicator, worldRef) {
|
|||
}
|
||||
|
||||
global.TarinaiItemLifecycleRuntimeSupport = Object.freeze({
|
||||
itemAngleForRuntime,
|
||||
duplicatorLoadTypeForItem,
|
||||
duplicatorLoadPoint,
|
||||
duplicatorCandidateReach,
|
||||
duplicatorCanLoadNow,
|
||||
syncDuplicatorRoles,
|
||||
duplicatorSetStoredType,
|
||||
duplicatorApplyStoredPin,
|
||||
DUPLICATOR_LOAD_DWELL_SECONDS,
|
||||
DUPLICATOR_REPLACE_DWELL_SECONDS,
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,770 +1,9 @@
|
|||
"use strict";
|
||||
(function ensureDeterministicHelpers(global) {
|
||||
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
|
||||
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
|
||||
const keyPart = value => {
|
||||
if (value == null) return "null";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
|
||||
if (typeof value === "string" || typeof value === "boolean") return String(value);
|
||||
if (typeof value === "object") {
|
||||
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
|
||||
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
|
||||
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
|
||||
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
|
||||
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const hashUnit = (seed, salt = "") => {
|
||||
const str = `${seed}:${salt}`;
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||
return ((h >>> 0) % 100000) / 100000;
|
||||
};
|
||||
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
|
||||
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
|
||||
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
|
||||
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
|
||||
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
|
||||
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
|
||||
// Unified item definition source: tools, item traits, visuals, food metadata, and item effects.
|
||||
|
||||
const TOOL_DEFINITIONS = Object.freeze({
|
||||
observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" },
|
||||
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
|
||||
area_delete: { id: "area_delete", label: "範囲削除", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "カーソル周囲の広い範囲にある置きものや汚れをまとめて消す。" },
|
||||
copy: { id: "copy", label: "コピー", placeable: false, scalable: false, iconText: "⧉", tooltip: "置いたアイテムをクリックしてコピーし、次のクリックで設定ごと貼り付ける。" },
|
||||
undo: { id: "undo", label: "戻す", placeable: false, scalable: false, iconText: "↶", tooltip: "直前の操作を取り消す。" },
|
||||
redo: { id: "redo", label: "進む", placeable: false, scalable: false, iconText: "↷", tooltip: "取り消した操作をやり直す。" },
|
||||
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
|
||||
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "たりないや一部の置きものを移動させる。" },
|
||||
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
|
||||
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
|
||||
rope: { id: "rope", label: "ヒモ", placeable: false, scalable: false, iconText: "╰", tooltip: "2つの対象を順にクリックして、伸び縮みしないヒモで接続する。機械体・柵・ヒモ・棒はクリック地点、それ以外は中心につなぐ。" },
|
||||
rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。" },
|
||||
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
|
||||
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
|
||||
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
|
||||
fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
|
||||
sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" },
|
||||
|
||||
laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" },
|
||||
protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" },
|
||||
niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" },
|
||||
ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" },
|
||||
mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" },
|
||||
mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" },
|
||||
giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" },
|
||||
dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" },
|
||||
zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" },
|
||||
grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" },
|
||||
stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" },
|
||||
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
|
||||
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
|
||||
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
|
||||
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
|
||||
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
|
||||
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
|
||||
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u30fb\u85ac\u30fb\u753b\u92f2\u30fb\u304a\u3057\u308a\u92f2\u3092\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002\u92f2\u7cfb\u306f\u89e6\u308c\u305f\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3002" },
|
||||
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
|
||||
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
|
||||
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
|
||||
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
rotator: { id: "rotator", itemType: "rotator", label: "回転体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "↻", collisionShape: "custom_rotator", tooltip: "回転する機械。クリックすると形状や速度を設定できる。" },
|
||||
poison_block: { id: "poison_block", itemType: "poison_block", label: "毒ブロック", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "☠", collisionShape: "custom_poison_block", tooltip: "自由に形を描ける毒の物理ブロック。外力で動き、触れたたりないにダメージ。当たり判定ON/OFFを切り替えられる。" },
|
||||
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "往復体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "⇄", collisionShape: "custom_reciprocator", tooltip: "往復する機械。クリックすると形状や速度を設定できる。" },
|
||||
water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" },
|
||||
trace: { id: "trace", itemType: "trace", label: "死骸", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
|
||||
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
|
||||
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
|
||||
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
|
||||
});
|
||||
|
||||
|
||||
const ITEM_TRAITS = Object.freeze({
|
||||
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
|
||||
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
|
||||
hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
|
||||
pin: new Set(["pushpin", "oshibyo"]),
|
||||
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
|
||||
sleepFurniture: new Set(["bed", "nest_box"]),
|
||||
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
|
||||
});
|
||||
|
||||
|
||||
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]));
|
||||
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
|
||||
const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"]));
|
||||
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]);
|
||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES]));
|
||||
function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); }
|
||||
function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); }
|
||||
function isLinkItemType(type = "") { return LINK_ITEM_TYPES.has(String(type || "")); }
|
||||
function isPhysicsToolId(id = "") { return PHYSICS_TOOL_IDS.includes(String(id || "")); }
|
||||
function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); }
|
||||
function defaultItemAngle(type = "") {
|
||||
const t = String(type || "");
|
||||
if (t === "fence_v" || t === "bounce_fence_v") return Math.PI / 2;
|
||||
return 0;
|
||||
}
|
||||
function normalizedItemAngle(angle = 0, fallback = 0) {
|
||||
const twoPi = Math.PI * 2;
|
||||
const n = Number(angle);
|
||||
const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0);
|
||||
return ((base % twoPi) + twoPi) % twoPi;
|
||||
}
|
||||
function itemAngleFor(itemOrType = "") {
|
||||
if (itemOrType && typeof itemOrType === "object") return normalizedItemAngle(itemOrType.angle, defaultItemAngle(itemOrType.type));
|
||||
return defaultItemAngle(String(itemOrType || ""));
|
||||
}
|
||||
function rotatableToolIds() {
|
||||
return Object.values(TOOL_DEFINITIONS).filter(def => isRotatableItemType(def.itemType || def.id)).map(def => def.id);
|
||||
}
|
||||
|
||||
function itemHasTrait(type = "", trait = "") {
|
||||
return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || "")));
|
||||
}
|
||||
|
||||
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
|
||||
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
|
||||
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
|
||||
function lodgedPinFor(tarinai = null, worldRef = null) {
|
||||
if (!tarinai?.stuckPushpinId) return null;
|
||||
const items = worldRef?.items || tarinai.world?.items || [];
|
||||
return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null;
|
||||
}
|
||||
function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); }
|
||||
function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); }
|
||||
|
||||
const PIN_BEHAVIORS = Object.freeze({
|
||||
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
|
||||
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
|
||||
});
|
||||
|
||||
function pinBehaviorFor(type = "") {
|
||||
return PIN_BEHAVIORS[String(type || "")] || null;
|
||||
}
|
||||
|
||||
|
||||
let ITEM_VISUAL_DEFINITIONS = null;
|
||||
let FOOD_REGISTRY = null;
|
||||
let FOOD_DEFINITIONS = null;
|
||||
let EFFECT_REGISTRY = null;
|
||||
let EFFECT_DEFINITIONS = null;
|
||||
|
||||
const TOOL_CATEGORIES = Object.freeze([
|
||||
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose"] },
|
||||
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] },
|
||||
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
|
||||
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard"] },
|
||||
{ id: "physics", label: "物理", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
|
||||
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
|
||||
]);
|
||||
|
||||
function toolCategories() {
|
||||
return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
}
|
||||
|
||||
function toolDefinition(id = "") {
|
||||
return TOOL_DEFINITIONS[id] || null;
|
||||
}
|
||||
|
||||
function toolLabel(id = "") {
|
||||
const def = toolDefinition(id);
|
||||
return def?.label || id || "";
|
||||
}
|
||||
|
||||
function toolItemType(id = "") {
|
||||
const def = toolDefinition(id);
|
||||
return def?.placeable ? (def.itemType || def.id) : null;
|
||||
}
|
||||
|
||||
function itemDefinition(type = "") {
|
||||
return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null;
|
||||
}
|
||||
|
||||
function itemRadiusFor(type = "", fallback = 12) {
|
||||
return itemDefinition(type)?.radius ?? fallback;
|
||||
}
|
||||
|
||||
function itemAmountFor(type = "", fallback = 80) {
|
||||
return itemDefinition(type)?.amount ?? fallback;
|
||||
}
|
||||
|
||||
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
|
||||
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" });
|
||||
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
|
||||
function normalizedToolSizeFor(worldRef = null, type = "") {
|
||||
const def = itemDefinition(type);
|
||||
if (!def?.scalable) return "medium";
|
||||
const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium";
|
||||
return TOOL_SIZE_ORDER.includes(value) ? value : "medium";
|
||||
}
|
||||
function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; }
|
||||
function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; }
|
||||
|
||||
function scalableToolIds() {
|
||||
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
|
||||
}
|
||||
|
||||
function toolTipsFromDefinitions() {
|
||||
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
|
||||
}
|
||||
|
||||
// Public facade for registries that are still consumed across modules.
|
||||
(function (global) {
|
||||
const raw = String.raw`{
|
||||
"water": {
|
||||
"renderer": "oval_droplet",
|
||||
"shape": "horizontal_oval_liquid",
|
||||
"palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"]
|
||||
},
|
||||
"zunda_juice": {
|
||||
"renderer": "oval_droplet",
|
||||
"shape": "horizontal_oval_liquid",
|
||||
"palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"]
|
||||
},
|
||||
"mercury": {
|
||||
"renderer": "oval_droplet",
|
||||
"shape": "horizontal_oval_liquid",
|
||||
"palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"]
|
||||
},
|
||||
"water_bowl": {
|
||||
"renderer": "water_bowl",
|
||||
"shape": "bowl_with_horizontal_oval_water",
|
||||
"palette": {
|
||||
"bowlFill": "rgba(171,120,76,0.72)",
|
||||
"bowlStroke": "rgba(94,69,48,0.55)",
|
||||
"waterFill": "rgba(86,157,224,0.58)",
|
||||
"waterStroke": "rgba(62,113,178,0.36)",
|
||||
"highlight": "rgba(255,255,255,0.54)"
|
||||
},
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"]
|
||||
},
|
||||
"sleep_drug": {
|
||||
"renderer": "sleep_tablet",
|
||||
"shape": "moon_tablet",
|
||||
"palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "moon", "purple"]
|
||||
},
|
||||
"laxative": {
|
||||
"renderer": "laxative_tablet",
|
||||
"shape": "rounded_tablet_zunchi_motif",
|
||||
"palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "zunchi motif", "tan"]
|
||||
},
|
||||
"mystery_drug": {
|
||||
"renderer": "mystery_tablet",
|
||||
"shape": "question_mark_tablet",
|
||||
"palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "question mark", "purple"]
|
||||
},
|
||||
"ammo": {
|
||||
"renderer": "bullet",
|
||||
"shape": "bullet",
|
||||
"palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["bullet", "gold"]
|
||||
},
|
||||
"protein": {
|
||||
"renderer": "powder_pile",
|
||||
"shape": "powder_pile",
|
||||
"palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["powder", "orange"]
|
||||
},
|
||||
"niteropu": {
|
||||
"renderer": "powder_pile",
|
||||
"shape": "powder_pile",
|
||||
"palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["powder", "purple"]
|
||||
},
|
||||
"giant_drug": {
|
||||
"renderer": "split_pill",
|
||||
"shape": "split_pill",
|
||||
"palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" },
|
||||
"scale": 1.08,
|
||||
"uiPreviewScale": 1.08,
|
||||
"fieldScale": 1.08,
|
||||
"accessibleShapeTraits": ["pill", "large", "orange"]
|
||||
},
|
||||
"dwarf_drug": {
|
||||
"renderer": "split_pill",
|
||||
"shape": "split_pill",
|
||||
"palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" },
|
||||
"scale": 0.92,
|
||||
"uiPreviewScale": 0.92,
|
||||
"fieldScale": 0.92,
|
||||
"accessibleShapeTraits": ["pill", "small", "purple"]
|
||||
},
|
||||
"ball": {
|
||||
"renderer": "emoji",
|
||||
"shape": "soccer_ball",
|
||||
"palette": {},
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["round", "soccer ball"],
|
||||
"emojiFallback": "\u26bd"
|
||||
}
|
||||
}`;
|
||||
|
||||
const defs = Object.freeze(JSON.parse(raw));
|
||||
|
||||
function itemVisualDefinition(type = "") {
|
||||
return defs[String(type || "")] || null;
|
||||
}
|
||||
|
||||
function itemVisualPalette(type = "") {
|
||||
return itemVisualDefinition(type)?.palette || null;
|
||||
}
|
||||
|
||||
ITEM_VISUAL_DEFINITIONS = defs;
|
||||
global.itemVisualDefinition = itemVisualDefinition;
|
||||
global.itemVisualPalette = itemVisualPalette;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
(function (global) {
|
||||
const rawDefinitions = {
|
||||
food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 },
|
||||
sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] },
|
||||
love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" },
|
||||
fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" },
|
||||
sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" },
|
||||
laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true },
|
||||
protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" },
|
||||
niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" },
|
||||
ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" },
|
||||
mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" },
|
||||
mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" },
|
||||
giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" },
|
||||
dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" },
|
||||
zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true },
|
||||
grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 },
|
||||
water: { label: "水滴", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
|
||||
water_bowl: { label: "水の皿", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
|
||||
ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 }
|
||||
};
|
||||
|
||||
class FoodRegistry extends global.TarinaiRegistryBase {
|
||||
servingTypes() {
|
||||
return [...this.defs.entries()]
|
||||
.filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest))
|
||||
.map(([id]) => id);
|
||||
}
|
||||
typesByInterest(...interests) {
|
||||
const wanted = new Set(interests.flat().filter(Boolean));
|
||||
return [...this.defs.entries()]
|
||||
.filter(([, def]) => wanted.has(def.interest))
|
||||
.map(([id]) => id);
|
||||
}
|
||||
paramEffectTypes() {
|
||||
return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id);
|
||||
}
|
||||
nutrition(type = "", fallback = 0) {
|
||||
const value = this.get(type)?.nutrition;
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
hungerRelief(type = "", fallback = 0) {
|
||||
const value = this.get(type)?.hungerRelief;
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
effectId(type = "") { return this.get(type)?.effectId || ""; }
|
||||
passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; }
|
||||
hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; }
|
||||
debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); }
|
||||
}
|
||||
|
||||
const registry = new FoodRegistry(rawDefinitions);
|
||||
FOOD_REGISTRY = registry;
|
||||
FOOD_DEFINITIONS = Object.freeze(rawDefinitions);
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
(function (global) {
|
||||
const rawDefinitions = {
|
||||
laxative: {
|
||||
label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60,
|
||||
tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`,
|
||||
},
|
||||
ammo: {
|
||||
label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18,
|
||||
tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`,
|
||||
},
|
||||
protein: {
|
||||
label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power",
|
||||
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d",
|
||||
},
|
||||
niteropu: {
|
||||
label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power",
|
||||
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d",
|
||||
},
|
||||
mercury: {
|
||||
label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply",
|
||||
tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`,
|
||||
},
|
||||
giant_drug: {
|
||||
label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size",
|
||||
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
|
||||
},
|
||||
dwarf_drug: {
|
||||
label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size",
|
||||
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
|
||||
},
|
||||
love_mochi: {
|
||||
label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer",
|
||||
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`,
|
||||
},
|
||||
fight_mochi: {
|
||||
label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer",
|
||||
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`,
|
||||
},
|
||||
sleep_drug: {
|
||||
label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"],
|
||||
},
|
||||
mystery_drug: {
|
||||
label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"],
|
||||
},
|
||||
zunda_juice: {
|
||||
label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"],
|
||||
},
|
||||
disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] },
|
||||
};
|
||||
|
||||
Object.assign(rawDefinitions.laxative, {
|
||||
modifiers: { hungerReliefMultiplier: 0 },
|
||||
tickInterval: 3,
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.ammo, {
|
||||
modifiers: { speedMultiplier: 10 },
|
||||
visualEffectId: "fall",
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.protein, {
|
||||
modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 },
|
||||
visualEffectId: "fall_up_red",
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.niteropu, {
|
||||
modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 },
|
||||
visualEffectId: "fall_down_purple",
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.mercury, {
|
||||
modifiers: { lifeMultiplierPerUse: 1.3 },
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.giant_drug, {
|
||||
modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 },
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.dwarf_drug, {
|
||||
modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 },
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) {
|
||||
Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" });
|
||||
}
|
||||
Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false });
|
||||
|
||||
Object.assign(rawDefinitions.love_mochi, {
|
||||
onApply(tarinai, context = {}) {
|
||||
const scale = context.scale ?? 1;
|
||||
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + deterministicRange(tarinai.world, "love-mochi-duration", 4, 12, tarinai, context.item));
|
||||
const nutrition = Number(context.nutrition) || 0;
|
||||
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
|
||||
if (context.source === "eating") {
|
||||
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.fight_mochi, {
|
||||
onApply(tarinai, context = {}) {
|
||||
const scale = context.scale ?? 1;
|
||||
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + deterministicRange(tarinai.world, "fight-mochi-duration", 10, 18, tarinai, context.item));
|
||||
const nutrition = Number(context.nutrition) || 0;
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
|
||||
if (context.source === "eating") {
|
||||
tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32);
|
||||
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.sleep_drug, {
|
||||
onApply(tarinai, context = {}) {
|
||||
if (context.source === "eating") {
|
||||
const nutrition = Number(context.nutrition) || 0;
|
||||
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 });
|
||||
tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100));
|
||||
tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18);
|
||||
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5);
|
||||
const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075);
|
||||
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
|
||||
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
|
||||
if (!tarinai.sleepDisease && deterministicChance(tarinai.world, "sleep-drug-disease", Math.min(0.98, sleepChance), tarinai, context.item)) return tarinai.infectSleepDisease?.(context.item || null) || true;
|
||||
return true;
|
||||
}
|
||||
return tarinai.infectSleepDisease?.(context.item || null) || true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.laxative, {
|
||||
onApply(tarinai, context = {}) {
|
||||
return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1)));
|
||||
},
|
||||
onTick(tarinai) {
|
||||
tarinai.forceLaxativePoop?.();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.ammo, {
|
||||
onApply(tarinai, context = {}) {
|
||||
return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1)));
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.protein, {
|
||||
onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); },
|
||||
});
|
||||
Object.assign(rawDefinitions.niteropu, {
|
||||
onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); },
|
||||
});
|
||||
Object.assign(rawDefinitions.mercury, {
|
||||
onApply(tarinai) { return tarinai.setMercuryEffect?.(); },
|
||||
});
|
||||
Object.assign(rawDefinitions.giant_drug, {
|
||||
onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); },
|
||||
onTick(tarinai) {
|
||||
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
|
||||
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.dwarf_drug, {
|
||||
onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); },
|
||||
onTick(tarinai) {
|
||||
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
|
||||
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.zunda_juice, {
|
||||
onApply(tarinai) { return tarinai.clearAllItemEffects?.(); },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_zunchi, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_sleep, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_explosion, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; },
|
||||
});
|
||||
Object.assign(rawDefinitions.disease_fight, {
|
||||
onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; },
|
||||
});
|
||||
|
||||
class EffectRegistry extends global.TarinaiRegistryBase {
|
||||
label(id = "") { return super.label(id, id || "\u4e0d\u660e"); }
|
||||
color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
|
||||
display(id = "", timer = 0, tarinai = null) {
|
||||
const def = this.get(id);
|
||||
if (!def) return "";
|
||||
return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || "");
|
||||
}
|
||||
apply(id = "", tarinai = null, context = {}) {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai || typeof def.onApply !== "function") return false;
|
||||
return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this }));
|
||||
}
|
||||
tick(id = "", tarinai = null, dt = 0, context = {}) {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai || typeof def.onTick !== "function") return false;
|
||||
return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this }));
|
||||
}
|
||||
remove(id = "", tarinai = null, context = {}) {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai || typeof def.onRemove !== "function") return false;
|
||||
return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this }));
|
||||
}
|
||||
modifier(id = "", key = "", fallback = 1) {
|
||||
const value = this.get(id)?.modifiers?.[key];
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
timerValue(tarinai = null, id = "") {
|
||||
const def = this.get(id);
|
||||
if (!def || !tarinai) return 0;
|
||||
if (def.timerProp) return Number(tarinai[def.timerProp] || 0);
|
||||
if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0);
|
||||
return 0;
|
||||
}
|
||||
isActive(tarinai = null, id = "") {
|
||||
if (!tarinai) return false;
|
||||
const def = this.get(id);
|
||||
if (!def) return false;
|
||||
if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id;
|
||||
if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id;
|
||||
if (id === "mercury") return tarinai.lifeItemMode === "mercury";
|
||||
return this.timerValue(tarinai, id) > 0.04;
|
||||
}
|
||||
activeSummary(tarinai = null) {
|
||||
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"];
|
||||
return order
|
||||
.filter(id => this.isActive(tarinai, id))
|
||||
.map(id => ({
|
||||
id,
|
||||
label: this.label(id),
|
||||
detail: this.display(id, this.timerValue(tarinai, id), tarinai),
|
||||
}));
|
||||
}
|
||||
labels() { return super.labels(); }
|
||||
colors() { return super.colors(); }
|
||||
randomPool() {
|
||||
const itemEffects = [...this.defs.entries()]
|
||||
.filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease")))
|
||||
.map(([id]) => id)
|
||||
.filter(id => id !== "mystery_drug" && id !== "zunda_juice");
|
||||
const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || [];
|
||||
return [...new Set([...itemEffects, ...diseases])];
|
||||
}
|
||||
permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); }
|
||||
timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); }
|
||||
debugSnapshot() {
|
||||
return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" }));
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new EffectRegistry(rawDefinitions);
|
||||
EFFECT_REGISTRY = registry;
|
||||
EFFECT_DEFINITIONS = Object.freeze(rawDefinitions);
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
|
||||
(function (global) {
|
||||
function itemTypeDefinition(type = "") {
|
||||
const id = String(type || "");
|
||||
const tool = typeof itemDefinition === "function" ? itemDefinition(id) : null;
|
||||
const food = FOOD_DEFINITIONS?.[id] || null;
|
||||
const effectId = food?.effectId || (EFFECT_DEFINITIONS?.[id] ? id : "");
|
||||
const effect = effectId ? (EFFECT_DEFINITIONS?.[effectId] || null) : null;
|
||||
const visual = ITEM_VISUAL_DEFINITIONS?.[id] || null;
|
||||
return {
|
||||
id,
|
||||
tool,
|
||||
food,
|
||||
effect,
|
||||
effectId,
|
||||
visual,
|
||||
label: tool?.label || food?.label || effect?.label || id,
|
||||
radius: tool?.radius,
|
||||
amount: tool?.amount,
|
||||
placeable: Boolean(tool?.placeable),
|
||||
scalable: Boolean(tool?.scalable),
|
||||
traits: Object.fromEntries(Object.keys(ITEM_TRAITS).map(trait => [trait, itemHasTrait(id, trait)])),
|
||||
};
|
||||
}
|
||||
|
||||
function itemTypeLabel(type = "") {
|
||||
return itemTypeDefinition(type).label;
|
||||
}
|
||||
|
||||
function itemTypeDebugSnapshot() {
|
||||
const ids = new Set([
|
||||
...Object.values(TOOL_DEFINITIONS).map(def => def.itemType || def.id),
|
||||
...Object.keys(FOOD_DEFINITIONS || {}),
|
||||
...Object.keys(EFFECT_DEFINITIONS || {}),
|
||||
...Object.keys(ITEM_VISUAL_DEFINITIONS || {}),
|
||||
]);
|
||||
return [...ids].sort().map(id => itemTypeDefinition(id));
|
||||
}
|
||||
|
||||
global.TarinaiItemRegistry = Object.freeze({
|
||||
definition: itemTypeDefinition,
|
||||
label: itemTypeLabel,
|
||||
debugSnapshot: itemTypeDebugSnapshot,
|
||||
tool: toolDefinition,
|
||||
item: itemDefinition,
|
||||
food: FOOD_REGISTRY,
|
||||
effect: EFFECT_REGISTRY,
|
||||
visual: itemVisualDefinition,
|
||||
hasTrait: itemHasTrait,
|
||||
radius: itemRadiusFor,
|
||||
amount: itemAmountFor,
|
||||
categories: toolCategories,
|
||||
physicsToolIds: () => [...PHYSICS_TOOL_IDS],
|
||||
isFence: isFenceItemType,
|
||||
isMechanical: isMechanicalItemType,
|
||||
isLink: isLinkItemType,
|
||||
scalableToolIds,
|
||||
pinBehavior: pinBehaviorFor,
|
||||
food: global.TarinaiFoodRegistry,
|
||||
effect: global.TarinaiEffectRegistry,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.isFenceItemType = isFenceItemType;
|
||||
window.isMechanicalItemType = isMechanicalItemType;
|
||||
window.isLinkItemType = isLinkItemType;
|
||||
window.isPhysicsToolId = isPhysicsToolId;
|
||||
window.isRotatableItemType = isRotatableItemType;
|
||||
window.defaultItemAngle = defaultItemAngle;
|
||||
window.normalizedItemAngle = normalizedItemAngle;
|
||||
window.itemAngleFor = itemAngleFor;
|
||||
window.rotatableToolIds = rotatableToolIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// Item field rendering. Kept separate from construction and lifecycle logic.
|
||||
|
||||
function segmentPathCacheKey(item, segments, extra = "") {
|
||||
const version = Number(item?._mechanicalShapeVersion || item?._segmentsVersion || 0) || 0;
|
||||
const version = Number(item?._mechanicalShapeVersion || 0) || 0;
|
||||
const len = Array.isArray(segments) ? segments.length : 0;
|
||||
let h = len * 2654435761;
|
||||
if (Array.isArray(segments)) {
|
||||
|
|
@ -52,7 +52,7 @@ function strokeSegments(ctx, item, segments, extra = "") {
|
|||
}
|
||||
|
||||
function mechanicalRenderTier() {
|
||||
return window.TarinaiPerf?.renderQualityTier?.() || "high";
|
||||
return window.TarinaiPerf.renderQualityTier();
|
||||
}
|
||||
|
||||
function drawRopeParticlePath(ctx, particles, ox, oy) {
|
||||
|
|
@ -72,23 +72,23 @@ function drawRopeParticlePath(ctx, particles, ox, oy) {
|
|||
Object.assign(Item.prototype, {
|
||||
draw(ctx, t, lighting = null) {
|
||||
const lightState = lighting || getLightingState(world);
|
||||
const night = clamp(lightState.nightStrength * 1.35, 0, 1);
|
||||
const warm = lightState.warmth;
|
||||
const styleNight = lightState.light < 0.42;
|
||||
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
|
||||
if (isPinType(this.type) && this.pinState === "lodged") {
|
||||
const owner = world?.liveTarinaiById?.(this.pinTargetId) || null;
|
||||
if (owner?.insideNestBoxId) return;
|
||||
if (owner && world?.isTarinaiHiddenInNestBox?.(owner)) return;
|
||||
}
|
||||
const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null;
|
||||
const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1);
|
||||
const forceOpaque = this.type === "slave_chain" || this.type === "golden_crown";
|
||||
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") {
|
||||
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
|
||||
const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null;
|
||||
const zunchiImg = zunchiId ? getRenderableImage(zunchiId, "zunchi") : null;
|
||||
if (zunchiImg) {
|
||||
const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi");
|
||||
const ratio = metrics?.ratio || 178 / 236;
|
||||
|
|
@ -110,7 +110,6 @@ draw(ctx, t, lighting = null) {
|
|||
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 });
|
||||
}
|
||||
}
|
||||
drawItemGlow(ctx, this, lightState, visibleAlpha);
|
||||
if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.32;
|
||||
|
|
@ -127,7 +126,7 @@ draw(ctx, t, lighting = null) {
|
|||
if (isConsumableSpriteType(this.type)) {
|
||||
drawConsumableFieldSprite(ctx, this.type, this.r, this.seed);
|
||||
} else if (this.type === "genkotsu") {
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu");
|
||||
const img = getRenderableImage("genkotsu", "genkotsu");
|
||||
const impact = clamp(this.impactFlash || 0, 0, 1);
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(54,42,31,0.26)";
|
||||
|
|
@ -137,7 +136,7 @@ draw(ctx, t, lighting = null) {
|
|||
const stretchX = 1 + impact * 0.05;
|
||||
ctx.scale(stretchX, squashY);
|
||||
if (img) {
|
||||
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null;
|
||||
const metrics = getImageMetrics("genkotsu");
|
||||
const w = this.r * 2.55;
|
||||
const h = w * (metrics?.ratio || 1.27);
|
||||
ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h);
|
||||
|
|
@ -165,9 +164,9 @@ draw(ctx, t, lighting = null) {
|
|||
} else if (isPinType(this.type)) {
|
||||
const stuck = this.pinState === "lodged";
|
||||
const isOshibyo = this.type === "oshibyo";
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
|
||||
const behavior = pinBehaviorFor(this.type);
|
||||
const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin");
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
|
||||
const img = getRenderableImage(assetId, assetId);
|
||||
ctx.save();
|
||||
if (stuck) ctx.scale((this.pinVisualSide || 1) < 0 ? -1 : 1, 1);
|
||||
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
|
||||
|
|
@ -175,7 +174,7 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.shadowBlur = 3;
|
||||
ctx.shadowOffsetY = 2;
|
||||
if (img) {
|
||||
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
|
||||
const metrics = getImageMetrics(assetId);
|
||||
const ratio = metrics?.ratio || (stuck ? 1 : 2.0);
|
||||
const w = isOshibyo ? (stuck ? this.r * 1.55 : this.r * 0.78) : (stuck ? this.r * 2.15 : this.r * 1.78);
|
||||
const h = w * ratio;
|
||||
|
|
@ -209,22 +208,57 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "water_bowl") {
|
||||
const palette = visualPaletteFor("water_bowl");
|
||||
ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)";
|
||||
ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)";
|
||||
ctx.lineWidth = 2.2;
|
||||
|
||||
} else if (this.type === "slave_chain") {
|
||||
const r = this.r || 15;
|
||||
ctx.save();
|
||||
ctx.rotate(-0.35 + Math.sin(this.seed || 0) * 0.18);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = styleNight ? "rgba(122,118,112,0.94)" : "rgba(92,86,78,0.94)";
|
||||
ctx.lineWidth = Math.max(2.2, r * 0.18);
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
ctx.save();
|
||||
ctx.translate(i * r * 0.52, Math.sin(i + this.seed) * r * 0.08);
|
||||
ctx.rotate(i % 2 ? 0.65 : -0.65);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)";
|
||||
ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)";
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.ellipse(0, 0, r * 0.36, r * 0.20, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.strokeStyle = "rgba(230,224,214,0.46)";
|
||||
ctx.lineWidth = Math.max(1.0, r * 0.06);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2);
|
||||
ctx.moveTo(-r * 0.95, -r * 0.24);
|
||||
ctx.lineTo(r * 0.86, -r * 0.18);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
} else if (this.type === "golden_crown") {
|
||||
const r = this.r || 16;
|
||||
ctx.save();
|
||||
ctx.rotate(0.08 + Math.sin(this.seed || 0) * 0.05);
|
||||
ctx.fillStyle = styleNight ? "#c28a22" : "#f0bf3f";
|
||||
ctx.strokeStyle = styleNight ? "rgba(82,54,12,0.88)" : "rgba(124,78,12,0.82)";
|
||||
ctx.lineWidth = Math.max(1.4, r * 0.10);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-r * 0.92, r * 0.36);
|
||||
ctx.lineTo(-r * 0.72, -r * 0.38);
|
||||
ctx.lineTo(-r * 0.32, r * 0.02);
|
||||
ctx.lineTo(0, -r * 0.62);
|
||||
ctx.lineTo(r * 0.32, r * 0.02);
|
||||
ctx.lineTo(r * 0.72, -r * 0.38);
|
||||
ctx.lineTo(r * 0.92, r * 0.36);
|
||||
ctx.closePath();
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)";
|
||||
ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,246,172,0.92)";
|
||||
for (const px of [-0.72, 0, 0.72]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(r * px, px === 0 ? -r * 0.54 : -r * 0.32, Math.max(1.4, r * 0.09), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = "rgba(255,238,128,0.94)";
|
||||
ctx.fillRect(-r * 0.78, r * 0.20, r * 1.56, r * 0.24);
|
||||
ctx.restore();
|
||||
} else if (this.type === "water") {
|
||||
drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water"));
|
||||
} else if (this.type === "grass") {
|
||||
|
|
@ -309,6 +343,59 @@ 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 === "robot_cleaner") {
|
||||
ctx.save();
|
||||
const flash = clamp(Number(this.robotCleanerCleanFlash || 0) || 0, 0, 1);
|
||||
const angle = Number(this.robotCleanerAngle || this.angle || 0) || 0;
|
||||
const bodyA = styleNight ? "#39434a" : "#d8e1e3";
|
||||
const bodyB = styleNight ? "#1f272d" : "#a7b4b8";
|
||||
const trim = styleNight ? "#657781" : "#ffffff";
|
||||
const dark = styleNight ? "#12191e" : "#526168";
|
||||
ctx.fillStyle = "rgba(20,24,28,0.16)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, this.r * 0.56, this.r * 1.05, this.r * 0.30, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
const bodyGrad = ctx.createRadialGradient(-this.r * 0.34, -this.r * 0.38, this.r * 0.18, 0, 0, this.r * 1.28);
|
||||
bodyGrad.addColorStop(0, trim);
|
||||
bodyGrad.addColorStop(0.46, bodyA);
|
||||
bodyGrad.addColorStop(1, bodyB);
|
||||
ctx.fillStyle = bodyGrad;
|
||||
ctx.strokeStyle = dark;
|
||||
ctx.lineWidth = Math.max(2, this.r * 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, this.r * 1.04, this.r * 0.88, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.rotate(angle);
|
||||
ctx.fillStyle = flash > 0.02 ? "rgba(105,210,225,0.84)" : (styleNight ? "#50636d" : "#71868e");
|
||||
ctx.strokeStyle = dark;
|
||||
ctx.lineWidth = Math.max(1, this.r * 0.045);
|
||||
roundedRect(ctx, this.r * 0.18, -this.r * 0.15, this.r * 0.58, this.r * 0.30, this.r * 0.09);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
ctx.fillStyle = styleNight ? "#20292f" : "#eef4f5";
|
||||
ctx.strokeStyle = styleNight ? "#11181d" : "#6a7980";
|
||||
ctx.lineWidth = Math.max(1.4, this.r * 0.055);
|
||||
ctx.beginPath();
|
||||
ctx.arc(-this.r * 0.26, -this.r * 0.12, this.r * 0.22, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.r * 0.26, -this.r * 0.12, this.r * 0.22, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = flash > 0.02 ? `rgba(110,220,235,${0.42 + flash * 0.42})` : "rgba(80,100,110,0.38)";
|
||||
ctx.lineWidth = Math.max(1.1, this.r * 0.04);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.r * 0.56, 0.22, Math.PI - 0.22);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = flash > 0.02 ? `rgba(125,230,240,${0.28 + flash * 0.36})` : "rgba(60,78,86,0.20)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, this.r * 0.20, this.r * (0.18 + flash * 0.10), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else if (this.type === "duplicator") {
|
||||
ctx.save();
|
||||
const loaded = !!this.storedFoodType;
|
||||
|
|
@ -445,6 +532,96 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.fillText("\u2026", 0, -this.r * 0.58);
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "dry_ice") {
|
||||
const r = this.r || 21;
|
||||
const time = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0);
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(90, 178, 255, 0.30)";
|
||||
ctx.shadowBlur = 11;
|
||||
|
||||
// 旧版の氷塊風から、白い保冷デュワー缶風の外観へ変更。
|
||||
const bodyW = r * 1.38;
|
||||
const bodyH = r * 1.56;
|
||||
const x = -bodyW / 2;
|
||||
const y = -bodyH * 0.36;
|
||||
const capH = r * 0.34;
|
||||
const footH = r * 0.18;
|
||||
|
||||
const grad = ctx.createLinearGradient(x, y, x + bodyW, y);
|
||||
grad.addColorStop(0, styleNight ? "rgba(134, 169, 188, 0.96)" : "rgba(214, 242, 252, 0.98)");
|
||||
grad.addColorStop(0.48, styleNight ? "rgba(230, 247, 252, 0.98)" : "rgba(255, 255, 252, 1)");
|
||||
grad.addColorStop(1, styleNight ? "rgba(108, 150, 176, 0.98)" : "rgba(164, 220, 244, 0.98)");
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.strokeStyle = styleNight ? "rgba(54, 96, 124, 0.82)" : "rgba(63, 138, 180, 0.76)";
|
||||
ctx.lineWidth = 2.1;
|
||||
roundedRect(ctx, x, y, bodyW, bodyH, r * 0.28);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = styleNight ? "rgba(72, 118, 148, 0.96)" : "rgba(88, 176, 220, 0.96)";
|
||||
roundedRect(ctx, -bodyW * 0.42, y - capH * 0.52, bodyW * 0.84, capH, r * 0.14);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = styleNight ? "rgba(42, 74, 96, 0.78)" : "rgba(48, 119, 158, 0.72)";
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = styleNight ? "rgba(41, 86, 118, 0.86)" : "rgba(39, 147, 198, 0.86)";
|
||||
roundedRect(ctx, -bodyW * 0.34, y + bodyH * 0.24, bodyW * 0.68, bodyH * 0.22, r * 0.10);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||
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₂", 0, y + bodyH * 0.35);
|
||||
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.72)";
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + bodyW * 0.25, y + bodyH * 0.05);
|
||||
ctx.lineTo(x + bodyW * 0.25, y + bodyH * 0.72);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = styleNight ? "rgba(61, 89, 110, 0.72)" : "rgba(84, 132, 156, 0.58)";
|
||||
roundedRect(ctx, -bodyW * 0.48, y + bodyH - footH * 0.4, bodyW * 0.96, footH, r * 0.10);
|
||||
ctx.fill();
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const phase = (time * (0.42 + i * 0.06) + i * 0.23 + (this.seed || 0)) % 1;
|
||||
const sx = -r * 0.50 + i * r * 0.33 + Math.sin(time * 1.4 + i) * r * 0.06;
|
||||
const sy = y - capH * 0.62 - phase * r * 0.76;
|
||||
ctx.globalAlpha = (0.30 + (1 - phase) * 0.28) * (styleNight ? 0.76 : 1);
|
||||
ctx.fillStyle = "rgba(222, 247, 255, 0.72)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(sx, sy, r * (0.12 + phase * 0.08), r * (0.07 + phase * 0.05), Math.sin(i) * 0.7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "stove") {
|
||||
const r = this.r || 24;
|
||||
ctx.save();
|
||||
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;
|
||||
roundedRect(ctx, -r * 0.78, -r * 0.62, r * 1.56, r * 1.24, r * 0.20);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "rgba(42, 32, 28, 0.92)";
|
||||
roundedRect(ctx, -r * 0.48, -r * 0.34, r * 0.96, r * 0.62, r * 0.14);
|
||||
ctx.fill();
|
||||
const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0));
|
||||
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, r * 0.16);
|
||||
ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame);
|
||||
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else if (this.type === "ant_nest") {
|
||||
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
|
||||
ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a");
|
||||
|
|
@ -471,7 +648,7 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(`${count}`, 0, this.r * 1.25);
|
||||
} else if (this.type === "ant_corpse") {
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker");
|
||||
const img = getRenderableImage("ant_worker", "ant_worker");
|
||||
ctx.save();
|
||||
ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26);
|
||||
ctx.scale(1, -1);
|
||||
|
|
@ -491,21 +668,25 @@ draw(ctx, t, lighting = null) {
|
|||
} else if (this.type === "ball") {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
const moving = clamp(speed / 260, 0, 1);
|
||||
const r = this.r || 18;
|
||||
ctx.save();
|
||||
ctx.rotate(this.spin || 0);
|
||||
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)";
|
||||
ctx.shadowBlur = 2 + moving * 3;
|
||||
ctx.shadowOffsetY = 2;
|
||||
ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||
// Use the original soccer-ball emoji as the visible sprite. The
|
||||
// collision center remains item.x/item.y; rendering is centered via
|
||||
// textAlign/textBaseline rather than replacing the emoji with vector art.
|
||||
ctx.font = `${Math.max(18, r * 2.05)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("\u26bd", 0, 1);
|
||||
ctx.fillText("⚽", 0, 0);
|
||||
if (moving > 0.55) {
|
||||
ctx.globalAlpha = 0.14 + moving * 0.10;
|
||||
ctx.globalAlpha *= 0.10 + moving * 0.10;
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2);
|
||||
ctx.ellipse(-r * 0.42, -r * 0.45, r * 0.25, r * 0.12, -0.45, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
|
|
@ -536,10 +717,11 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.font = "bold 10px ui-rounded, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
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 === "bounce_fence" || this.type === "bounce_fence_v" || this.type === "gate_fence") {
|
||||
} 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";
|
||||
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
|
||||
const glassWall = this.type === "glass_wall";
|
||||
const angle = itemAngleFor(this);
|
||||
const len = Math.max(112, this.r * 3.55);
|
||||
const thick = Math.max(10, this.r * 0.31);
|
||||
const open = gate && this.gateOpen;
|
||||
|
|
@ -564,7 +746,31 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.fill();
|
||||
ctx.restore();
|
||||
};
|
||||
if (gate) {
|
||||
if (glassWall) {
|
||||
const glassGrad = ctx.createLinearGradient(-len / 2, -thick / 2, len / 2, thick / 2);
|
||||
glassGrad.addColorStop(0, styleNight ? "rgba(92,134,154,0.30)" : "rgba(208,244,255,0.36)");
|
||||
glassGrad.addColorStop(0.48, styleNight ? "rgba(152,196,210,0.18)" : "rgba(255,255,255,0.18)");
|
||||
glassGrad.addColorStop(1, styleNight ? "rgba(66,104,128,0.36)" : "rgba(134,214,238,0.32)");
|
||||
ctx.fillStyle = glassGrad;
|
||||
ctx.strokeStyle = styleNight ? "rgba(184,228,244,0.78)" : "rgba(80,176,208,0.72)";
|
||||
ctx.lineWidth = Math.max(2, thick * 0.18);
|
||||
ctx.beginPath();
|
||||
roundedRect(ctx, -len / 2, -thick / 2, len, thick, thick * 0.45);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = styleNight ? "rgba(235,252,255,0.46)" : "rgba(255,255,255,0.72)";
|
||||
ctx.lineWidth = Math.max(1.2, thick * 0.08);
|
||||
for (let x = -len * 0.42; x <= len * 0.35; x += len * 0.26) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, -thick * 0.28);
|
||||
ctx.lineTo(x + len * 0.16, thick * 0.28);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = styleNight ? "rgba(220,248,255,0.74)" : "rgba(255,255,255,0.82)";
|
||||
ctx.beginPath();
|
||||
roundedRect(ctx, -len * 0.46, -thick * 0.32, len * 0.32, thick * 0.10, thick * 0.10);
|
||||
ctx.fill();
|
||||
} else if (gate) {
|
||||
const pivotX = -len / 2;
|
||||
ctx.save();
|
||||
drawRail(pivotX, -thick / 2, len, thick, open ? 0.34 : 1);
|
||||
|
|
@ -614,13 +820,12 @@ draw(ctx, t, lighting = null) {
|
|||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "rotator") {
|
||||
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
|
||||
const angle = itemAngleFor(this);
|
||||
const pb = window.TarinaiPhysicsBodySystem;
|
||||
const powered = pb?.scalar?.(this, "motorOn", true) !== false;
|
||||
const speed = Number((powered ? pb?.scalar?.(this, "motorSpeed", 0) : pb?.scalar?.(this, "spin", 0)) || 0) || 0;
|
||||
const segments = (pb?.segments?.(this) || [[-78, 0, 78, 0], [0, -52, 0, 52]]);
|
||||
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
|
||||
const tier = mechanicalRenderTier();
|
||||
ctx.save();
|
||||
ctx.rotate(angle);
|
||||
ctx.lineCap = "round";
|
||||
|
|
@ -628,15 +833,8 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.globalAlpha *= powered ? 1 : 0.72;
|
||||
ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5";
|
||||
ctx.lineWidth = thick;
|
||||
ctx.shadowColor = powered ? (styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)") : "rgba(88,80,96,0.20)";
|
||||
ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.16) : Math.max(4, thick * 0.28));
|
||||
strokeSegments(ctx, this, segments, "body");
|
||||
ctx.shadowBlur = 0;
|
||||
if (tier !== "low") {
|
||||
ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)";
|
||||
ctx.lineWidth = Math.max(1.4, thick * 0.22);
|
||||
strokeSegments(ctx, this, segments, "body");
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.fillStyle = powered ? (styleNight ? "#3c324e" : "#f4eaff") : (styleNight ? "#34343a" : "#ece8ef");
|
||||
ctx.strokeStyle = powered ? (styleNight ? "#a28cc8" : "#6e4fa4") : "rgba(92,88,98,0.82)";
|
||||
|
|
@ -648,48 +846,30 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(powered ? (speed >= 0 ? "↻" : "↺") : "○", 0, 0);
|
||||
ctx.fillText(powered ? (speed >= 0 ? "\u21bb" : "\u21ba") : "\u25cb", 0, 0);
|
||||
} else if (this.type === "poison_block") {
|
||||
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
|
||||
const angle = itemAngleFor(this);
|
||||
const pb = window.TarinaiPhysicsBodySystem;
|
||||
const enabled = pb?.scalar?.(this, "solid", true) !== false;
|
||||
const segments = (pb?.segments?.(this) || [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]]);
|
||||
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 14) || 14) || 14));
|
||||
const tier = mechanicalRenderTier();
|
||||
ctx.save();
|
||||
ctx.rotate(angle);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.globalAlpha *= enabled ? 1 : 0.48;
|
||||
if (!enabled) ctx.setLineDash([10, 7]);
|
||||
ctx.strokeStyle = styleNight ? "#4d8e4e" : "#5cac4d";
|
||||
ctx.lineWidth = thick;
|
||||
ctx.shadowColor = enabled ? (styleNight ? "rgba(92,190,86,0.34)" : "rgba(62,170,54,0.28)") : "rgba(70,120,70,0.16)";
|
||||
ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.18) : Math.max(4, thick * 0.32));
|
||||
strokeSegments(ctx, this, segments, "body");
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.setLineDash([]);
|
||||
if (tier !== "low") {
|
||||
ctx.strokeStyle = enabled ? (styleNight ? "rgba(226,255,218,0.46)" : "rgba(255,255,255,0.50)") : "rgba(245,255,236,0.28)";
|
||||
ctx.lineWidth = Math.max(1.4, thick * 0.22);
|
||||
strokeSegments(ctx, this, segments, "body");
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
ctx.fillStyle = enabled ? (styleNight ? "#263b27" : "#efffe9") : (styleNight ? "#303934" : "#eef4e8");
|
||||
ctx.strokeStyle = enabled ? (styleNight ? "#80c56e" : "#3e8f35") : "rgba(78,118,74,0.72)";
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, Math.max(8, thick * 0.74), 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = enabled ? (styleNight ? "#d8ffd0" : "#26711f") : "rgba(70,92,68,0.70)";
|
||||
ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(enabled ? "毒" : "透", 0, 0);
|
||||
// v39.15.53: 毒ブロックは物理輪郭だけを安定描画する。
|
||||
// 中央の塗りつぶし/文字はキャッシュ更新時に明滅して見えるため描画しない。
|
||||
} else if (this.type === "reciprocator") {
|
||||
const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
|
||||
const bodyAngle = itemAngleFor(this);
|
||||
const pb = window.TarinaiPhysicsBodySystem;
|
||||
const axisAngle = window.TarinaiMechanicalSystem?.railAxisAngle?.(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle;
|
||||
const axisAngle = window.TarinaiMechanicalSystem.railAxisAngle(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle;
|
||||
const powered = pb?.scalar?.(this, "railOn", true) !== false;
|
||||
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
|
||||
const tier = mechanicalRenderTier();
|
||||
|
|
@ -750,10 +930,10 @@ draw(ctx, t, lighting = null) {
|
|||
if (powered || Math.abs(visualVelocity) > 0.8) {
|
||||
ctx.save();
|
||||
ctx.rotate(axisAngle);
|
||||
ctx.fillText(visualDirection >= 0 ? "→" : "←", 0, 0);
|
||||
ctx.fillText(visualDirection >= 0 ? "\u2192" : "\u2190", 0, 0);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.fillText("○", 0, 0);
|
||||
ctx.fillText("\u25cb", 0, 0);
|
||||
}
|
||||
} else if (this.type === "rope" || this.type === "rod") {
|
||||
const runtime = window.TarinaiLinkRuntime;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
// through the explicit lifecycle pipeline.
|
||||
(function (global) {
|
||||
function installPrototypeBoundary() {
|
||||
const proto = global.Item?.prototype;
|
||||
if (!proto || proto._tarinaiItemRuntimeInstalled) return false;
|
||||
const proto = global.Item.prototype;
|
||||
if (proto._tarinaiItemRuntimeInstalled) return false;
|
||||
proto.update = function update(dt, worldRef = global.world) {
|
||||
return global.TarinaiItemRuntime.updateOne(this, dt, worldRef);
|
||||
};
|
||||
|
|
@ -19,36 +19,19 @@
|
|||
}
|
||||
|
||||
function updateOne(item, dt, worldRef = global.world) {
|
||||
if (!item || item.dead || !global.TarinaiItemLifecyclePipeline?.updateOne) return false;
|
||||
if (!item || item.dead) return false;
|
||||
return global.TarinaiItemLifecyclePipeline.updateOne(item, dt, worldRef) !== false;
|
||||
}
|
||||
|
||||
function updateScheduled(worldRef, dt) {
|
||||
if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt);
|
||||
const helpers = global.TarinaiSimulationRuntime?.helpers;
|
||||
if (!helpers?.forScheduledItems || !helpers?.itemUpdateInterval) return 0;
|
||||
let ran = 0;
|
||||
helpers.forScheduledItems(worldRef, (item) => {
|
||||
const interval = helpers.itemUpdateInterval(item);
|
||||
if (!Number.isFinite(interval)) return;
|
||||
if (interval <= 0) {
|
||||
if (updateOne(item, dt, worldRef)) ran += 1;
|
||||
return;
|
||||
}
|
||||
item._scheduledUpdateDt = Math.min(10, (item._scheduledUpdateDt || 0) + dt);
|
||||
if (item._scheduledUpdateDt < interval) return;
|
||||
const scheduledDt = item._scheduledUpdateDt;
|
||||
item._scheduledUpdateDt = 0;
|
||||
if (updateOne(item, scheduledDt, worldRef)) ran += 1;
|
||||
});
|
||||
return ran;
|
||||
return global.TarinaiItemUpdateScheduler.run(worldRef, dt);
|
||||
}
|
||||
|
||||
function trackedDynamicItems(worldRef) {
|
||||
return global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef) || worldRef?._itemUpdateRealtime || [];
|
||||
return global.TarinaiItemUpdateScheduler.trackedDynamicItems(worldRef);
|
||||
}
|
||||
|
||||
const api = Object.freeze({ installPrototypeBoundary, updateOne, updateScheduled, trackedDynamicItems });
|
||||
const api = Object.freeze({ updateOne, updateScheduled, trackedDynamicItems });
|
||||
global.TarinaiItemRuntime = api;
|
||||
installPrototypeBoundary();
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
214
js/item_tool_definitions.js
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"use strict";
|
||||
|
||||
// Tool definitions, item traits, categories, and basic type helpers.
|
||||
(function (global) {
|
||||
const TOOL_DEFINITIONS = Object.freeze({
|
||||
observe: { id: "observe", label: "\u305f\u308a\u306a\u3044\u30c7\u30fc\u30bf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" },
|
||||
clear_tool: { id: "clear_tool", label: "\u89e3\u9664", placeable: false, scalable: false, iconText: "\u2715", tooltip: "\u9078\u629e\u4e2d\u306e\u9053\u5177\u30dc\u30bf\u30f3\u3092\u89e3\u9664\u3057\u3001\u901a\u5e38\u306e\u89b3\u5bdf\u72b6\u614b\u306b\u623b\u3059\u3002" },
|
||||
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
|
||||
area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" },
|
||||
copy: { id: "copy", label: "\u30b3\u30d4\u30fc", placeable: false, scalable: false, iconText: "\u29c9", tooltip: "\u7f6e\u3044\u305f\u30a2\u30a4\u30c6\u30e0\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u30b3\u30d4\u30fc\u3057\u3001\u6b21\u306e\u30af\u30ea\u30c3\u30af\u3067\u8a2d\u5b9a\u3054\u3068\u8cbc\u308a\u4ed8\u3051\u308b\u3002" },
|
||||
undo: { id: "undo", label: "\u623b\u3059", placeable: false, scalable: false, iconText: "\u21b6", tooltip: "\u76f4\u524d\u306e\u64cd\u4f5c\u3092\u53d6\u308a\u6d88\u3059\u3002" },
|
||||
redo: { id: "redo", label: "\u9032\u3080", placeable: false, scalable: false, iconText: "\u21b7", tooltip: "\u53d6\u308a\u6d88\u3057\u305f\u64cd\u4f5c\u3092\u3084\u308a\u76f4\u3059\u3002" },
|
||||
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
|
||||
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u4e00\u90e8\u306e\u7f6e\u304d\u3082\u306e\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002" },
|
||||
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
|
||||
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
|
||||
rope: { id: "rope", label: "\u30d2\u30e2", placeable: false, scalable: false, iconText: "\u2570", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3075\u306b\u3083\u3075\u306b\u3083\u3057\u305f\u30d2\u30e2\u3067\u7e4b\u3050\u3002" },
|
||||
rod: { id: "rod", label: "\u68d2", placeable: false, scalable: false, iconText: "\u2501", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001\u66f2\u304c\u3089\u306a\u3044\u68d2\u3067\u63a5\u7d9a\u3059\u308b\u3002" },
|
||||
shoot: { id: "shoot", label: "\u5c04\u6483", placeable: false, scalable: false, icon: "assets/ui/tool_shoot.png", tooltip: "\u9283\u3067\u653b\u6483\u3059\u308b\u3002" },
|
||||
robot_cleaner: { id: "robot_cleaner", itemType: "robot_cleaner", label: "\u30ed\u30dc\u6383\u9664\u6a5f", placeable: true, scalable: false, radius: 22, amount: 999, iconText: "\u25c9", tooltip: "\u30b4\u30df\u3084\u6c5a\u308c\u3092\u7247\u4ed8\u3051\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
|
||||
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
|
||||
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
|
||||
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
|
||||
fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
|
||||
sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" },
|
||||
|
||||
laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" },
|
||||
protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" },
|
||||
niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" },
|
||||
ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" },
|
||||
mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" },
|
||||
mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" },
|
||||
giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" },
|
||||
dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" },
|
||||
zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" },
|
||||
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" },
|
||||
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" },
|
||||
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" },
|
||||
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
|
||||
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
|
||||
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
glass_wall: { id: "glass_wall", itemType: "glass_wall", label: "\u30ac\u30e9\u30b9\u58c1", placeable: true, scalable: true, radius: 42, amount: 999, iconText: "\u25ad", collisionShape: "oriented_rect", tooltip: "\u900f\u660e\u306a\u58c1\u3002\u305f\u308a\u306a\u3044\u306f\u901a\u308c\u306a\u3044\u304c\u3001\u898b\u3048\u3066\u3044\u308b\u5bfe\u8c61\u3078\u306f\u8fc2\u56de\u305b\u305a\u306b\u5411\u304b\u3046\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
rotator: { id: "rotator", itemType: "rotator", label: "\u56de\u8ee2\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21bb", collisionShape: "custom_rotator", tooltip: "\u56de\u8ee2\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
|
||||
poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" },
|
||||
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
|
||||
water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" },
|
||||
trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
|
||||
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
|
||||
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
|
||||
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
|
||||
});
|
||||
|
||||
|
||||
const ITEM_TRAITS = Object.freeze({
|
||||
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "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 = "") {
|
||||
const t = String(type || "");
|
||||
if (t === "fence_v" || t === "bounce_fence_v") return Math.PI / 2;
|
||||
return 0;
|
||||
}
|
||||
function normalizedItemAngle(angle = 0, fallback = 0) {
|
||||
const twoPi = Math.PI * 2;
|
||||
const n = Number(angle);
|
||||
const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0);
|
||||
return ((base % twoPi) + twoPi) % twoPi;
|
||||
}
|
||||
function itemAngleFor(itemOrType = "") {
|
||||
if (itemOrType && typeof itemOrType === "object") return normalizedItemAngle(itemOrType.angle, defaultItemAngle(itemOrType.type));
|
||||
return defaultItemAngle(String(itemOrType || ""));
|
||||
}
|
||||
|
||||
function itemHasTrait(type = "", trait = "") {
|
||||
return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || "")));
|
||||
}
|
||||
|
||||
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
|
||||
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
|
||||
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
|
||||
function lodgedPinFor(tarinai = null, worldRef = null) {
|
||||
if (!tarinai?.stuckPushpinId) return null;
|
||||
const items = worldRef?.items || tarinai.world?.items || [];
|
||||
return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null;
|
||||
}
|
||||
function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); }
|
||||
function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); }
|
||||
|
||||
const PIN_BEHAVIORS = Object.freeze({
|
||||
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
|
||||
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
|
||||
});
|
||||
|
||||
function pinBehaviorFor(type = "") {
|
||||
return PIN_BEHAVIORS[String(type || "")] || null;
|
||||
}
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function toolDefinition(id = "") {
|
||||
return TOOL_DEFINITIONS[id] || null;
|
||||
}
|
||||
|
||||
function toolLabel(id = "") {
|
||||
const def = toolDefinition(id);
|
||||
return def?.label || id || "";
|
||||
}
|
||||
|
||||
function toolItemType(id = "") {
|
||||
const def = toolDefinition(id);
|
||||
return def?.placeable ? (def.itemType || def.id) : null;
|
||||
}
|
||||
|
||||
function itemDefinition(type = "") {
|
||||
return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null;
|
||||
}
|
||||
|
||||
function itemRadiusFor(type = "", fallback = 12) {
|
||||
return itemDefinition(type)?.radius ?? fallback;
|
||||
}
|
||||
|
||||
function itemAmountFor(type = "", fallback = 80) {
|
||||
return itemDefinition(type)?.amount ?? fallback;
|
||||
}
|
||||
|
||||
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
|
||||
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" });
|
||||
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
|
||||
function normalizedToolSizeFor(worldRef = null, type = "") {
|
||||
const def = itemDefinition(type);
|
||||
if (!def?.scalable) return "medium";
|
||||
const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium";
|
||||
return TOOL_SIZE_ORDER.includes(value) ? value : "medium";
|
||||
}
|
||||
function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; }
|
||||
function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; }
|
||||
|
||||
function scalableToolIds() {
|
||||
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
|
||||
}
|
||||
|
||||
function toolTipsFromDefinitions() {
|
||||
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
|
||||
}
|
||||
|
||||
|
||||
Object.assign(global, {
|
||||
isFenceItemType,
|
||||
isRotatableItemType,
|
||||
defaultItemAngle,
|
||||
normalizedItemAngle,
|
||||
itemAngleFor,
|
||||
itemHasTrait,
|
||||
isPinType,
|
||||
isSleepFurnitureType,
|
||||
lodgedPinFor,
|
||||
hasLodgedPinEffect,
|
||||
pinBehaviorFor,
|
||||
toolCategories,
|
||||
toolDefinition,
|
||||
toolLabel,
|
||||
toolItemType,
|
||||
itemDefinition,
|
||||
itemRadiusFor,
|
||||
itemAmountFor,
|
||||
TOOL_SIZE_ORDER,
|
||||
TOOL_SIZE_LABELS,
|
||||
normalizedToolSizeFor,
|
||||
toolSizeScaleFor,
|
||||
scalableToolIds,
|
||||
toolTipsFromDefinitions,
|
||||
});
|
||||
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -4,13 +4,11 @@
|
|||
|
||||
function initializeItemTypeState(item, type, x, y) {
|
||||
if (!item) return item;
|
||||
const bodySchemaVersion = globalThis.TarinaiPhysicsBodySystem?.BODY_SCHEMA_VERSION || 4;
|
||||
const constraintSchemaVersion = globalThis.TarinaiPhysicsBodySystem?.CONSTRAINT_SCHEMA_VERSION || 4;
|
||||
if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true;
|
||||
if (type === "water" || type === "water_bowl" || type === "zunda_juice") item.roles.drink = true;
|
||||
if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = 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") item.roles.sleepPlace = true;
|
||||
if (["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "splat"].includes(type)) item.roles.danger = true;
|
||||
if (["firecracker", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true;
|
||||
if (type === "grass") item.roles.grassMaterial = true;
|
||||
if (type === "grass") {
|
||||
item.grassStage = Math.floor(rand(0, 2.999));
|
||||
|
|
@ -21,6 +19,9 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
item.lifeSpan = rand(420, 860);
|
||||
item.wither = 0;
|
||||
}
|
||||
if (type === "slave_chain" || type === "golden_crown") {
|
||||
item.amount = Math.max(1, Number(item.amount || 0) || 1);
|
||||
}
|
||||
if (type === "bed") {
|
||||
item.comfort = rand(0.86, 1.14);
|
||||
item.wear = 0;
|
||||
|
|
@ -37,6 +38,20 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
item.needEffects.food = 55;
|
||||
item.amount = 999;
|
||||
}
|
||||
if (type === "robot_cleaner") {
|
||||
item.amount = 999;
|
||||
item.robotCleanerMask = globalThis.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3;
|
||||
item.robotCleanerTargetId = "";
|
||||
item.robotCleanerTargetType = "";
|
||||
item.robotCleanerScanTimer = 0;
|
||||
item.robotCleanerCleanFlash = 0;
|
||||
item.robotCleanerCleanCount = 0;
|
||||
item.robotCleanerAngle = rand(0, Math.PI * 2);
|
||||
item.robotCleanerWanderAngle = item.robotCleanerAngle;
|
||||
item.robotCleanerWanderTimer = rand(0.2, 1.4);
|
||||
item.robotCleanerHighSpeed = false;
|
||||
item.robotCleanerSpeed = globalThis.TarinaiItemDynamicToolSystem?.robotCleanerMoveSpeed ?? 60;
|
||||
}
|
||||
if (type === "ball") {
|
||||
item.vx = rand(-5, 5);
|
||||
item.vy = rand(-5, 5);
|
||||
|
|
@ -62,7 +77,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
if (type === "ant_nest") {
|
||||
item.antCount = ANT_NEST_START_COUNT || 6;
|
||||
item.antSpawnTimer = rand(0.6, 2.2);
|
||||
item.antSpawnCooldown = rand(0.8, 2.4);
|
||||
item.antWorkers = typeof buildAntWorkerPool === "function"
|
||||
? buildAntWorkerPool(item.antCount)
|
||||
: Array.from({ length: item.antCount }, () => ANT_WORKER_HP || 32);
|
||||
|
|
@ -104,8 +118,8 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
item.deletable = true;
|
||||
}
|
||||
|
||||
if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) {
|
||||
item.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0;
|
||||
if (isRotatableItemType(type)) {
|
||||
item.angle = defaultItemAngle(type);
|
||||
item.amount = Math.max(item.amount || 0, 999);
|
||||
}
|
||||
if (type === "gate_fence") {
|
||||
|
|
@ -115,7 +129,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
|
||||
if (type === "poison_block") {
|
||||
item.physicsBody = {
|
||||
schema: bodySchemaVersion,
|
||||
type: "poison_block",
|
||||
kind: "passive",
|
||||
pose: { x, y, angle: Number(item.angle) || 0 },
|
||||
|
|
@ -126,8 +139,8 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
collision: { solid: true, hazard: true },
|
||||
hazard: { kind: "poison", damage: 7 },
|
||||
sleep: { awakeUntil: 0 },
|
||||
mass: 4.8,
|
||||
inertia: 36000,
|
||||
mass: 0.45,
|
||||
inertia: 3200,
|
||||
};
|
||||
item.prevX = x;
|
||||
item.prevY = y;
|
||||
|
|
@ -139,7 +152,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
|
||||
if (type === "rotator") {
|
||||
item.physicsBody = {
|
||||
schema: bodySchemaVersion,
|
||||
type: "rotator",
|
||||
kind: "rotational",
|
||||
pose: { x, y, angle: Number(item.angle) || 0 },
|
||||
|
|
@ -159,7 +171,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
item.r = 48;
|
||||
item.deletable = true;
|
||||
item.physicsConstraint = {
|
||||
schema: constraintSchemaVersion,
|
||||
type,
|
||||
kind: type === "rope" ? "flexible-distance" : "rigid-distance",
|
||||
endpoints: [null, null],
|
||||
|
|
@ -171,7 +182,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
|
||||
if (type === "reciprocator") {
|
||||
item.physicsBody = {
|
||||
schema: bodySchemaVersion,
|
||||
type: "reciprocator",
|
||||
kind: "linear",
|
||||
pose: { x, y, angle: Number(item.angle) || 0 },
|
||||
|
|
@ -186,9 +196,9 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
item.amount = 999;
|
||||
}
|
||||
|
||||
if (globalThis.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) {
|
||||
globalThis.TarinaiPhysicsBodySystem.purgeLegacyPhysicsStorage?.(item);
|
||||
globalThis.TarinaiPhysicsBodySystem.invalidateItem?.(item, "item-initialized");
|
||||
if (globalThis.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
|
||||
globalThis.TarinaiPhysicsBodySystem.purgeLegacyPhysicsStorage(item);
|
||||
globalThis.TarinaiPhysicsBodySystem.invalidateItem(item, "item-initialized");
|
||||
}
|
||||
|
||||
if (isServingFoodType(type)) {
|
||||
|
|
|
|||
|
|
@ -5,17 +5,22 @@
|
|||
// helpers and the item scheduler both read this single policy instead of
|
||||
// duplicating item-type timing rules.
|
||||
(function (global) {
|
||||
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]);
|
||||
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "robot_cleaner"]);
|
||||
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
|
||||
const SCHEDULED_ITEM_TYPES = Object.freeze([
|
||||
"ball", "genkotsu", "firecracker", "pushpin", "oshibyo",
|
||||
"plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
|
||||
"plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
|
||||
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
|
||||
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", "water_bowl",
|
||||
"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() {
|
||||
return Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
|
||||
}
|
||||
|
||||
function itemUpdateInterval(item) {
|
||||
if (!item || item.dead) return Infinity;
|
||||
const mobile = mobilePerfHint();
|
||||
// Mechanical bodies and links are owned by the central physics step.
|
||||
// Keeping them out of the per-item scheduler prevents N independent
|
||||
// collision passes and makes future physics-format changes cheaper.
|
||||
|
|
@ -27,12 +32,12 @@
|
|||
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
|
||||
if (item.type === "zunchi") return 2.4;
|
||||
if (item.type === "grass") return Infinity;
|
||||
if (item.type === "duplicator") return 0.5;
|
||||
if (item.type === "water") return 1.8;
|
||||
if (item.type === "trace" || item.type === "splat") return 1.2;
|
||||
if (item.type === "duplicator") return mobile ? 0.8 : 0.5;
|
||||
if (item.type === "water") return mobile ? 2.4 : 1.8;
|
||||
if (item.type === "trace" || item.type === "splat") return mobile ? 1.8 : 1.2;
|
||||
if (item.type === "ant_corpse") return 3.5;
|
||||
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return 5.0;
|
||||
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0;
|
||||
if (isServingFoodType(item.type)) return 5.0;
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
|
|
@ -69,18 +74,11 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function isRealtime(item) {
|
||||
return itemUpdateInterval(item) <= 0;
|
||||
}
|
||||
|
||||
global.TarinaiItemUpdatePolicy = Object.freeze({
|
||||
REALTIME_ITEM_TYPES,
|
||||
PIN_ITEM_TYPES,
|
||||
SCHEDULED_ITEM_TYPES,
|
||||
itemUpdateInterval,
|
||||
forScheduledItems,
|
||||
ensureBuckets,
|
||||
candidateItems,
|
||||
isRealtime,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,19 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const policy = () => global.TarinaiItemUpdatePolicy || {};
|
||||
const policy = global.TarinaiItemUpdatePolicy;
|
||||
|
||||
function itemUpdateInterval(item) {
|
||||
const interval = policy().itemUpdateInterval?.(item);
|
||||
const interval = policy.itemUpdateInterval(item);
|
||||
return Number.isFinite(interval) ? interval : Infinity;
|
||||
}
|
||||
|
||||
function candidateItems(worldRef) {
|
||||
if (policy().candidateItems) return policy().candidateItems(worldRef);
|
||||
return (worldRef?.items || []).filter(it => it && !it.dead && it.type !== "grass");
|
||||
return policy.candidateItems(worldRef);
|
||||
}
|
||||
|
||||
function ensureBuckets(worldRef) {
|
||||
if (policy().ensureBuckets) return policy().ensureBuckets(worldRef);
|
||||
if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-scheduler");
|
||||
return undefined;
|
||||
return policy.ensureBuckets(worldRef);
|
||||
}
|
||||
|
||||
function heapPush(heap, entry) {
|
||||
|
|
@ -111,8 +108,7 @@
|
|||
continue;
|
||||
}
|
||||
if (interval <= 0) {
|
||||
if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(item, dt, worldRef);
|
||||
else item.update(dt, worldRef);
|
||||
global.TarinaiItemRuntime.updateOne(item, dt, worldRef);
|
||||
item._schedulerLastAt = now;
|
||||
ran += 1;
|
||||
if (Number.isFinite(itemUpdateInterval(item)) && itemUpdateInterval(item) <= 0) nextRealtime.push(item);
|
||||
|
|
@ -143,8 +139,7 @@
|
|||
continue;
|
||||
}
|
||||
const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval)));
|
||||
if (global.TarinaiItemRuntime?.updateOne) global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef);
|
||||
else item.update(elapsed, worldRef);
|
||||
global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef);
|
||||
item._schedulerLastAt = now;
|
||||
ran += 1;
|
||||
dueRuns += 1;
|
||||
|
|
@ -154,9 +149,12 @@
|
|||
}
|
||||
|
||||
function physicsBudgetFor(worldRef) {
|
||||
const tier = global.TarinaiPerf?.renderQualityTier?.() || "high";
|
||||
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;
|
||||
|
|
@ -170,25 +168,24 @@
|
|||
}
|
||||
|
||||
function run(worldRef, dt) {
|
||||
const end = global.TarinaiPerf?.begin?.("update.items") || null;
|
||||
const end = global.TarinaiPerf.begin("update.items");
|
||||
try {
|
||||
const state = ensure(worldRef);
|
||||
const realtimeEnd = global.TarinaiPerf?.begin?.("update.items.realtime") || null;
|
||||
const realtimeEnd = global.TarinaiPerf.begin("update.items.realtime");
|
||||
let realtimeRan = runRealtime(worldRef, state, dt);
|
||||
let ran = realtimeRan;
|
||||
if (realtimeEnd) realtimeEnd();
|
||||
const dueEnd = global.TarinaiPerf?.begin?.("update.items.scheduled") || null;
|
||||
const dueEnd = global.TarinaiPerf.begin("update.items.scheduled");
|
||||
const dueRan = runDue(worldRef, state);
|
||||
ran += dueRan;
|
||||
if (dueEnd) dueEnd();
|
||||
const physicsEnd = global.TarinaiPerf?.begin?.("update.physicsWorld") || null;
|
||||
const physicsEnd = global.TarinaiPerf.begin("update.physicsWorld");
|
||||
const physicsBudget = physicsBudgetFor(worldRef);
|
||||
const physicsWorld = global.TarinaiPhysicsWorldSystem?.updateWorld
|
||||
? global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget)
|
||||
: null;
|
||||
const mechanicalWorld = physicsWorld?.mechanical || (global.TarinaiMechanicalSystem?.updateWorld?.(worldRef, dt, { maxPairs: physicsBudget.maxPairs, maxLinks: 0, skipLinks: true, maxSubsteps: physicsBudget.maxSubsteps }) || null);
|
||||
const constraintWorld = physicsWorld ? physicsWorld.constraintRan : (global.TarinaiConstraintSystem?.updateWorld?.(worldRef, dt, { maxLinks: Math.min(180, physicsBudget.maxLinks) }) || 0);
|
||||
const postConstraintPairs = physicsWorld ? physicsWorld.postConstraintPairs : (constraintWorld ? (global.TarinaiMechanicalSystem?.resolveMechanicalPairs?.(worldRef, dt, { maxPairs: Math.min(80, physicsBudget.postMaxPairs) }) || 0) : 0);
|
||||
const physicsWorld = global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget);
|
||||
const mechanicalWorld = physicsWorld.mechanical || null;
|
||||
const constraintWorld = physicsWorld.constraintRan || 0;
|
||||
const postConstraintPairs = physicsWorld.postConstraintPairs || 0;
|
||||
const poisonContactRemoved = worldRef.resolvePoisonBlockItemContacts?.({ maxPoison: 24, maxTargets: 80 }) || 0;
|
||||
if (physicsEnd) physicsEnd();
|
||||
state.signature = schedulerSignature(worldRef);
|
||||
worldRef._itemUpdateSchedulerStats = {
|
||||
|
|
@ -208,7 +205,7 @@
|
|||
mechanicalStats: worldRef._mechanicalWorldStats || null,
|
||||
constraintStats: worldRef._constraintWorldStats || null,
|
||||
};
|
||||
return ran + (physicsWorld?.ran || mechanicalWorld?.ran || 0) + constraintWorld;
|
||||
return ran + (physicsWorld.ran || mechanicalWorld?.ran || 0) + constraintWorld + poisonContactRemoved;
|
||||
} finally {
|
||||
if (end) end();
|
||||
}
|
||||
|
|
@ -218,5 +215,5 @@
|
|||
return worldRef?._itemUpdateRealtime || [];
|
||||
}
|
||||
|
||||
global.TarinaiItemUpdateScheduler = Object.freeze({ run, rebuild, ensure, itemUpdateInterval, trackedDynamicItems });
|
||||
global.TarinaiItemUpdateScheduler = Object.freeze({ run, trackedDynamicItems });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
123
js/item_visual_definitions.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use strict";
|
||||
|
||||
// Item visual metadata and palettes.
|
||||
(function (global) {
|
||||
const raw = String.raw`{
|
||||
"water": {
|
||||
"renderer": "oval_droplet",
|
||||
"shape": "horizontal_oval_liquid",
|
||||
"palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"]
|
||||
},
|
||||
"zunda_juice": {
|
||||
"renderer": "oval_droplet",
|
||||
"shape": "horizontal_oval_liquid",
|
||||
"palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"]
|
||||
},
|
||||
"mercury": {
|
||||
"renderer": "oval_droplet",
|
||||
"shape": "horizontal_oval_liquid",
|
||||
"palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"]
|
||||
},
|
||||
"sleep_drug": {
|
||||
"renderer": "sleep_tablet",
|
||||
"shape": "moon_tablet",
|
||||
"palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "moon", "purple"]
|
||||
},
|
||||
"laxative": {
|
||||
"renderer": "laxative_tablet",
|
||||
"shape": "rounded_tablet_zunchi_motif",
|
||||
"palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "zunchi motif", "tan"]
|
||||
},
|
||||
"mystery_drug": {
|
||||
"renderer": "mystery_tablet",
|
||||
"shape": "question_mark_tablet",
|
||||
"palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "question mark", "purple"]
|
||||
},
|
||||
"ammo": {
|
||||
"renderer": "bullet",
|
||||
"shape": "bullet",
|
||||
"palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["bullet", "gold"]
|
||||
},
|
||||
"protein": {
|
||||
"renderer": "powder_pile",
|
||||
"shape": "powder_pile",
|
||||
"palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["powder", "orange"]
|
||||
},
|
||||
"niteropu": {
|
||||
"renderer": "powder_pile",
|
||||
"shape": "powder_pile",
|
||||
"palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["powder", "purple"]
|
||||
},
|
||||
"giant_drug": {
|
||||
"renderer": "split_pill",
|
||||
"shape": "split_pill",
|
||||
"palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" },
|
||||
"scale": 1.08,
|
||||
"uiPreviewScale": 1.08,
|
||||
"fieldScale": 1.08,
|
||||
"accessibleShapeTraits": ["pill", "large", "orange"]
|
||||
},
|
||||
"dwarf_drug": {
|
||||
"renderer": "split_pill",
|
||||
"shape": "split_pill",
|
||||
"palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" },
|
||||
"scale": 0.92,
|
||||
"uiPreviewScale": 0.92,
|
||||
"fieldScale": 0.92,
|
||||
"accessibleShapeTraits": ["pill", "small", "purple"]
|
||||
},
|
||||
"ball": {
|
||||
"renderer": "emoji",
|
||||
"shape": "soccer_ball",
|
||||
"palette": {},
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["round", "soccer ball"],
|
||||
"emojiFallback": "\u26bd"
|
||||
}
|
||||
}`;
|
||||
|
||||
const defs = Object.freeze(JSON.parse(raw));
|
||||
|
||||
function itemVisualDefinition(type = "") {
|
||||
return defs[String(type || "")] || null;
|
||||
}
|
||||
global.itemVisualDefinition = itemVisualDefinition;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
42
js/items.js
|
|
@ -1,8 +1,8 @@
|
|||
// Item construction and shared item helpers. Type-specific initialization is in item_type_initializers.js.
|
||||
"use strict";
|
||||
|
||||
const FOOD_SERVING_TYPES = new Set(window.TarinaiItemRegistry?.food?.servingTypes?.() || ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]);
|
||||
const PARAM_EFFECT_ITEM_TYPES = new Set(window.TarinaiItemRegistry?.food?.paramEffectTypes?.() || ["laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]);
|
||||
const FOOD_SERVING_TYPES = new Set(window.TarinaiItemRegistry.food?.servingTypes?.() || ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]);
|
||||
const PARAM_EFFECT_ITEM_TYPES = new Set(window.TarinaiItemRegistry.food?.paramEffectTypes?.() || ["laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]);
|
||||
function isParamEffectItemType(type = "") {
|
||||
return PARAM_EFFECT_ITEM_TYPES.has(type);
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ function isServingFoodType(type = "") {
|
|||
return FOOD_SERVING_TYPES.has(type);
|
||||
}
|
||||
const PASSIVE_FOOD_DECAY_PER_SECOND = 0.004;
|
||||
const PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING = 0.055;
|
||||
|
||||
const GRASS_STAGE_COUNT = 5;
|
||||
const GRASS_STAGE_AMOUNTS = [24, 48, 72, 96, 120];
|
||||
|
|
@ -96,12 +95,12 @@ function passiveFoodDecayInterval(worldRef = null) {
|
|||
function passiveFoodDecayRate(item) {
|
||||
const size = item?.toolSize || "medium";
|
||||
const sizeMul = size === "small" ? 0.74 : (size === "large" ? 1.32 : 1);
|
||||
const typeMul = window.TarinaiItemRegistry?.food?.passiveDecayMultiplier?.(item?.type) ?? (item?.type === "sweet" ? 0.82 : (item?.type === "zunda_juice" ? 1.12 : 1));
|
||||
const typeMul = window.TarinaiItemRegistry.food?.passiveDecayMultiplier?.(item?.type) ?? (item?.type === "sweet" ? 0.82 : (item?.type === "zunda_juice" ? 1.12 : 1));
|
||||
return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul;
|
||||
}
|
||||
|
||||
function itemVisualFor(type = "") {
|
||||
return typeof itemVisualDefinition === "function" ? itemVisualDefinition(type) : null;
|
||||
return itemVisualDefinition(type);
|
||||
}
|
||||
|
||||
function visualPaletteFor(type = "", fallback = {}) {
|
||||
|
|
@ -250,7 +249,7 @@ function drawBulletSprite(ctx, r) {
|
|||
function isConsumableSpriteType(type = "") {
|
||||
const key = String(type || "");
|
||||
return ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(key)
|
||||
|| (typeof isParamEffectItemType === "function" && isParamEffectItemType(key));
|
||||
|| isParamEffectItemType(key);
|
||||
}
|
||||
|
||||
function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {}) {
|
||||
|
|
@ -321,7 +320,7 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {})
|
|||
}
|
||||
} else if (key === "sleep_drug") {
|
||||
drawSleepTabletSprite(ctx, localR * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug"));
|
||||
} else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(key)) {
|
||||
} else if (isParamEffectItemType(key)) {
|
||||
const visual = itemVisualFor(key);
|
||||
const palette = visual?.palette || {};
|
||||
const renderer = visual?.renderer || "split_pill";
|
||||
|
|
@ -390,14 +389,14 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {})
|
|||
|
||||
function drawStoredConsumableSprite(ctx, type = "", r = 10, seed = 1) {
|
||||
const key = String(type || "food");
|
||||
if (typeof isPinType === "function" && isPinType(key)) {
|
||||
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(key) : null;
|
||||
if (isPinType(key)) {
|
||||
const behavior = pinBehaviorFor(key);
|
||||
const assetId = behavior?.looseAsset || key;
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
|
||||
const img = getRenderableImage(assetId, assetId);
|
||||
ctx.save();
|
||||
ctx.rotate(-0.35);
|
||||
if (img) {
|
||||
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
|
||||
const metrics = getImageMetrics(assetId);
|
||||
const ratio = metrics?.ratio || 2.0;
|
||||
const w = key === "oshibyo" ? r * 1.30 : r * 1.70;
|
||||
const h = w * ratio;
|
||||
|
|
@ -422,10 +421,10 @@ function drawStoredConsumableSprite(ctx, type = "", r = 10, seed = 1) {
|
|||
}
|
||||
|
||||
function previewItemRadiusFor(type = "") {
|
||||
const def = typeof itemDefinition === "function" ? itemDefinition(type) : null;
|
||||
const def = itemDefinition(type);
|
||||
const base = Number(def?.radius || itemRadiusFor?.(type, 16) || 16);
|
||||
if (type === "genkotsu") return 30;
|
||||
if (type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") return 24;
|
||||
if (type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") return 24;
|
||||
if (type === "rotator") return 25;
|
||||
if (type === "reciprocator") return 26;
|
||||
if (type === "nest_box") return 26;
|
||||
|
|
@ -523,8 +522,8 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
|
|||
fuseTimer: 5,
|
||||
fuseMax: 5,
|
||||
impactFlash: 0,
|
||||
foodServingsMax: typeof foodServingsForSize === "function" ? foodServingsForSize("medium") : 5,
|
||||
foodServingsRemaining: typeof foodServingsForSize === "function" ? foodServingsForSize("medium") : 5,
|
||||
foodServingsMax: foodServingsForSize("medium"),
|
||||
foodServingsRemaining: foodServingsForSize("medium"),
|
||||
toolSize: "medium",
|
||||
});
|
||||
if (type === "water") item.amount = 50;
|
||||
|
|
@ -546,7 +545,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
|
|||
ctx = drawCtx;
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const baseOffsetY = type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence" ? 1 : 4;
|
||||
const baseOffsetY = type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence" ? 1 : 4;
|
||||
const centerX = watermark ? w * 0.78 : w / 2;
|
||||
const centerY = watermark ? (h * 0.80 + baseOffsetY * 0.15) : (h / 2 + baseOffsetY);
|
||||
ctx.translate(centerX, centerY);
|
||||
|
|
@ -558,6 +557,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
|
|||
duplicator: 0.82,
|
||||
fence_v: 0.70,
|
||||
fence_h: 0.70,
|
||||
glass_wall: 0.74,
|
||||
bounce_fence: 0.74,
|
||||
bounce_fence_v: 0.74,
|
||||
gate_fence: 0.74,
|
||||
|
|
@ -572,10 +572,10 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
|
|||
};
|
||||
let scale = scaleMap[type] || 0.92;
|
||||
if (isConsumableSpriteType(type)) scale *= 1.95;
|
||||
else if (["grass", "water", "water_bowl", "zunda_juice"].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", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "genkotsu"].includes(type)) scale *= 1.08;
|
||||
else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) scale *= 1.35;
|
||||
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);
|
||||
try {
|
||||
|
|
@ -598,14 +598,10 @@ if (typeof window !== "undefined") {
|
|||
window.drawStoredConsumableSprite = drawStoredConsumableSprite;
|
||||
window.drawToolItemPreview = drawToolItemPreview;
|
||||
window.TarinaiGrass = {
|
||||
stageCount: GRASS_STAGE_COUNT,
|
||||
normalize: normalizeGrassStage,
|
||||
setStage: setGrassStage,
|
||||
advance: advanceGrassStage,
|
||||
regress: regressGrassStage,
|
||||
amountForStage: grassAmountForStage,
|
||||
growthForStage: grassGrowthForStage,
|
||||
stageFromGrowth: grassStageFromGrowth,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
32
js/main.js
|
|
@ -9,7 +9,6 @@ function ensureWorldBootstrap() {
|
|||
if (typeof globalThis !== "undefined") globalThis.world = currentWorld;
|
||||
return currentWorld;
|
||||
} catch (error) {
|
||||
if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,19 +20,10 @@ let fpsTimer = 0;
|
|||
let fpsFrames = 0;
|
||||
window.__tarinaiFps = 0;
|
||||
|
||||
const LOADING_SPRITES = [
|
||||
"assets/sprites/tarinai_01_smile.webp",
|
||||
"assets/sprites/tarinai_02_angry.webp",
|
||||
"assets/sprites/tarinai_03_teary.webp",
|
||||
"assets/sprites/tarinai_04_jito.webp",
|
||||
"assets/sprites/tarinai_05_drool.webp",
|
||||
"assets/sprites/tarinai_06_cry.webp",
|
||||
"assets/sprites/tarinai_07_sleep.webp",
|
||||
"assets/sprites/tarinai_08_pokan.webp",
|
||||
"assets/sprites/tarinai_11_weak.webp",
|
||||
"assets/sprites/tarinai_21_normal_happy.webp",
|
||||
"assets/sprites/tarinai_29_hungry_70.webp",
|
||||
];
|
||||
const LOADING_SPRITES = SPRITES
|
||||
.filter(asset => asset && !asset.actionOnly)
|
||||
.slice(0, 11)
|
||||
.map(asset => asset.path);
|
||||
let loadingSpriteTimer = 0;
|
||||
|
||||
function shuffledLoadingSprites() {
|
||||
|
|
@ -94,7 +84,7 @@ function loop() {
|
|||
const rawDt = t - last;
|
||||
const dt = clamp(rawDt, 0, 0.05);
|
||||
last = t;
|
||||
window.TarinaiPerf?.beginFrame?.();
|
||||
window.TarinaiPerf.beginFrame();
|
||||
fpsTimer += rawDt;
|
||||
fpsFrames += 1;
|
||||
if (fpsTimer >= 0.5) {
|
||||
|
|
@ -102,14 +92,14 @@ function loop() {
|
|||
fpsTimer = 0;
|
||||
fpsFrames = 0;
|
||||
}
|
||||
const endUpdate = window.TarinaiPerf?.begin?.("update.total") || null;
|
||||
const endUpdate = window.TarinaiPerf.begin("update.total");
|
||||
world.update(dt);
|
||||
if (endUpdate) endUpdate();
|
||||
const endRender = window.TarinaiPerf?.begin?.("render.total") || null;
|
||||
const endRender = window.TarinaiPerf.begin("render.total");
|
||||
render();
|
||||
if (endRender) endRender();
|
||||
window.TarinaiPerf?.endFrame?.(rawDt, window.__tarinaiFps || 0);
|
||||
if (window.TarinaiPerf?.consumeResizeRequest?.() && typeof resizeCanvas === "function") resizeCanvas();
|
||||
window.TarinaiPerf.endFrame(rawDt, window.__tarinaiFps || 0);
|
||||
if (window.TarinaiPerf.consumeResizeRequest() && typeof resizeCanvas === "function") resizeCanvas();
|
||||
statsTimer += dt;
|
||||
const statsInterval = 2.5;
|
||||
if (statsTimer > statsInterval) {
|
||||
|
|
@ -121,7 +111,7 @@ function loop() {
|
|||
|
||||
initLoadingShowcase();
|
||||
setLoadingProgress("\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d", 0.08);
|
||||
loadImages(null, (done, total) => {
|
||||
loadImages((done, total) => {
|
||||
const p = total > 0 ? done / total : 1;
|
||||
setLoadingProgress(`\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d ${done}/${total}`, 0.08 + p * 0.46);
|
||||
}).then(() => {
|
||||
|
|
@ -138,7 +128,7 @@ loadImages(null, (done, total) => {
|
|||
render();
|
||||
renderStats();
|
||||
syncTopButtons();
|
||||
if (typeof startBackgroundImageLoading === "function") startBackgroundImageLoading();
|
||||
startBackgroundImageLoading();
|
||||
setLoadingProgress("\u30b7\u30df\u30e5\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u958b\u59cb\u4e2d", 0.94);
|
||||
loop();
|
||||
requestAnimationFrame(hideLoadingScreen);
|
||||
|
|
|
|||
|
|
@ -9,5 +9,3 @@ const distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
|||
const nowSec = () => performance.now() / 1000;
|
||||
const fmt = (v) => Math.round(v).toString();
|
||||
|
||||
|
||||
window.TarinaiMath = { rand, randi, pick, clamp, lerp, dist, distXY, nowSec, fmt };
|
||||
|
|
|
|||
24
js/mechanical_shape_bridge.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: physics/mechanical-shape-bridge
|
||||
// Single read-only facade for mechanical shape/footprint queries. It keeps
|
||||
// world, placement, and renderer code from binding directly to the body solver.
|
||||
(function (global) {
|
||||
function mech() { return global.TarinaiMechanicalSystem || null; }
|
||||
|
||||
function isMechanicalType(itemOrType) { return Boolean(mech()?.isMechanicalType?.(itemOrType)); }
|
||||
function worldSegments(item) { return mech()?.worldSegments?.(item) || []; }
|
||||
function obstacleRects(item) { return mech()?.obstacleRects?.(item) || []; }
|
||||
function boundsAabb(item) { return mech()?.boundsAabb?.(item) || null; }
|
||||
function extent(item) { return mech()?.extent?.(item) || 0; }
|
||||
function reach(item) { return mech()?.reach?.(item) || extent(item); }
|
||||
|
||||
global.TarinaiMechanicalShapeSystem = Object.freeze({
|
||||
isMechanicalType,
|
||||
worldSegments,
|
||||
obstacleRects,
|
||||
boundsAabb,
|
||||
extent,
|
||||
reach,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,14 +1,88 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: physics/mechanical-body
|
||||
// Common body, footprint, motion, and contact runtime for 回転体, 毒ブロック, and 往復体.
|
||||
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
|
||||
(function (global) {
|
||||
const FP = () => global.TarinaiCollisionFootprints || {};
|
||||
|
||||
function num(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function rectCorners(rect, padding = 0) {
|
||||
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) {
|
||||
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) {
|
||||
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 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"; }
|
||||
function motionType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" ? "rotate" : (t === "reciprocator" ? "reciprocate" : (t === "poison_block" ? "passive" : "none")); }
|
||||
|
|
@ -70,7 +144,7 @@
|
|||
|
||||
function shapeVersion(item) {
|
||||
const shape = bodyShape(item);
|
||||
return Number(shape?.version ?? item?._mechanicalShapeVersion ?? item?._segmentsVersion ?? 0) || 0;
|
||||
return Number(shape?.version ?? item?._mechanicalShapeVersion ?? 0) || 0;
|
||||
}
|
||||
|
||||
function invalidateGeometry(item) {
|
||||
|
|
@ -84,7 +158,6 @@
|
|||
}
|
||||
item._mechanicalShapeVersion = nextVersion;
|
||||
item._mechanicalGeomCache = null;
|
||||
item._mechanicalSupportVersion = nextVersion;
|
||||
item.world?.markSpatialDirty?.("mechanical-geometry-edited");
|
||||
item.world?.markItemBucketsDirty?.("mechanical-geometry-edited");
|
||||
return true;
|
||||
|
|
@ -219,8 +292,6 @@
|
|||
if (cache) {
|
||||
cache.localKey = key;
|
||||
cache.localSegments = out;
|
||||
cache.displaySegmentCount = normalized.length;
|
||||
cache.physicsSegmentCount = out.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -298,16 +369,6 @@
|
|||
return { x: num(rail?.anchorX, ps(item, "railAnchorX", item.x)) + a.x * travel * phase, y: num(rail?.anchorY, ps(item, "railAnchorY", item.y)) + a.y * travel * phase };
|
||||
}
|
||||
|
||||
function phaseFromPosition(item) {
|
||||
resetAnchor(item);
|
||||
const a = axis(item);
|
||||
const travel = halfTravel(item);
|
||||
const pose = bodyPose(item);
|
||||
const rail = bodyRail(item);
|
||||
const dx = num(pose?.x, item.x) - num(rail?.anchorX, ps(item, "railAnchorX", item.x));
|
||||
const dy = num(pose?.y, item.y) - num(rail?.anchorY, ps(item, "railAnchorY", item.y));
|
||||
return Math.max(-1, Math.min(1, (dx * a.x + dy * a.y) / Math.max(10, travel)));
|
||||
}
|
||||
|
||||
function signedDrive(item) {
|
||||
if (!item) return 0;
|
||||
|
|
@ -430,7 +491,12 @@
|
|||
|
||||
function obstacleRects(item) {
|
||||
if (!item || item.dead || !isMechanicalType(item)) return [];
|
||||
if (item.type === "poison_block" && bodyOf(item)?.collision?.solid === false) return [];
|
||||
if (bodyOf(item)?.collision?.solid === false) return [];
|
||||
return rectsFor(item, "obstacle");
|
||||
}
|
||||
|
||||
function placementRects(item) {
|
||||
if (!item || item.dead || !isMechanicalType(item)) return [];
|
||||
return rectsFor(item, "obstacle");
|
||||
}
|
||||
|
||||
|
|
@ -465,12 +531,6 @@
|
|||
return aabbFromRects(item, obstacleRects(item), "obstacle");
|
||||
}
|
||||
|
||||
function hazardBoundsAabb(item) {
|
||||
if (!item || item.dead) return null;
|
||||
if (item.type === "poison_block") return aabbFromRects(item, poisonHazardRects(item), "hazard");
|
||||
return boundsAabb(item);
|
||||
}
|
||||
|
||||
function reach(item) {
|
||||
if (!item) return 64;
|
||||
const base = Math.max(num(item.r, 64), extent(item));
|
||||
|
|
@ -487,7 +547,7 @@
|
|||
body.velocity = body.velocity || { x: 0, y: 0, angular: 0, linear: 0 };
|
||||
const vel = body.velocity;
|
||||
const pose = body.pose || {};
|
||||
const mass = Math.max(1.2, ps(item, "mass", 4.8));
|
||||
const mass = Math.max(0.20, ps(item, "mass", 0.45));
|
||||
const prevVx = num(vel.x, item.vx);
|
||||
const prevVy = num(vel.y, item.vy);
|
||||
const nextVx = clamp(prevVx + num(fx) * 0.42 * scale / mass, -260, 260);
|
||||
|
|
@ -501,7 +561,7 @@
|
|||
const rx = num(contactX) - num(pose.x, item.x);
|
||||
const ry = num(contactY) - num(pose.y, item.y);
|
||||
const torque = rx * num(fy) - ry * num(fx);
|
||||
const inertia = Math.max(12000, ps(item, "inertia", 36000));
|
||||
const inertia = Math.max(1200, ps(item, "inertia", 3200));
|
||||
vel.angular = clamp(num(vel.angular, ps(item, "spin", 0)) + torque / inertia * scale, -2.2, 2.2);
|
||||
const changed = Math.hypot(nextVx - prevVx, nextVy - prevVy) > 0.002 || Math.abs(torque) > 0.001;
|
||||
if (changed) { commitBody(item, "poison-block-impulse"); wakeItem(item, "poison-block-impulse"); }
|
||||
|
|
@ -648,7 +708,7 @@
|
|||
|
||||
function mechanicalSeparationWeight(item, nx, ny) {
|
||||
if (!item || item.dead || item.playerHeld || item._heldByPlayer) return 0;
|
||||
if (item.type === "poison_block") return 1 / Math.max(1.2, ps(item, "mass", 4.8));
|
||||
if (item.type === "poison_block") return 1 / Math.max(0.20, ps(item, "mass", 0.45));
|
||||
if (item.type === "reciprocator") {
|
||||
const a = axis(item);
|
||||
const projection = Math.abs(num(nx) * a.x + num(ny) * a.y);
|
||||
|
|
@ -715,9 +775,9 @@
|
|||
const wb = mechanicalSeparationWeight(b, nx, ny);
|
||||
const sum = wa + wb;
|
||||
if (sum <= 0.0001) return false;
|
||||
// Solve most of the penetration as position, not velocity. Capping avoids
|
||||
// large teleports when old saves spawn bodies deeply overlapped.
|
||||
const correction = Math.min(overlap + 0.35, Math.max(2.2, Math.min(18, overlap * 0.82)));
|
||||
// Resolve the actual penetration only. The former extra bias made bodies
|
||||
// visibly pop when many mechanical items touched at once.
|
||||
const correction = Math.min(Math.max(0, overlap - 0.05) * 0.92, 18);
|
||||
const ax = -nx * correction * (wa / sum);
|
||||
const ay = -ny * correction * (wa / sum);
|
||||
const bx = nx * correction * (wb / sum);
|
||||
|
|
@ -735,7 +795,7 @@
|
|||
if (!item || !info) return false;
|
||||
const overlap = Math.max(0, num(info.overlap));
|
||||
if (overlap <= 0.001) return false;
|
||||
const correction = Math.min(overlap + 0.4, Math.max(2.5, Math.min(20, overlap * 0.88)));
|
||||
const correction = Math.min(Math.max(0, overlap - 0.05) * 0.94, 20);
|
||||
const moved = moveMechanicalBodyForSeparation(item, -num(info.nx) * correction, -num(info.ny) * correction, dt, "fence-separation");
|
||||
if (moved) {
|
||||
worldRef?.markSpatialDirty?.("mechanical-fence-separation");
|
||||
|
|
@ -936,11 +996,9 @@
|
|||
|
||||
function strongestRectContact(rectsA, rectsB, padding = 0) {
|
||||
let best = null;
|
||||
const overlapInfo = FP().rectOverlapInfo;
|
||||
if (typeof overlapInfo !== "function") return null;
|
||||
for (const ra of rectsA || []) {
|
||||
for (const rb of rectsB || []) {
|
||||
const info = overlapInfo(ra, rb, padding);
|
||||
const info = rectOverlapInfo(ra, rb, padding);
|
||||
if (!info) continue;
|
||||
if (!best || info.overlap > best.info.overlap) best = { ra, rb, info };
|
||||
}
|
||||
|
|
@ -967,12 +1025,6 @@
|
|||
if (pairFrameGuard(a, b, worldRef, 6)) return false;
|
||||
let contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75);
|
||||
if (!contact) return false;
|
||||
const now = worldRef?.time || 0;
|
||||
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
||||
worldRef._mechanicalContactAt = worldRef._mechanicalContactAt || Object.create(null);
|
||||
const recentlyHandled = (worldRef._mechanicalContactAt[key] || -999) + 0.045 > now;
|
||||
worldRef._mechanicalContactAt[key] = now;
|
||||
|
||||
let info = contact.info;
|
||||
const separated = applyMechanicalPairSeparation(a, b, info, dt, worldRef);
|
||||
if (separated) {
|
||||
|
|
@ -1001,7 +1053,6 @@
|
|||
if (a.type === "rotator") brakePoweredRotatorOnMechanicalContact(a, dt, worldRef, "mechanical");
|
||||
if (b.type === "rotator") brakePoweredRotatorOnMechanicalContact(b, dt, worldRef, "mechanical");
|
||||
|
||||
if (!recentlyHandled) worldRef?.effects?.push(new Effect("ring", info.x, info.y, { size: Math.max(14, Math.min(28, 12 + info.overlap)), life: 0.14, color: "rgba(184,132,230,0.34)" }));
|
||||
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact"); }
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1018,18 +1069,28 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function hasFenceNearby(item, worldRef, radius) {
|
||||
if (!item || !worldRef?.items) return false;
|
||||
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items;
|
||||
for (const other of source) {
|
||||
if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue;
|
||||
if (other.type === "gate_fence" && other.gateOpen) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
const now = worldRef.time || 0;
|
||||
for (const mechRect of rects) {
|
||||
for (const fenceRect of fences) {
|
||||
let info = FP().rectOverlapInfo?.(mechRect, fenceRect, 0.5);
|
||||
let info = rectOverlapInfo(mechRect, fenceRect, item.type === "rotator" ? 1.35 : 0.5);
|
||||
if (!info) continue;
|
||||
const separated = applyMechanicalFenceSeparation(item, info, dt, worldRef);
|
||||
if (separated) {
|
||||
|
|
@ -1042,12 +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);
|
||||
const key = `f:${item.id}:${fenceRect.item?.id || "?"}`;
|
||||
worldRef._mechanicalContactAt = worldRef._mechanicalContactAt || Object.create(null);
|
||||
if ((worldRef._mechanicalContactAt[key] || -999) + 0.08 <= now) {
|
||||
worldRef._mechanicalContactAt[key] = now;
|
||||
worldRef.effects?.push(new Effect("ring", info.x, info.y, { size: Math.max(14, Math.min(30, 13 + info.overlap)), life: 0.13, color: "rgba(145,120,94,0.34)" }));
|
||||
}
|
||||
if (item.type === "rotator") brakePoweredRotatorOnMechanicalContact(item, dt, worldRef, "fence");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1173,12 +1229,11 @@
|
|||
if (Math.abs(num(moved)) <= 0.001) {
|
||||
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
|
||||
if ((item._reciprocatorMovedAt || -999) + 0.18 < now) {
|
||||
// The motor is powered but the pose did not change for several frames.
|
||||
// Reset only rail-local transient state; do not touch user speed/travel.
|
||||
// Do not nudge the rail body forward. Treat a stalled powered rail as
|
||||
// blocked and let the existing blocked/reverse path decide the next move.
|
||||
pset(item, "slideSpeed", 0, "rail-stall-slide-clear");
|
||||
if (!changed && Math.abs(phase) < 1 - 0.000001) {
|
||||
integrateReciprocatorPhase(item, dir * motorSpeed, dt, true, "rail-stall-nudge");
|
||||
changed = true;
|
||||
changed = noteReciprocatorBlocked(item, dt, worldRef, "rail-stall-blocked") || changed;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1204,7 +1259,6 @@
|
|||
resetAnchor(item);
|
||||
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
|
||||
const powered = isPowered(item);
|
||||
const travel = halfTravel(item);
|
||||
const prevX = num(item.x, ps(item, "railAnchorX", 0)), prevY = num(item.y, ps(item, "railAnchorY", 0));
|
||||
item.prevX = prevX; item.prevY = prevY;
|
||||
const baseSpeed = Math.max(0, ps(item, "railMotorSpeed", 92));
|
||||
|
|
@ -1259,6 +1313,59 @@
|
|||
return changed;
|
||||
}
|
||||
|
||||
function resolvePoisonBlockTarinaiContacts(item, worldRef, dt = 0.016) {
|
||||
if (!item || item.dead || item.type !== "poison_block" || !worldRef?.tarinai) return false;
|
||||
if (ps(item, "solid", true) === false) return false;
|
||||
const rects = obstacleRects(item);
|
||||
if (!rects.length) return false;
|
||||
const radius = reach(item) + 96;
|
||||
const source = worldRef.nearbyTarinai?.(item.x, item.y, radius + 48) || worldRef.tarinai || [];
|
||||
let changed = false;
|
||||
let contacts = 0;
|
||||
for (const t of source) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
|
||||
if (Math.hypot(num(t.x) - num(item.x), num(t.y) - num(item.y)) > radius + rr) continue;
|
||||
for (const rect of rects) {
|
||||
if (!worldRef.circleOverlapsObstacleRect?.(t.x, t.y, rr, rect, 1.0)) continue;
|
||||
const beforeX = num(t.x);
|
||||
const beforeY = num(t.y);
|
||||
const pushed = worldRef.pushTarinaiOutOfRect?.(t, rect, rr, {
|
||||
maxPasses: 1,
|
||||
maxPush: Math.max(14, rr * 1.65),
|
||||
slop: 0.45,
|
||||
reason: "poison-block-direct-tarinai-contact",
|
||||
});
|
||||
if (!pushed) continue;
|
||||
contacts += 1;
|
||||
changed = true;
|
||||
const dx = num(t.x) - beforeX;
|
||||
const dy = num(t.y) - beforeY;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 0.001) {
|
||||
// Eating/sleeping bodies can be nearly stationary. Feed a small,
|
||||
// opposite impulse back into the passive block so the contact is not
|
||||
// visually perceived as the block tunneling through the Tarinai.
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
const speed = Math.hypot(ps(item, "xv", 0), ps(item, "yv", 0));
|
||||
const impulse = Math.min(34, 5.5 + d * 0.85 + speed * 0.035);
|
||||
applyImpulse(item, num(t.x) - nx * rr, num(t.y) - ny * rr, -nx * impulse, -ny * impulse, 0.42);
|
||||
t.lastSolidObstacleCollisionSource = item;
|
||||
t.lastSolidObstacleCollisionAt = worldRef.time || 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (contacts >= 10) break;
|
||||
}
|
||||
if (changed) {
|
||||
commitBody(item, "poison-block-tarinai-contact");
|
||||
worldRef.markSpatialDirty?.("poison-block-tarinai-contact");
|
||||
worldRef.drawListDirty = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updatePoisonBlock(item, dt, worldRef, opts = {}) {
|
||||
if (!item || item.dead || item.type !== "poison_block") return false;
|
||||
applyBodyState(item);
|
||||
|
|
@ -1293,6 +1400,7 @@
|
|||
item.angle = normalizeAngle(beforeA + ps(item, "spin", 0) * subDt);
|
||||
changed = constrainPassiveBlockInsideWorld(item, worldRef) || changed;
|
||||
if (hasContacts) changed = resolveInteractions(item, subDt, worldRef) || changed;
|
||||
changed = resolvePoisonBlockTarinaiContacts(item, worldRef, subDt) || changed;
|
||||
changed = changed || Math.hypot(num(item.x) - beforeX, num(item.y) - beforeY) > 0.001 || Math.abs(num(item.angle) - beforeA) > 0.0001;
|
||||
}
|
||||
const friction = Math.pow(0.42, stepTime);
|
||||
|
|
@ -1320,7 +1428,7 @@
|
|||
function shouldCollideMechanical(item) {
|
||||
if (!item || item.dead || !isMechanicalType(item)) return false;
|
||||
if (item.playerHeld || item._heldByPlayer) return false;
|
||||
if (item.type === "poison_block" && bodyOf(item)?.collision?.solid === false) return false;
|
||||
if (bodyOf(item)?.collision?.solid === false) return false;
|
||||
return obstacleRects(item).length > 0;
|
||||
}
|
||||
|
||||
|
|
@ -1460,6 +1568,9 @@
|
|||
const active = all.filter(r => r.active);
|
||||
if (!active.length) return [];
|
||||
const minUseFocused = Number(opts.focusThreshold || 48) || 48;
|
||||
// When only a few bodies are awake, build the broadphase around those
|
||||
// bodies plus nearby mechanical neighbors instead of scanning every static
|
||||
// collider in the field.
|
||||
if (!worldRef?.nearbyObstacles || all.length <= minUseFocused || active.length > Math.max(10, all.length * 0.45)) return all;
|
||||
const chosen = new Set(active.map(r => r.item));
|
||||
const mech = global.TarinaiMechanicalSystem;
|
||||
|
|
@ -1496,6 +1607,8 @@
|
|||
let cells = (maxX - minX + 1) * (maxY - minY + 1);
|
||||
if (!Number.isFinite(cells) || cells <= 0) cells = 1;
|
||||
if (cells > maxCellsPerBody) {
|
||||
// Oversized AABBs can explode grid insertion cost. Center-bucketing
|
||||
// keeps the frame bounded; the later narrowphase still checks geometry.
|
||||
const key = `${Math.floor(((a.left + a.right) * 0.5) / cellSize)}:${Math.floor(((a.top + a.bottom) * 0.5) / cellSize)}`;
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) { bucket = []; grid.set(key, bucket); }
|
||||
|
|
@ -1562,6 +1675,8 @@
|
|||
pairs += 1;
|
||||
if (pairCandidates.length < maxCandidates) pairCandidates.push({ a, b, key, score });
|
||||
else {
|
||||
// Keep only the highest-priority candidates so pathological overlap
|
||||
// scenes stay bounded before narrowphase resolution.
|
||||
let worst = 0;
|
||||
for (let k = 1; k < pairCandidates.length; k += 1) if (pairCandidates[k].score < pairCandidates[worst].score) worst = k;
|
||||
if (score > pairCandidates[worst].score) pairCandidates[worst] = { a, b, key, score };
|
||||
|
|
@ -1622,7 +1737,7 @@
|
|||
function updateMechanicalWorld(worldRef, dt = 0.016, opts = {}) {
|
||||
if (!worldRef?.itemsOfType) return { ran: 0, active: 0, solved: 0, fenceSolved: 0, links: 0 };
|
||||
const profiler = global.TarinaiPerf;
|
||||
const end = profiler?.begin?.("update.mechanicalWorld") || null;
|
||||
const end = profiler.begin("update.mechanicalWorld") || null;
|
||||
try {
|
||||
const rawDt = Number(dt || 0.016) || 0.016;
|
||||
const clampedDt = Math.max(0.001, Math.min(0.05, rawDt));
|
||||
|
|
@ -1633,13 +1748,21 @@
|
|||
const movingWork = active.length;
|
||||
let maxMotion = 0;
|
||||
for (const item of active) maxMotion = Math.max(maxMotion, estimatedBodyMotion(item, clampedDt));
|
||||
const substeps = Math.max(1, Math.min(Number(opts.maxSubsteps || 4) || 4, Math.max(Math.ceil(movingWork / 36), Math.ceil(maxMotion / 14))));
|
||||
let fenceSensitiveMotion = false;
|
||||
for (const item of active) {
|
||||
if (item?.type === "rotator" && estimatedBodyMotion(item, clampedDt) > 9 && hasFenceNearby(item, worldRef, reach(item) + 94)) { fenceSensitiveMotion = true; break; }
|
||||
}
|
||||
const requestedMaxSubsteps = Number(opts.maxSubsteps || 4) || 4;
|
||||
const substepCap = fenceSensitiveMotion ? Math.min(6, Math.max(requestedMaxSubsteps, requestedMaxSubsteps + 2)) : requestedMaxSubsteps;
|
||||
const motionSlice = fenceSensitiveMotion ? 8 : 14;
|
||||
const substeps = Math.max(1, Math.min(substepCap, Math.max(Math.ceil(movingWork / 36), Math.ceil(maxMotion / motionSlice))));
|
||||
const subDt = clampedDt / substeps;
|
||||
let ran = 0;
|
||||
let moved = false;
|
||||
let stepSolved = 0;
|
||||
let stepChecked = 0;
|
||||
let stepPairs = 0;
|
||||
let stepFenceSolved = 0;
|
||||
// Resolve body pairs between substeps, not only once after all motion.
|
||||
// This keeps thin, visually-aligned rotator/reciprocator strokes from
|
||||
// tunneling through each other without inflating their collision width.
|
||||
|
|
@ -1653,14 +1776,22 @@
|
|||
else if (item.type === "poison_block") changed = updatePoisonBlock(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true });
|
||||
if (changed) { moved = true; subMoved = true; ran += 1; }
|
||||
}
|
||||
if (subMoved && active.length > 1) {
|
||||
const subFrame = buildMechanicalFrame(worldRef, { bodies });
|
||||
if (subMoved) {
|
||||
const subFrame = active.length > 1 || active.some(it => it?.type === "rotator") ? buildMechanicalFrame(worldRef, { bodies }) : null;
|
||||
if (active.length > 1 && subFrame) {
|
||||
const subStats = resolveMechanicalPairsBroadphase(worldRef, subDt, { frame: subFrame, maxPairs: opts.maxSubstepPairs || 140, focusThreshold: opts.focusThreshold || 48 });
|
||||
if (subStats.solved) moved = true;
|
||||
stepSolved += subStats.solved || 0;
|
||||
stepChecked += subStats.checked || 0;
|
||||
stepPairs += subStats.pairs || 0;
|
||||
}
|
||||
// Fence contacts are checked during high-speed rotator substeps rather
|
||||
// than only after the final pose. This closes the tunneling gap where
|
||||
// a thin/hand-drawn rotator can pass entirely across a fence between
|
||||
// two final-frame overlap checks.
|
||||
const subFenceSolved = resolveMechanicalFences(worldRef, subDt, active, subFrame?.activeSet || new Set(active), { maxFenceItems: opts.maxSubstepFenceItems || 32 });
|
||||
if (subFenceSolved) { moved = true; stepFenceSolved += subFenceSolved; }
|
||||
}
|
||||
}
|
||||
if (moved) {
|
||||
worldRef.drawListDirty = true;
|
||||
|
|
@ -1668,7 +1799,7 @@
|
|||
}
|
||||
// Resolve links after all motors/passive bodies have moved, so constraints see
|
||||
// one coherent world-state rather than each item solving in isolation.
|
||||
const links = opts.skipLinks === true ? 0 : (global.TarinaiConstraintSystem?.updateWorld?.(worldRef, clampedDt, { maxLinks: opts.maxLinks || 160 }) || 0);
|
||||
const links = opts.skipLinks === true ? 0 : (global.TarinaiConstraintSystem.updateWorld(worldRef, clampedDt, { maxLinks: opts.maxLinks || 160 }) || 0);
|
||||
// Build a single physics frame after motion and reuse it for pair and fence passes.
|
||||
// This is intentionally save-incompatible: transient body records are runtime only.
|
||||
const frame = buildMechanicalFrame(worldRef, { bodies });
|
||||
|
|
@ -1676,12 +1807,12 @@
|
|||
pairStats.solved = (pairStats.solved || 0) + stepSolved;
|
||||
pairStats.checked = (pairStats.checked || 0) + stepChecked;
|
||||
pairStats.pairs = (pairStats.pairs || 0) + stepPairs;
|
||||
const fenceSolved = resolveMechanicalFences(worldRef, clampedDt, bodies, frame.activeSet, opts);
|
||||
const fenceSolved = stepFenceSolved + resolveMechanicalFences(worldRef, clampedDt, bodies, frame.activeSet, opts);
|
||||
if (pairStats.solved || fenceSolved || links) {
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("mechanical-world-solve");
|
||||
}
|
||||
worldRef._mechanicalWorldStats = { bodies: bodies.length, active: frame.active, collidable: frame.collidable, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused), candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps };
|
||||
worldRef._mechanicalWorldStats = { bodies: bodies.length, active: frame.active, collidable: frame.collidable, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused), candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps, fenceSensitive: fenceSensitiveMotion };
|
||||
return { ran, active: frame.active, solved: pairStats.solved, checked: pairStats.checked, pairs: pairStats.pairs, fenceSolved, links, candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused) };
|
||||
} finally {
|
||||
if (end) end();
|
||||
|
|
@ -1708,7 +1839,7 @@
|
|||
if (item.type === "rotator" && !isPowered(item)) {
|
||||
const a = itemAngle(item);
|
||||
const pose = bodyPose(item);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || ((typeof stableUnit === "function" ? stableUnit(item.id || item.seed || "rotator", "poke-side") : 0.35) < 0.5 ? -1 : 1);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || (stableUnit(item.id || item.seed || "rotator", "poke-side") < 0.5 ? -1 : 1);
|
||||
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 + side * 0.32, -1.6, 1.6), "pair-spin-response");
|
||||
commitBody(item, "poke-passive-rotator");
|
||||
worldRef?.markSpatialDirty?.("poke-passive-rotator");
|
||||
|
|
@ -1717,7 +1848,7 @@
|
|||
if (item.type === "reciprocator" && !isPowered(item)) {
|
||||
const a = axis(item);
|
||||
const pose = bodyPose(item);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || ((typeof stableUnit === "function" ? stableUnit(item.id || item.seed || "reciprocator", "poke-side") : 0.65) < 0.5 ? -1 : 1);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || (stableUnit(item.id || item.seed || "reciprocator", "poke-side") < 0.5 ? -1 : 1);
|
||||
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 + side * 34, -115, 115), "pair-slide-response");
|
||||
commitBody(item, "poke-passive-reciprocator");
|
||||
worldRef?.markSpatialDirty?.("poke-passive-reciprocator");
|
||||
|
|
@ -1727,51 +1858,21 @@
|
|||
}
|
||||
|
||||
function hitTest(worldRef, item, x, y, opts = {}) {
|
||||
return FP().hitTestItem?.(worldRef, item, x, y, opts) || { hit: false, distance: Infinity };
|
||||
return global.TarinaiCollisionFootprints.hitTestItem(worldRef, item, x, y, opts) || { hit: false, distance: Infinity };
|
||||
}
|
||||
|
||||
global.TarinaiMechanicalSystem = Object.freeze({
|
||||
isMechanicalType,
|
||||
motionType,
|
||||
isPowered,
|
||||
itemAngle,
|
||||
sanitizeSegments,
|
||||
thickness,
|
||||
extent,
|
||||
worldSegments,
|
||||
obstacleRects,
|
||||
placementRects,
|
||||
poisonHazardRects,
|
||||
boundsAabb,
|
||||
hazardBoundsAabb,
|
||||
axis,
|
||||
railAxisAngle,
|
||||
halfTravel,
|
||||
resetAnchor,
|
||||
positionFromPhase,
|
||||
phaseFromPosition,
|
||||
signedDrive,
|
||||
pointVelocity,
|
||||
applyImpulse,
|
||||
applyPassiveReciprocatorImpulse,
|
||||
applyPassiveRotatorImpulse,
|
||||
applyRailCorrection,
|
||||
reverseReciprocatorOnMechanicalContact,
|
||||
resolveReciprocatorPhysicalContacts,
|
||||
repairPoweredReciprocatorStall,
|
||||
brakePoweredRotatorOnMechanicalContact,
|
||||
applySurfaceVelocityToCircle,
|
||||
strongestRectContact,
|
||||
resolvePair,
|
||||
resolveFenceContacts,
|
||||
resolveInteractions,
|
||||
resolveMechanicalPairs,
|
||||
resolveMechanicalPairsBroadphase,
|
||||
updateWorld: updateMechanicalWorld,
|
||||
collectMechanicalBodies,
|
||||
isMechanicallyActive,
|
||||
estimatedBodyMotion,
|
||||
shouldCollideMechanical,
|
||||
hasInteractionCandidates,
|
||||
updateRotator,
|
||||
updateReciprocator,
|
||||
updatePoisonBlock,
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const applied = new Set();
|
||||
function patchPrototype(key, owner, patchFn) {
|
||||
if (!key || !owner?.prototype || typeof patchFn !== "function") return false;
|
||||
const fullKey = `${owner.name || "Owner"}:${key}`;
|
||||
if (applied.has(fullKey) || owner.prototype[`__${key}Patched`]) return false;
|
||||
patchFn(owner.prototype);
|
||||
applied.add(fullKey);
|
||||
owner.prototype[`__${key}Patched`] = true;
|
||||
return true;
|
||||
}
|
||||
function patchWorld(key, patchFn) { return patchPrototype(key, global.World, patchFn); }
|
||||
global.TarinaiPatcher = { patchPrototype, patchWorld, applied };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -17,8 +17,9 @@
|
|||
let frameOpen = null;
|
||||
let frameCount = 0;
|
||||
let lastQualityCheckAt = 0;
|
||||
let tier = "high";
|
||||
let dprScale = 1;
|
||||
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) {
|
||||
|
|
@ -75,10 +76,12 @@
|
|||
|
||||
function qualityFromFps(fps) {
|
||||
const f = Number(fps) || 0;
|
||||
if (f > 55) return { tier: "high", dpr: 1 };
|
||||
if (f < 32) return { tier: "low", dpr: 0.72 };
|
||||
if (f < 45) return { tier: "mid", dpr: 0.86 };
|
||||
return { tier: tier === "low" ? "mid" : tier, dpr: tier === "low" ? 0.86 : dprScale };
|
||||
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 endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) {
|
||||
|
|
@ -151,7 +154,6 @@
|
|||
}
|
||||
|
||||
global.TarinaiPerf = Object.freeze({
|
||||
enabled: debugEnabled,
|
||||
begin,
|
||||
beginFrame,
|
||||
endFrame,
|
||||
|
|
|
|||
|
|
@ -3,18 +3,11 @@
|
|||
(function (global) {
|
||||
|
||||
function rectLocalPoint(rect, x, y) {
|
||||
const dx = x - (rect.cx ?? 0);
|
||||
const dy = y - (rect.cy ?? 0);
|
||||
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(rect.angle || 0);
|
||||
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(rect.angle || 0);
|
||||
return { x: dx * c + dy * ss, y: -dx * ss + dy * c };
|
||||
return global.TarinaiCollisionFootprints.rectLocalPoint(rect, x, y);
|
||||
}
|
||||
|
||||
|
||||
function rectWorldVector(rect, x, y) {
|
||||
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(rect.angle || 0);
|
||||
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(rect.angle || 0);
|
||||
return { x: x * c - y * ss, y: x * ss + y * c };
|
||||
return global.TarinaiCollisionFootprints.rectWorldVector(rect, x, y);
|
||||
}
|
||||
|
||||
function circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts = {}) {
|
||||
|
|
@ -86,7 +79,7 @@
|
|||
}
|
||||
}
|
||||
if (rect.mechanical) {
|
||||
global.TarinaiMechanicalSystem?.applySurfaceVelocityToCircle?.(obj, rect, nx, ny, vx, vy, {
|
||||
global.TarinaiMechanicalSystem.applySurfaceVelocityToCircle(obj, rect, nx, ny, vx, vy, {
|
||||
damping: 1,
|
||||
surfaceScale: rect.rotator ? 0.28 : 0.30,
|
||||
normalBoost: rect.rotator ? 20 : 14,
|
||||
|
|
@ -265,5 +258,5 @@
|
|||
return speed;
|
||||
}
|
||||
|
||||
global.TarinaiPhysics = { bounceInsideWorld, bounceCircleOffRect, resolveKinematicBounceFenceCollision, applyKinematicItemMotion };
|
||||
global.TarinaiPhysics = { bounceCircleOffRect, applyKinematicItemMotion };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
32
js/physics_projection_system.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: physics/projection-bridge
|
||||
// Shared tiny helpers for positional projection used by links, mechanical
|
||||
// bodies, and circle/circle collision code.
|
||||
(function (global) {
|
||||
function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; }
|
||||
|
||||
function markSpatialDirty(worldRef, item, reason = "projection", threshold = 0.35, minInterval = 0.065) {
|
||||
const world = worldRef || item?.world || null;
|
||||
if (!world || !item) return false;
|
||||
const now = Number(world.time || 0) || 0;
|
||||
const x = num(item.x), y = num(item.y), r = num(item.r || item.radius || 0);
|
||||
const lastX = Number(item._lastProjectionSpatialDirtyX);
|
||||
const lastY = Number(item._lastProjectionSpatialDirtyY);
|
||||
const lastR = Number(item._lastProjectionSpatialDirtyR);
|
||||
const missing = !Number.isFinite(lastX) || !Number.isFinite(lastY) || !Number.isFinite(lastR);
|
||||
const moved = missing || Math.hypot(x - lastX, y - lastY) > threshold || Math.abs(r - lastR) > threshold;
|
||||
const due = now >= Number(item._nextProjectionSpatialDirtyAt || 0);
|
||||
if (!moved && !due) return false;
|
||||
item._lastProjectionSpatialDirtyX = x;
|
||||
item._lastProjectionSpatialDirtyY = y;
|
||||
item._lastProjectionSpatialDirtyR = r;
|
||||
item._nextProjectionSpatialDirtyAt = now + Math.max(0.016, num(minInterval, 0.065));
|
||||
world.markSpatialDirty?.(reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiPhysicsProjectionSystem = Object.freeze({
|
||||
markSpatialDirty,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
67
js/physics_shape_editor_system.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: ui/physics-shape-editor
|
||||
// Shared button labels/templates for rotator, reciprocator, and poison-block
|
||||
// segment editors. Low-level editor pointer behavior remains in placement log.
|
||||
(function (global) {
|
||||
const MODE_LABELS = Object.freeze({
|
||||
line: "📏 直線",
|
||||
free: "✎ 自由描画",
|
||||
erase: "⌫ 消しゴム",
|
||||
});
|
||||
const TEMPLATE_LABELS = Object.freeze({
|
||||
bar: "┃ 棒",
|
||||
cross: "+ 十字",
|
||||
circle: "○ 円",
|
||||
});
|
||||
const ACTION_LABELS = Object.freeze({
|
||||
undo: "↶ 一つ戻す",
|
||||
clear: "🗑 全消し",
|
||||
});
|
||||
function templateSegments(name) {
|
||||
if (name === "bar") return [[-130, 0, 130, 0]];
|
||||
if (name === "cross") return [[-118, 0, 118, 0], [0, -90, 0, 90]];
|
||||
if (name === "circle") {
|
||||
const segments = [];
|
||||
const n = 18, r = 108;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = i / n * Math.PI * 2;
|
||||
const b = (i + 1) / n * Math.PI * 2;
|
||||
segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function decorateToolbar(dialog, prefix = "rotator") {
|
||||
if (!dialog) return false;
|
||||
for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) {
|
||||
const key = btn.dataset[`${prefix}Mode`];
|
||||
if (MODE_LABELS[key]) { btn.textContent = MODE_LABELS[key]; btn.title = MODE_LABELS[key].replace(/^[^\s]+\s*/, ""); }
|
||||
}
|
||||
for (const btn of dialog.querySelectorAll(`[data-${prefix}-template]`)) {
|
||||
const key = btn.dataset[`${prefix}Template`];
|
||||
if (TEMPLATE_LABELS[key]) { btn.textContent = TEMPLATE_LABELS[key]; btn.title = TEMPLATE_LABELS[key].replace(/^[^\s]+\s*/, ""); }
|
||||
}
|
||||
const undo = dialog.querySelector(`#${prefix}Undo`);
|
||||
const clear = dialog.querySelector(`#${prefix}Clear`);
|
||||
if (undo) undo.textContent = ACTION_LABELS.undo;
|
||||
if (clear) clear.textContent = ACTION_LABELS.clear;
|
||||
return true;
|
||||
}
|
||||
function pointSegmentDistance(p, seg) {
|
||||
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
|
||||
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
|
||||
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
|
||||
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
|
||||
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
|
||||
}
|
||||
global.TarinaiPhysicsShapeEditorSystem = Object.freeze({
|
||||
decorateToolbar,
|
||||
templateSegments,
|
||||
pointSegmentDistance,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -4,8 +4,6 @@
|
|||
// Central schema, serialization, and orchestration layer for physical tools.
|
||||
// Physical state lives only in item.physicsBody / item.physicsConstraint.
|
||||
(function (global) {
|
||||
const BODY_SCHEMA_VERSION = 4;
|
||||
const CONSTRAINT_SCHEMA_VERSION = 4;
|
||||
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
|
||||
const LINK_TYPES = new Set(["rope", "rod"]);
|
||||
|
||||
|
|
@ -54,13 +52,11 @@
|
|||
}
|
||||
return out.length ? out : (fallback || [[-78, 0, 78, 0]]).map(seg => seg.slice());
|
||||
}
|
||||
function q(value, scale = 1000) { return Math.round(num(value) * scale); }
|
||||
|
||||
function defaultBody(item) {
|
||||
const type = String(item?.type || "");
|
||||
const x = num(item?.x), y = num(item?.y), angle = num(item?.angle);
|
||||
return {
|
||||
schema: BODY_SCHEMA_VERSION,
|
||||
type,
|
||||
kind: defaultKind(type),
|
||||
pose: { x, y, angle },
|
||||
|
|
@ -71,8 +67,8 @@
|
|||
collision: { solid: true, hazard: type === "poison_block" },
|
||||
hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null,
|
||||
sleep: { awakeUntil: 0 },
|
||||
mass: type === "poison_block" ? 4.8 : 1,
|
||||
inertia: type === "poison_block" ? 36000 : 22000,
|
||||
mass: type === "poison_block" ? 0.45 : 1,
|
||||
inertia: type === "poison_block" ? 3200 : 22000,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +76,6 @@
|
|||
const type = String(item?.type || body?.type || "");
|
||||
const base = defaultBody({ type, x: item?.x, y: item?.y, angle: item?.angle });
|
||||
const out = body && typeof body === "object" ? body : base;
|
||||
out.schema = BODY_SCHEMA_VERSION;
|
||||
out.type = type;
|
||||
out.kind = out.kind || base.kind;
|
||||
out.pose = out.pose && typeof out.pose === "object" ? out.pose : base.pose;
|
||||
|
|
@ -160,28 +155,6 @@
|
|||
|
||||
function markBodyChanged(item, reason = "physics-body") {
|
||||
if (!item) return;
|
||||
item._physicsBodyVersion = (Number(item._physicsBodyVersion || 0) || 0) + 1;
|
||||
item._physicsBodyDirtyReason = reason;
|
||||
}
|
||||
|
||||
function bodySignature(item) {
|
||||
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
|
||||
if (!b) return "";
|
||||
const s = b.shape || {};
|
||||
const v = b.velocity || {};
|
||||
const m = b.motor || {};
|
||||
const r = b.rail || {};
|
||||
const c = b.collision || {};
|
||||
const sl = b.sleep || {};
|
||||
return [
|
||||
b.type, q(b.pose?.x, 10), q(b.pose?.y, 10), q(b.pose?.angle, 10000),
|
||||
q(s.thickness, 10), Number(s.version || 0) || 0, Array.isArray(s.segments) ? s.segments.length : 0,
|
||||
c.solid === false ? 0 : 1, c.hazard === false ? 0 : 1,
|
||||
m.powered === false ? 0 : 1, q(m.speed, 1000), q(m.direction, 10),
|
||||
q(v.x, 1000), q(v.y, 1000), q(v.angular, 10000), q(v.linear, 1000),
|
||||
q(r.axisAngle, 10000), q(r.travel, 10), q(r.phase, 10000), q(r.anchorX, 10), q(r.anchorY, 10),
|
||||
q(sl.awakeUntil, 1000), q(b.hazard?.damage, 10), q(b.mass, 10), q(b.inertia, 1),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function scalar(item, key, fallback = 0) {
|
||||
|
|
@ -232,8 +205,8 @@
|
|||
case "slideSpeed": b.velocity.linear = num(value); break;
|
||||
case "solid": b.collision.solid = !!value; break;
|
||||
case "damage": b.hazard = b.hazard || { kind: "poison", damage: 7 }; b.hazard.damage = Math.max(1, num(value, 7)); break;
|
||||
case "mass": b.mass = Math.max(0.2, num(value, 4.8)); break;
|
||||
case "inertia": b.inertia = Math.max(1, num(value, 36000)); break;
|
||||
case "mass": b.mass = Math.max(0.12, num(value, 0.45)); break;
|
||||
case "inertia": b.inertia = Math.max(1, num(value, item.type === "poison_block" ? 3200 : 36000)); break;
|
||||
case "railOn": b.motor.powered = !!value; break;
|
||||
case "railMotorSpeed": b.motor.speed = Math.max(0, num(value, 92)); break;
|
||||
case "railTravel": if (b.rail) b.rail.travel = Math.max(24, num(value, 150)); break;
|
||||
|
|
@ -247,7 +220,7 @@
|
|||
}
|
||||
normalizeBody(b, item);
|
||||
markBodyChanged(item, reason);
|
||||
if (key === "thickness" || key === "solid") global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
|
||||
if (key === "thickness" || key === "solid") global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -260,18 +233,9 @@
|
|||
b.shape.segments = cloneSegments(next, defaultSegments(item.type));
|
||||
b.shape.version = (Number(b.shape.version || 0) || 0) + 1;
|
||||
markBodyChanged(item, reason);
|
||||
global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
|
||||
global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
||||
return true;
|
||||
}
|
||||
function pose(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.pose || null; }
|
||||
function velocity(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.velocity || null; }
|
||||
function motor(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.motor || null; }
|
||||
function rail(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.rail || null; }
|
||||
function shape(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.shape || null; }
|
||||
function collision(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.collision || null; }
|
||||
function hazard(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.hazard || null; }
|
||||
function sleep(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.sleep || null; }
|
||||
|
||||
function syncPoseFromItem(item) {
|
||||
if (!item) return null;
|
||||
// Capture the item pose before ensureBody()/normalizeBody() can write the
|
||||
|
|
@ -327,7 +291,6 @@
|
|||
function defaultConstraint(item) {
|
||||
const x = num(item?.x), y = num(item?.y);
|
||||
return {
|
||||
schema: CONSTRAINT_SCHEMA_VERSION,
|
||||
type: item?.type,
|
||||
kind: item?.type === "rope" ? "flexible-distance" : "rigid-distance",
|
||||
endpoints: [null, null],
|
||||
|
|
@ -340,7 +303,6 @@
|
|||
function normalizeConstraint(c, item) {
|
||||
const base = defaultConstraint(item);
|
||||
const out = c && typeof c === "object" ? c : base;
|
||||
out.schema = CONSTRAINT_SCHEMA_VERSION;
|
||||
out.type = item?.type;
|
||||
out.kind = out.kind || base.kind;
|
||||
out.endpoints = Array.isArray(out.endpoints) ? out.endpoints : [null, null];
|
||||
|
|
@ -414,12 +376,6 @@
|
|||
c.particles = Array.isArray(list) ? list : null;
|
||||
return true;
|
||||
}
|
||||
function constraintSignature(item) {
|
||||
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
|
||||
if (!c) return "";
|
||||
const epSig = ep => !ep ? "null" : [ep.kind || "item", ep.id || "", ep.type || "", q(ep.localX, 10), q(ep.localY, 10), ep.center ? 1 : 0, q(ep.x, 10), q(ep.y, 10), ep.attachT == null ? "" : q(ep.attachT, 1000)].join("/");
|
||||
return [item.type || "", q(c.length, 10), q(c.mid?.x, 10), q(c.mid?.y, 10), q(c.mid?.vx, 1000), q(c.mid?.vy, 1000), q(c.sleep?.awakeUntil, 1000), epSig(c.endpoints?.[0]), epSig(c.endpoints?.[1])].join(":");
|
||||
}
|
||||
|
||||
function collectPhysicsItems(worldRef) {
|
||||
const bodies = [];
|
||||
|
|
@ -434,7 +390,7 @@
|
|||
const { bodies, constraints } = collectPhysicsItems(worldRef);
|
||||
for (const item of bodies) ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true });
|
||||
for (const item of constraints) ensureConstraint(item, worldRef, { syncFromLegacy: false });
|
||||
const frame = { id: (worldRef._physicsFrameSeq = (Number(worldRef._physicsFrameSeq || 0) || 0) + 1), time: num(worldRef?.time), bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length, sync: { bodyReads: 0, bodyWrites: 0, constraintReads: 0, constraintWrites: 0 } };
|
||||
const frame = { time: num(worldRef?.time), bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length, sync: { bodyReads: 0, bodyWrites: 0, constraintReads: 0, constraintWrites: 0 } };
|
||||
worldRef._physicsWorldFrame = frame;
|
||||
return frame;
|
||||
}
|
||||
|
|
@ -455,15 +411,14 @@
|
|||
function updateWorld(worldRef, dt = 0.016, opts = {}) {
|
||||
if (!worldRef?.itemsOfType) return { ran: 0, bodies: 0, constraints: 0 };
|
||||
const profiler = global.TarinaiPerf;
|
||||
const end = profiler?.begin?.("update.physicsWorld.central") || null;
|
||||
const end = profiler.begin("update.physicsWorld.central") || null;
|
||||
try {
|
||||
const frame = prepareWorld(worldRef, opts);
|
||||
const mechanicalStats = global.TarinaiMechanicalSystem?.updateWorld?.(worldRef, dt, { maxPairs: opts.maxPairs || 320, maxLinks: 0, skipLinks: true, maxSubsteps: opts.maxSubsteps || 5, focusThreshold: opts.focusThreshold || 44 }) || { ran: 0, active: 0 };
|
||||
const constraintRan = global.TarinaiConstraintSystem?.updateWorld?.(worldRef, dt, { maxLinks: opts.maxLinks || 220 }) || 0;
|
||||
const postConstraintPairs = constraintRan ? (global.TarinaiMechanicalSystem?.resolveMechanicalPairs?.(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0;
|
||||
const mechanicalStats = global.TarinaiMechanicalSystem.updateWorld(worldRef, dt, { maxPairs: opts.maxPairs || 320, maxLinks: 0, skipLinks: true, maxSubsteps: opts.maxSubsteps || 5, focusThreshold: opts.focusThreshold || 44 }) || { ran: 0, active: 0 };
|
||||
const constraintRan = global.TarinaiConstraintSystem.updateWorld(worldRef, dt, { maxLinks: opts.maxLinks || 220 }) || 0;
|
||||
const postConstraintPairs = constraintRan ? (global.TarinaiMechanicalSystem.resolveMechanicalPairs(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0;
|
||||
const committed = commitWorld(worldRef, frame);
|
||||
const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, postConstraintPairs, committed, sync: frame.sync || null, schema: BODY_SCHEMA_VERSION };
|
||||
worldRef._physicsWorldStats = stats;
|
||||
const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, postConstraintPairs, committed, sync: frame.sync || null };
|
||||
return stats;
|
||||
} finally { if (end) end(); }
|
||||
}
|
||||
|
|
@ -531,7 +486,7 @@
|
|||
return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) });
|
||||
};
|
||||
const midArr = Array.isArray(p.mid) ? p.mid : [];
|
||||
item.physicsConstraint = normalizeConstraint({ schema: CONSTRAINT_SCHEMA_VERSION, type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : "rigid-distance"), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item);
|
||||
item.physicsConstraint = normalizeConstraint({ type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : "rigid-distance"), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item);
|
||||
return true;
|
||||
}
|
||||
function applyCompactExtra(item, extra, tarinaiList = [], itemList = []) {
|
||||
|
|
@ -544,7 +499,7 @@
|
|||
if (!item) return false;
|
||||
if (isBodyType(item)) {
|
||||
ensureBody(item, item.world || null, { rebuild: false, syncFromLegacy: false });
|
||||
global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
|
||||
global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
||||
markBodyChanged(item, reason);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -568,22 +523,19 @@
|
|||
|
||||
// Schema normalization entry points.
|
||||
const normalizeBodyState = (item, body = item?.physicsBody) => { if (item && body) { item.physicsBody = normalizeBody(body, item); return item.physicsBody; } return null; };
|
||||
const applyBodyState = (item, body = item?.physicsBody, opts = {}) => { if (item && body) { item.physicsBody = normalizeBody(body, item); if (opts.invalidateShape === true) global.TarinaiMechanicalSystem?.invalidateGeometry?.(item); return applyPoseToItem(item); } return false; };
|
||||
const applyBodyState = (item, body = item?.physicsBody, opts = {}) => { if (item && body) { item.physicsBody = normalizeBody(body, item); if (opts.invalidateShape === true) global.TarinaiMechanicalSystem.invalidateGeometry(item); return applyPoseToItem(item); } return false; };
|
||||
const normalizeConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return item.physicsConstraint; } return null; };
|
||||
const applyConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return true; } return false; };
|
||||
|
||||
const bodyApi = Object.freeze({
|
||||
BODY_SCHEMA_VERSION, CONSTRAINT_SCHEMA_VERSION, BODY_TYPES, LINK_TYPES,
|
||||
isBodyType, isConstraintType, isPhysicsType,
|
||||
bodySignature, constraintSignature,
|
||||
ensureBody, ensureConstraint,
|
||||
normalizeBodyState, applyBodyState, markBodyChanged, normalizeConstraintState, applyConstraintState,
|
||||
prepareWorld, commitWorld, compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage,
|
||||
defaultSegments, cloneSegments, invalidateItem,
|
||||
pose, velocity, motor, rail, shape, collision, hazard, sleep,
|
||||
scalar, setScalar, segments, setSegments, syncPoseFromItem, applyPoseToItem,
|
||||
endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles, endpointToPlain,
|
||||
compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage,
|
||||
invalidateItem,
|
||||
scalar, setScalar, segments, setSegments, syncPoseFromItem,
|
||||
endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles,
|
||||
});
|
||||
global.TarinaiPhysicsBodySystem = bodyApi;
|
||||
global.TarinaiPhysicsWorldSystem = Object.freeze({ updateWorld, prepareWorld, commitWorld, collectPhysicsItems, bodyApi });
|
||||
global.TarinaiPhysicsWorldSystem = Object.freeze({ updateWorld });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
125
js/pin_attachment_system.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: entity-runtime/items/pin-attachment
|
||||
// Centralizes pushpin/oshibyo attachment geometry, target classification, and
|
||||
// render attachment placement. Motion/effects still live in dynamic systems.
|
||||
(function (global) {
|
||||
function isPin(itemOrType) {
|
||||
const type = typeof itemOrType === "string" ? itemOrType : itemOrType?.type;
|
||||
return Boolean(type && typeof global.isPinType === "function" && global.isPinType(type));
|
||||
}
|
||||
function behaviorFor(itemOrType) {
|
||||
const type = typeof itemOrType === "string" ? itemOrType : itemOrType?.type;
|
||||
return typeof global.pinBehaviorFor === "function" ? global.pinBehaviorFor(type) : null;
|
||||
}
|
||||
function isOshibyo(itemOrType) {
|
||||
return Boolean(behaviorFor(itemOrType)?.blocksZunchi);
|
||||
}
|
||||
function tarinaiVisualSide(item, tarinai) {
|
||||
const faceDir = tarinai?.facingDir ? tarinai.facingDir() : (tarinai?.facing || 1);
|
||||
const faceSide = faceDir >= 0 ? 1 : -1;
|
||||
return isOshibyo(item) ? -faceSide : faceSide;
|
||||
}
|
||||
function tarinaiAnchor(item, tarinai, opts = {}) {
|
||||
const r = tarinai?.radius || 16;
|
||||
const visualSide = Number.isFinite(opts.visualSide) ? opts.visualSide : tarinaiVisualSide(item, tarinai);
|
||||
if (isOshibyo(item)) {
|
||||
const sprite = String(tarinai?.visibleSpriteId || (tarinai?.rawSpriteId ? tarinai.rawSpriteId() : "") || "");
|
||||
const backSprite = sprite === "back" || sprite === "tarinai12_back" || /(^|_)back$/i.test(sprite);
|
||||
const xFactor = backSprite ? 0.62 : 0.96;
|
||||
const yFactor = backSprite ? 0.28 : 0.50;
|
||||
const angleFactor = backSprite ? 0.32 : 0.48;
|
||||
const distFactor = backSprite ? 0.70 : 0.96;
|
||||
return {
|
||||
visualSide,
|
||||
x: (tarinai?.x || 0) + visualSide * r * xFactor,
|
||||
y: (tarinai?.y || 0) + r * yFactor,
|
||||
angle: visualSide * angleFactor,
|
||||
distance: r * distFactor,
|
||||
offsetY: r * yFactor,
|
||||
};
|
||||
}
|
||||
const dx = Number.isFinite(opts.dx) ? opts.dx : 0;
|
||||
const dy = Number.isFinite(opts.dy) ? opts.dy : -1;
|
||||
const dist = Number.isFinite(opts.distance) ? opts.distance : Math.hypot(dx, dy);
|
||||
const clampFn = global.clamp || ((v, lo, hi) => Math.max(lo, Math.min(hi, v)));
|
||||
return {
|
||||
visualSide,
|
||||
x: (tarinai?.x || 0) + visualSide * r * 0.36,
|
||||
y: (tarinai?.y || 0) - r * 0.04,
|
||||
angle: Math.atan2(dy || -1, dx || visualSide),
|
||||
poseAngle: visualSide * 0.28,
|
||||
distance: clampFn(dist || r * 0.58, r * 0.20, r * 0.74),
|
||||
offsetY: clampFn(dy, -r * 0.74, r * 0.38),
|
||||
};
|
||||
}
|
||||
function applyTarinaiPose(item, tarinai, worldRef = null) {
|
||||
if (!item || !tarinai) return null;
|
||||
const anchor = tarinaiAnchor(item, tarinai);
|
||||
item.pinVisualSide = anchor.visualSide;
|
||||
item.x = anchor.x;
|
||||
item.y = anchor.y;
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
item.spin = isOshibyo(item) ? anchor.angle : (anchor.poseAngle ?? anchor.angle);
|
||||
item.vx = tarinai.vx || 0;
|
||||
item.vy = tarinai.vy || 0;
|
||||
item.deletable = false;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return anchor;
|
||||
}
|
||||
function ballAnchor(item, ball) {
|
||||
const angle = (ball?.spin || 0) + (item?.pinBallOffsetAngle || 0);
|
||||
const distance = Math.max(4, Number(item?.pinBallOffsetDistance || (ball?.r || 18) * 0.55) || (ball?.r || 18) * 0.55);
|
||||
return {
|
||||
angle,
|
||||
distance,
|
||||
visualSide: Math.cos(angle) >= 0 ? 1 : -1,
|
||||
x: (ball?.x || 0) + Math.cos(angle) * distance,
|
||||
y: (ball?.y || 0) + Math.sin(angle) * distance,
|
||||
spin: angle + (isOshibyo(item) ? 0.18 : Math.PI * 0.5),
|
||||
};
|
||||
}
|
||||
function applyBallPose(item, ball, worldRef = null) {
|
||||
if (!item || !ball) return null;
|
||||
const anchor = ballAnchor(item, ball);
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
item.x = anchor.x;
|
||||
item.y = anchor.y;
|
||||
item.vx = ball.vx || 0;
|
||||
item.vy = ball.vy || 0;
|
||||
item.pinVisualSide = anchor.visualSide;
|
||||
item.spin = anchor.spin;
|
||||
item.deletable = false;
|
||||
if (Number.isFinite(ball.spinVelocity)) item.spinVelocity = ball.spinVelocity;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return anchor;
|
||||
}
|
||||
function isAttachedToEntity(pin, entity) {
|
||||
if (!pin || !entity || !isPin(pin)) return false;
|
||||
if (entity.type === "ball") return pin.pinState === "ball_lodged" && pin.pinBallId === entity.id;
|
||||
const isTarinaiEntity = typeof global.Tarinai !== "undefined" && entity instanceof global.Tarinai;
|
||||
return isTarinaiEntity && pin.pinState === "lodged" && pin.pinTargetId === entity.id;
|
||||
}
|
||||
function drawAttachedForEntity(args = {}) {
|
||||
const { renderStack, entity, visibleRect, worldRef, time, drawnIds, isVisible } = args;
|
||||
if (!renderStack || !entity || !drawnIds || typeof isVisible !== "function") return 0;
|
||||
const source = entity.type === "ball" ? (renderStack.ballLodgedPins || []) : (renderStack.lodgedPins || []);
|
||||
let drawn = 0;
|
||||
for (const it of source) {
|
||||
if (!it || drawnIds.has(it.id) || !isAttachedToEntity(it, entity)) continue;
|
||||
if (isVisible(it, visibleRect)) it.draw?.(global.ctx || args.ctx, time ?? worldRef?.time ?? 0, args.lighting || null);
|
||||
drawnIds.add(it.id);
|
||||
drawn += 1;
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
global.TarinaiPinAttachmentSystem = Object.freeze({
|
||||
isPin,
|
||||
tarinaiAnchor,
|
||||
applyTarinaiPose,
|
||||
applyBallPose,
|
||||
drawAttachedForEntity,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
41
js/placement_collision_system.js
Normal 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);
|
||||
179
js/placement_preview_system.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: physics/placement-preview
|
||||
// Single source of truth for placement preview geometry and the boundary part
|
||||
// of placement blocking. Render and actual placement both delegate here so a
|
||||
// green preview means the same footprint the click will try to place.
|
||||
(function (global) {
|
||||
function num(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
function itemRadius(type, fallback = 12) {
|
||||
return typeof global.itemRadiusFor === "function" ? global.itemRadiusFor(type, fallback) : fallback;
|
||||
}
|
||||
function toolTypeFor(worldRef) {
|
||||
return typeof global.toolItemType === "function" ? global.toolItemType(worldRef?.tool || "") : "";
|
||||
}
|
||||
function toolSizeScale(worldRef, type) {
|
||||
return worldRef?.toolSizeScale ? worldRef.toolSizeScale(type) : 1;
|
||||
}
|
||||
function toolSizeName(worldRef, type) {
|
||||
return worldRef?.toolSizeFor ? worldRef.toolSizeFor(type) : (worldRef?.toolSize || "medium");
|
||||
}
|
||||
function isRotatable(type) {
|
||||
return typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type);
|
||||
}
|
||||
function defaultAngle(type) {
|
||||
return typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
|
||||
}
|
||||
function rectOutside(worldRef, rect) {
|
||||
if (!worldRef || !rect) return true;
|
||||
const pad = global.CONFIG?.worldPadding || 30;
|
||||
return num(rect.left) < pad || num(rect.top) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.bottom) > (worldRef.h || 0) - pad;
|
||||
}
|
||||
function circleOutside(worldRef, x, y, radius) {
|
||||
if (!worldRef) return true;
|
||||
const pad = global.CONFIG?.worldPadding || 30;
|
||||
const r = Math.max(0, num(radius));
|
||||
return num(x) - r < pad || num(y) - r < pad || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0) - pad;
|
||||
}
|
||||
function orientedAabb(rect) {
|
||||
if (!rect) return null;
|
||||
if (!rect.oriented) return rect;
|
||||
const reach = Math.max(num(rect.halfW), num(rect.halfH));
|
||||
return { left: num(rect.cx) - reach, right: num(rect.cx) + reach, top: num(rect.cy) - reach, bottom: num(rect.cy) + reach };
|
||||
}
|
||||
function rectsFor(worldRef, item) {
|
||||
return global.TarinaiCollisionFootprints.placementRectsFor(worldRef, item);
|
||||
}
|
||||
function footprintRadius(worldRef, item, rects = null) {
|
||||
return global.TarinaiCollisionFootprints.itemFootprintRadius(worldRef, item, rects);
|
||||
}
|
||||
function ensureMechanicalPreview(item, worldRef) {
|
||||
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return item;
|
||||
const pb = global.TarinaiPhysicsBodySystem;
|
||||
pb?.ensureBody?.(item, worldRef || null, { syncFromLegacy: false });
|
||||
if (item.type === "rotator") {
|
||||
pb?.setScalar?.(item, "motorSpeed", Math.PI * 0.65, "preview");
|
||||
pb?.setScalar?.(item, "motorOn", true, "preview");
|
||||
pb?.setScalar?.(item, "thickness", 12, "preview");
|
||||
pb?.setSegments?.(item, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview");
|
||||
} else if (item.type === "poison_block") {
|
||||
pb?.setScalar?.(item, "thickness", 14, "preview");
|
||||
item.world = worldRef || null;
|
||||
} else if (item.type === "reciprocator") {
|
||||
pb?.setScalar?.(item, "railOn", true, "preview");
|
||||
pb?.setScalar?.(item, "railMotorSpeed", 92, "preview");
|
||||
pb?.setScalar?.(item, "railTravel", 150, "preview");
|
||||
pb?.setScalar?.(item, "railPhase", 0, "preview");
|
||||
pb?.setScalar?.(item, "railAnchorX", item.x, "preview");
|
||||
pb?.setScalar?.(item, "railAnchorY", item.y, "preview");
|
||||
pb?.setScalar?.(item, "thickness", 12, "preview");
|
||||
pb?.setSegments?.(item, [[-78, 0, 78, 0]], "preview");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
function makePreviewItem(worldRef, type, x, y, opts = {}) {
|
||||
if (!worldRef || !type) return null;
|
||||
const scale = opts.sizeScale ?? toolSizeScale(worldRef, type);
|
||||
const sizeName = opts.toolSize ?? toolSizeName(worldRef, type);
|
||||
const r = (opts.r ?? itemRadius(type, 12)) * scale;
|
||||
const item = { type, x: num(x), y: num(y), r, toolSize: sizeName, foodServingScale: scale, dead: false, isPlacementPreview: true };
|
||||
if (isRotatable(type)) item.angle = opts.angle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type));
|
||||
if (type === "reciprocator") {
|
||||
const axis = item.angle ?? defaultAngle(type);
|
||||
global.TarinaiPhysicsBodySystem.setScalar(item, "railAxis", axis, "preview-axis");
|
||||
item.angle = defaultAngle(type);
|
||||
}
|
||||
ensureMechanicalPreview(item, worldRef);
|
||||
return item;
|
||||
}
|
||||
function boundsBlocked(worldRef, item, opts = {}) {
|
||||
if (!worldRef || !item || item.dead) return true;
|
||||
const rects = opts.rects || rectsFor(worldRef, item);
|
||||
if (rects.length) return rects.some(rect => rectOutside(worldRef, orientedAabb(rect)));
|
||||
const type = item.type || "";
|
||||
const radius = opts.radius ?? Math.max(14, (num(item.r, itemRadius(type, 12)) || itemRadius(type, 12)) * (type === "bed" ? 1.55 : 1.25));
|
||||
return circleOutside(worldRef, item.x, item.y, radius);
|
||||
}
|
||||
function clampPointFor(worldRef, item, x = item?.x || 0, y = item?.y || 0) {
|
||||
if (!worldRef || !item) return { x, y };
|
||||
const pad = global.CONFIG?.worldPadding || 30;
|
||||
const rects = rectsFor(worldRef, item);
|
||||
if (rects.length) {
|
||||
let left = 0, right = 0, top = 0, bottom = 0;
|
||||
for (const rect of rects) {
|
||||
const aabb = orientedAabb(rect);
|
||||
if (!aabb) continue;
|
||||
left = Math.max(left, num(item.x) - num(aabb.left));
|
||||
right = Math.max(right, num(aabb.right) - num(item.x));
|
||||
top = Math.max(top, num(item.y) - num(aabb.top));
|
||||
bottom = Math.max(bottom, num(aabb.bottom) - num(item.y));
|
||||
}
|
||||
return { x: global.clamp ? global.clamp(x, pad + left, (worldRef.w || 0) - pad - right) : Math.max(pad + left, Math.min((worldRef.w || 0) - pad - right, x)), y: global.clamp ? global.clamp(y, pad + top, (worldRef.h || 0) - pad - bottom) : Math.max(pad + top, Math.min((worldRef.h || 0) - pad - bottom, y)) };
|
||||
}
|
||||
const radius = Math.max(14, (num(item.r, itemRadius(item.type, 12)) || itemRadius(item.type, 12)) * (item.type === "bed" ? 1.55 : 1.25));
|
||||
return { x: global.clamp ? global.clamp(x, pad + radius, (worldRef.w || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.w || 0) - pad - radius, x)), y: global.clamp ? global.clamp(y, pad + radius, (worldRef.h || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.h || 0) - pad - radius, y)) };
|
||||
}
|
||||
function geometryFor(worldRef, item) {
|
||||
if (!worldRef || !item) return null;
|
||||
ensureMechanicalPreview(item, worldRef);
|
||||
if ((item.type === "fence_v" || item.type === "fence_h" || item.type === "bounce_fence" || item.type === "bounce_fence_v" || item.type === "gate_fence") && worldRef.fenceRect) {
|
||||
return { rect: worldRef.fenceRect(item), rects: [] };
|
||||
}
|
||||
if (item.type === "nest_box" && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] };
|
||||
const rects = rectsFor(worldRef, item);
|
||||
return { rect: null, rects };
|
||||
}
|
||||
function previewForItem(worldRef, item, opts = {}) {
|
||||
if (!worldRef || !item) return null;
|
||||
const geometry = geometryFor(worldRef, item) || {};
|
||||
const rects = geometry.rects || [];
|
||||
const radius = footprintRadius(worldRef, item, rects);
|
||||
const bounds = boundsBlocked(worldRef, item, { rects: rects.length ? rects : (geometry.rect ? [geometry.rect] : null) });
|
||||
return {
|
||||
type: item.type,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
r: item.r || radius,
|
||||
rect: geometry.rect || null,
|
||||
rects,
|
||||
boundsBlocked: bounds,
|
||||
blocked: opts.skipBlocked ? bounds : (bounds || Boolean(worldRef.placementBlocked?.(item) || worldRef.fencePlacementBlocked?.(item))),
|
||||
};
|
||||
}
|
||||
function previewForWorld(worldRef) {
|
||||
const p = worldRef?.pointer;
|
||||
const type = toolTypeFor(worldRef);
|
||||
if (!p?.inside || !type) return null;
|
||||
const x = num(p.x), y = num(p.y);
|
||||
const tmp = makePreviewItem(worldRef, type, x, y);
|
||||
if (!tmp) return null;
|
||||
const r = tmp.r;
|
||||
let extra = {};
|
||||
if (type === "dry_ice" || type === "stove") {
|
||||
const range = Math.max(24, global.CONFIG?.temperatureItemRange ?? 190);
|
||||
extra.influence = { kind: "temperatureEffectRange", shape: "circle", x, y, radius: range, type };
|
||||
}
|
||||
if (type === "grass") {
|
||||
const overlaps = global.TarinaiPhysicsSoftClear.collectForPlacement(worldRef, tmp) || [];
|
||||
const replacingGrass = overlaps.some(it => it?.type === "grass");
|
||||
const limitBlocked = !(worldRef.canAddGrass?.(1) ?? true) && !replacingGrass;
|
||||
const softBlocked = Boolean(worldRef.grassBlockedAt?.(x, y, tmp, { ignoreSoftPlacement: true }) || worldRef.grassOnTarinaiAt?.(x, y));
|
||||
const b = limitBlocked || softBlocked || circleOutside(worldRef, x, y, Math.max(14, r * 1.55));
|
||||
return { type, x, y, r, blocked: b, item: tmp };
|
||||
}
|
||||
const geometry = geometryFor(worldRef, tmp) || {};
|
||||
const rectsForBounds = geometry.rects && geometry.rects.length ? geometry.rects : (geometry.rect ? [geometry.rect] : null);
|
||||
const ownBounds = extra.boundsRadius ? circleOutside(worldRef, x, y, extra.boundsRadius) : boundsBlocked(worldRef, tmp, { rects: rectsForBounds });
|
||||
const blocked = ownBounds || Boolean(worldRef.placementBlocked?.(tmp));
|
||||
return { type, x, y, r, rect: geometry.rect || null, rects: geometry.rects || [], influence: extra.influence || null, blocked, item: tmp };
|
||||
}
|
||||
global.TarinaiPlacementPreviewSystem = Object.freeze({
|
||||
boundsBlocked,
|
||||
clampPointFor,
|
||||
forItem: previewForItem,
|
||||
forWorld: previewForWorld,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -5,16 +5,9 @@
|
|||
constructor(defs = {}) { this.defs = new Map(Object.entries(defs || {})); }
|
||||
key(id = "") { return String(id || ""); }
|
||||
get(id = "") { return this.defs.get(this.key(id)) || null; }
|
||||
has(id = "") { return this.defs.has(this.key(id)); }
|
||||
entries() { return [...this.defs.entries()]; }
|
||||
values() { return [...this.defs.values()]; }
|
||||
label(id = "", fallback = "") { return this.get(id)?.label || fallback || id || ""; }
|
||||
color(id = "", fallback = "rgba(255,242,160,0.85)") { return this.get(id)?.color || fallback; }
|
||||
labels() { return Object.fromEntries(this.entries().map(([id, def]) => [id, def.label])); }
|
||||
colors() { return Object.fromEntries(this.entries().map(([id, def]) => [id, def.color]).filter(([, color]) => color)); }
|
||||
debugSnapshot(mapper = null) {
|
||||
return this.entries().map(([id, def]) => mapper ? mapper(id, def) : ({ id, label: def.label || id }));
|
||||
}
|
||||
}
|
||||
global.TarinaiRegistryBase = TarinaiRegistryBase;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
591
js/render.js
|
|
@ -6,8 +6,8 @@ const toolCursorIconImages = new Map();
|
|||
|
||||
function toolCursorIconDefinition(world) {
|
||||
const tool = String(world?.tool || "");
|
||||
if (!tool || tool === "observe" || tool === "undo" || tool === "redo") return null;
|
||||
const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null;
|
||||
if (!tool || tool === "observe" || tool === "undo" || tool === "redo" || tool === "shoot") return null;
|
||||
const def = toolDefinition(tool);
|
||||
if (!def) return null;
|
||||
return { tool, def, type: def.itemType || (def.placeable ? tool : "") };
|
||||
}
|
||||
|
|
@ -120,6 +120,174 @@ function drawSelectedToolCursorIcon(ctx, world) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
function strokeWorldObstacleRect(ctx, rect) {
|
||||
if (!rect) return false;
|
||||
if (rect.oriented) {
|
||||
ctx.save();
|
||||
ctx.translate(rect.cx || 0, rect.cy || 0);
|
||||
ctx.rotate(rect.angle || 0);
|
||||
ctx.strokeRect(-(rect.halfW || 0), -(rect.halfH || 0), (rect.halfW || 0) * 2, (rect.halfH || 0) * 2);
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
ctx.strokeRect(rect.left || 0, rect.top || 0, Math.max(1, (rect.right || 0) - (rect.left || 0)), Math.max(1, (rect.bottom || 0) - (rect.top || 0)));
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawToolTargetHoverHighlight(ctx, world) {
|
||||
if (!world) return;
|
||||
const tool = String(world.tool || "");
|
||||
const target = tool === "pinch" ? world.hoverGrabTarget : (tool === "delete" ? world.hoverDeleteTarget : null);
|
||||
if (!target || target.dead || target.playerHeld || target._heldByPlayer) return;
|
||||
const p = world.pointer || {};
|
||||
if (!p.inside) return;
|
||||
const style = tool === "delete"
|
||||
? { color: "rgba(224,82,68,0.98)", fill: "rgba(224,82,68,0.13)", label: "\u524a\u9664" }
|
||||
: { color: "rgba(86,142,232,0.98)", fill: "rgba(86,142,232,0.12)", label: "\u3064\u307e\u3080" };
|
||||
const pulse = 0.62 + Math.sin((world.time || 0) * 9.5) * 0.12;
|
||||
const worldLine = world.screenSizeToWorld ? world.screenSizeToWorld(4.6) : 4.6;
|
||||
const outerLine = world.screenSizeToWorld ? world.screenSizeToWorld(2.1) : 2.1;
|
||||
const drawLabel = (cx, cy, ry) => {
|
||||
const labelY = cy - ry - (world.screenSizeToWorld ? world.screenSizeToWorld(14) : 14);
|
||||
const labelW = Math.max(42, (world.screenSizeToWorld ? world.screenSizeToWorld(52) : 52));
|
||||
const labelH = Math.max(18, (world.screenSizeToWorld ? world.screenSizeToWorld(21) : 21));
|
||||
ctx.save();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 0.96;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.94)";
|
||||
ctx.strokeStyle = style.color;
|
||||
ctx.lineWidth = Math.max(1.2, outerLine);
|
||||
if (typeof roundedRect === "function") { roundedRect(ctx, cx - labelW / 2, labelY - labelH / 2, labelW, labelH, labelH / 2); ctx.fill(); ctx.stroke(); }
|
||||
else ctx.strokeRect(cx - labelW / 2, labelY - labelH / 2, labelW, labelH);
|
||||
ctx.fillStyle = style.color;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font = `${Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(12) : 12)}px sans-serif`;
|
||||
ctx.fillText(style.label, cx, labelY + 0.5);
|
||||
ctx.restore();
|
||||
};
|
||||
const strokeEllipse = (cx, cy, rx, ry) => {
|
||||
ctx.fillStyle = style.fill;
|
||||
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.shadowColor = style.color;
|
||||
ctx.shadowBlur = Math.max(12, world.screenSizeToWorld ? world.screenSizeToWorld(18) : 18);
|
||||
ctx.strokeStyle = style.color;
|
||||
ctx.lineWidth = Math.max(3.6, worldLine + pulse * 1.8);
|
||||
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.setLineDash([10, 6]);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.96)";
|
||||
ctx.lineWidth = Math.max(1.6, outerLine);
|
||||
ctx.beginPath(); ctx.ellipse(cx, cy, rx * 1.10, ry * 1.12, 0, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
drawLabel(cx, cy, ry * 1.12);
|
||||
};
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.92;
|
||||
const isTarinaiTarget = typeof Tarinai !== "undefined" && target instanceof Tarinai;
|
||||
if (isTarinaiTarget) {
|
||||
const r = Math.max(17, target.radius || 22);
|
||||
strokeEllipse(target.x || 0, target.y || 0, r * 1.02, r * 0.80);
|
||||
} else if (target.type === "rope" || target.type === "rod") {
|
||||
const runtime = TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime;
|
||||
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
|
||||
const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world);
|
||||
const b = runtime?.endpointWorld?.(pb?.endpoint?.(target, 1), world);
|
||||
if (a && b) {
|
||||
ctx.shadowColor = style.color;
|
||||
ctx.shadowBlur = Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(15) : 15);
|
||||
ctx.strokeStyle = style.color;
|
||||
ctx.lineWidth = Math.max(5, worldLine + 2);
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.setLineDash([10, 6]);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.lineWidth = Math.max(2, outerLine);
|
||||
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
drawLabel((a.x + b.x) / 2, (a.y + b.y) / 2, 20);
|
||||
}
|
||||
} else {
|
||||
const rects = world.solidObstacleRects?.(target) || [];
|
||||
if (rects.length) {
|
||||
ctx.strokeStyle = style.color;
|
||||
ctx.shadowColor = style.color;
|
||||
ctx.shadowBlur = Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(16) : 16);
|
||||
ctx.lineWidth = Math.max(3.2, worldLine);
|
||||
let cx = target.x || 0, cy = target.y || 0, top = cy;
|
||||
for (const rect of rects.slice(0, 14)) {
|
||||
if (rect.oriented) { cx = rect.cx || cx; cy = rect.cy || cy; top = Math.min(top, cy - (rect.halfH || 18)); }
|
||||
strokeWorldObstacleRect(ctx, rect);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.setLineDash([9, 6]);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.96)";
|
||||
ctx.lineWidth = Math.max(1.6, outerLine);
|
||||
for (const rect of rects.slice(0, 14)) strokeWorldObstacleRect(ctx, rect);
|
||||
ctx.setLineDash([]);
|
||||
drawLabel(cx, top, 14);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawShootCursorFallback(ctx, x, y, size) {
|
||||
const half = size * 0.5;
|
||||
const tick = size * 0.16;
|
||||
const gap = size * 0.11;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.strokeStyle = "rgba(94,118,138,0.96)";
|
||||
ctx.lineWidth = Math.max(1.3, size * 0.045);
|
||||
ctx.lineCap = "square";
|
||||
for (const sign of [-1, 1]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, sign * gap);
|
||||
ctx.lineTo(0, sign * (half - 2));
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sign * gap, 0);
|
||||
ctx.lineTo(sign * (half - 2), 0);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-tick * 0.55, sign * (gap + tick));
|
||||
ctx.lineTo(tick * 0.55, sign * (gap + tick));
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sign * (gap + tick), -tick * 0.55);
|
||||
ctx.lineTo(sign * (gap + tick), tick * 0.55);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawShootCursorOverlay(ctx, world) {
|
||||
if (!world || world.tool !== "shoot") return;
|
||||
const aim = world.shootAimPoint?.();
|
||||
if (!aim?.inside) return;
|
||||
const screen = world.worldToScreen ? world.worldToScreen(aim.x, aim.y) : { x: aim.x, y: aim.y };
|
||||
const canvasW = world.viewportW || world.w || ctx.canvas?.width || 1;
|
||||
const canvasH = world.viewportH || world.h || ctx.canvas?.height || 1;
|
||||
const size = Math.max(90, Math.min(160, Math.round(Math.min(canvasW, canvasH) * 0.205)));
|
||||
const def = toolDefinition("shoot");
|
||||
const img = cachedToolCursorIconImage(def?.icon || "");
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.shadowColor = "rgba(255,255,255,0.12)";
|
||||
ctx.shadowBlur = Math.max(2, size * 0.08);
|
||||
if (img && img.complete && (img.naturalWidth || img.width)) {
|
||||
ctx.drawImage(img, screen.x - size / 2, screen.y - size / 2, size, size);
|
||||
} else {
|
||||
drawShootCursorFallback(ctx, screen.x, screen.y, size);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function randSeed(seed, min, max) {
|
||||
const s = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return min + (s - Math.floor(s)) * (max - min);
|
||||
|
|
@ -150,9 +318,8 @@ function getLightingState(targetWorld) {
|
|||
const horizon = Math.cos(progress * Math.PI * 2 - Math.PI / 2);
|
||||
const shadowLength = lerp(1.58, 0.52, elevation);
|
||||
const warmth = clamp(0.28 + goldenStrength * 0.70 + noonStrength * 0.18 - nightStrength * 0.12, 0, 1);
|
||||
const bloom = CONFIG.lightingQuality === "cinematic" ? clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58) : 0;
|
||||
const bloom = clamp(goldenStrength * 0.42 + nightStrength * 0.18 + noonStrength * 0.14, 0, 0.58);
|
||||
return {
|
||||
quality: CONFIG.lightingQuality,
|
||||
progress,
|
||||
light,
|
||||
dawnStrength,
|
||||
|
|
@ -240,94 +407,37 @@ function drawRadialGlow(ctx, x, y, radius, color, alpha) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
|
||||
// \u500b\u5225\u306e\u767a\u5149\u30b0\u30e9\u30c7\u30fc\u30b7\u30e7\u30f3\u306f\u500b\u4f53\u6570\u5897\u52a0\u6642\u306e\u8ca0\u8377\u304c\u9ad8\u3044\u305f\u3081\u3001\u901a\u5e38\u63cf\u753b\u306b\u7d71\u5408\u3059\u308b\u3002
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale = 1) {
|
||||
if (!influence) return;
|
||||
const x = Number(influence.x || 0) || 0;
|
||||
const y = Number(influence.y || 0) || 0;
|
||||
const radius = Math.max(1, Number(influence.radius || 0) || 0);
|
||||
if (!(radius > 0)) return;
|
||||
ctx.save();
|
||||
ctx.setLineDash([10, 7]);
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.globalAlpha = (blocked ? 0.30 : 0.40) * Math.max(0.2, Number(alphaScale || 1) || 1);
|
||||
ctx.setLineDash([12, 9]);
|
||||
ctx.lineWidth = 2.0;
|
||||
const tempRange = influence?.kind === "temperatureEffectRange";
|
||||
const hotRange = tempRange && influence?.type === "stove";
|
||||
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.72)" : (tempRange ? (hotRange ? "rgba(230,112,48,0.80)" : "rgba(74,166,230,0.82)") : "rgba(80,174,222,0.82)");
|
||||
ctx.fillStyle = blocked ? "rgba(232,84,72,0.055)" : (tempRange ? (hotRange ? "rgba(255,138,56,0.075)" : "rgba(74,166,230,0.075)") : "rgba(80,174,222,0.075)");
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([3, 9]);
|
||||
ctx.globalAlpha *= 0.72;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius * 0.55, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function placementPreviewFor(world) {
|
||||
const p = world?.pointer;
|
||||
const type = toolItemType(world?.tool || "");
|
||||
if (!p?.inside || !type) return null;
|
||||
const pad = CONFIG.worldPadding || 30;
|
||||
let x = p.x;
|
||||
let y = p.y;
|
||||
const sizeScale = world?.toolSizeScale ? world.toolSizeScale(type) : 1;
|
||||
const sizeName = world?.toolSizeFor ? world.toolSizeFor(type) : "medium";
|
||||
const r = itemRadiusFor(type, 12) * sizeScale;
|
||||
const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad));
|
||||
const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
|
||||
let blocked = false;
|
||||
let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false };
|
||||
if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0);
|
||||
if (type === "reciprocator") {
|
||||
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
|
||||
pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
|
||||
pb?.setScalar?.(tmp, "railAxis", tmp.angle, "preview-axis");
|
||||
tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0;
|
||||
pb?.syncPoseFromItem?.(tmp);
|
||||
}
|
||||
if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) {
|
||||
const rawRect = world.fenceRect(tmp);
|
||||
return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
|
||||
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
|
||||
pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
|
||||
pb?.setScalar?.(tmp, "motorSpeed", Math.PI * 0.65, "preview");
|
||||
pb?.setScalar?.(tmp, "motorOn", true, "preview");
|
||||
pb?.setScalar?.(tmp, "thickness", 12, "preview");
|
||||
pb?.setSegments?.(tmp, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview");
|
||||
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
|
||||
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
|
||||
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "poison_block" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
|
||||
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
|
||||
// Use the actual default poison-block physics footprint for placement
|
||||
// feedback. Previously it fell through to the generic oval preview, which
|
||||
// did not match the placed shape.
|
||||
pb?.ensureBody?.(tmp, null, { syncFromLegacy: false });
|
||||
tmp.world = null;
|
||||
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
|
||||
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
|
||||
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
|
||||
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
|
||||
pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
|
||||
pb?.setScalar?.(tmp, "railOn", true, "preview");
|
||||
pb?.setScalar?.(tmp, "railMotorSpeed", 92, "preview");
|
||||
pb?.setScalar?.(tmp, "railTravel", 150, "preview");
|
||||
pb?.setScalar?.(tmp, "railPhase", 0, "preview");
|
||||
pb?.setScalar?.(tmp, "railAnchorX", x, "preview");
|
||||
pb?.setScalar?.(tmp, "railAnchorY", y, "preview");
|
||||
pb?.setScalar?.(tmp, "thickness", 12, "preview");
|
||||
pb?.setSegments?.(tmp, [[-78, 0, 78, 0]], "preview");
|
||||
const rects = world.reciprocatorObstacleRects(tmp) || [];
|
||||
const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null;
|
||||
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "nest_box" && world.nestBoxBaseRect) {
|
||||
const rect = world.nestBoxBaseRect(tmp);
|
||||
return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) };
|
||||
}
|
||||
if (type === "grass") {
|
||||
blocked = !(world.canAddGrass?.(1) ?? true) || !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55));
|
||||
}
|
||||
if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp);
|
||||
return { type, x, y, r, blocked };
|
||||
return TARINAI_RENDER_GLOBAL.TarinaiPlacementPreviewSystem.forWorld(world) || null;
|
||||
}
|
||||
|
||||
|
||||
function drawPlacementPreview(ctx, world) {
|
||||
const preview = placementPreviewFor(world);
|
||||
if (!preview) return;
|
||||
|
|
@ -366,7 +476,7 @@ function drawPlacementPreview(ctx, world) {
|
|||
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
|
||||
}
|
||||
}
|
||||
} else if (type === "water_bowl" || type === "stone" || type === "ball" || 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();
|
||||
|
|
@ -396,19 +506,9 @@ function drawOperationToolPreview(ctx, world) {
|
|||
const type = world.copyBuffer.type || "";
|
||||
const item = world.copyPreviewItemAt?.(p.x, p.y);
|
||||
if (item) {
|
||||
const pad = typeof CONFIG !== "undefined" ? (CONFIG.worldPadding ?? 0) : 0;
|
||||
const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad));
|
||||
const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
|
||||
const previewRects = world.solidObstacleRects?.(item) || [];
|
||||
const boundsBlocked = previewRects.length
|
||||
? previewRects.some(rr => rectOutside(rr.oriented ? {
|
||||
left: (rr.cx || p.x) - (rr.halfW || 10) - (rr.halfH || 5),
|
||||
right: (rr.cx || p.x) + (rr.halfW || 10) + (rr.halfH || 5),
|
||||
top: (rr.cy || p.y) - (rr.halfW || 10) - (rr.halfH || 5),
|
||||
bottom: (rr.cy || p.y) + (rr.halfW || 10) + (rr.halfH || 5),
|
||||
} : rr))
|
||||
: circleOutside(p.x, p.y, Math.max(14, (item.r || itemRadiusFor?.(type, 16) || 16) * 1.25));
|
||||
const blocked = boundsBlocked || Boolean(world.placementBlocked?.(item) || world.fencePlacementBlocked?.(item));
|
||||
const placementInfo = TARINAI_RENDER_GLOBAL.TarinaiPlacementPreviewSystem.forItem(world, item) || null;
|
||||
const previewRects = placementInfo?.rects || world.solidObstacleRects?.(item) || [];
|
||||
const blocked = Boolean(placementInfo ? placementInfo.blocked : (world.placementBlocked?.(item) || world.fencePlacementBlocked?.(item)));
|
||||
let copyPreviewDrew = false;
|
||||
if (typeof item.draw === "function") {
|
||||
ctx.save();
|
||||
|
|
@ -476,6 +576,30 @@ function drawOperationToolPreview(ctx, world) {
|
|||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (world?.tool === "water_hose") {
|
||||
const radius = world.waterHoseRadius?.() || 138;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.48;
|
||||
ctx.lineWidth = 2.1;
|
||||
ctx.setLineDash([8, 7]);
|
||||
ctx.strokeStyle = "rgba(72,166,216,0.84)";
|
||||
ctx.fillStyle = "rgba(72,166,216,0.10)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 0.72;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x - 10, p.y);
|
||||
ctx.lineTo(p.x + 10, p.y);
|
||||
ctx.moveTo(p.x, p.y - 10);
|
||||
ctx.lineTo(p.x, p.y + 10);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
if (world?.tool !== "area_delete") return;
|
||||
const radius = world.areaDeleteRadius?.() || 138;
|
||||
ctx.save();
|
||||
|
|
@ -515,10 +639,6 @@ function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawDawnRimLight(ctx, drawW, drawH, lighting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cached static garden layer: expensive soil/decor loops are rendered only when
|
||||
// canvas size, coarse light phase, or weather changes.
|
||||
let activeCtx = null;
|
||||
|
|
@ -531,15 +651,24 @@ const backgroundCache = {
|
|||
key: "",
|
||||
};
|
||||
|
||||
function backgroundTimePhase(world) {
|
||||
const progress = typeof world?.dayProgress === "function" ? world.dayProgress() : (((world?.time || 0) % CONFIG.dayLength) / CONFIG.dayLength);
|
||||
const hour = ((progress % 1) + 1) % 1 * 24;
|
||||
if (hour >= 5 && hour < 10) return "morning";
|
||||
if (hour >= 10 && hour < 17) return "day";
|
||||
if (hour >= 17 && hour < 20) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
function backgroundCacheKey(world, lighting) {
|
||||
const weather = world.weather || "sunny";
|
||||
const field = world.fieldType || "garden";
|
||||
const ground = world.groundType || "soil";
|
||||
const phase = backgroundTimePhase(world);
|
||||
// Keep the heavy garden/sky redraw coarse. Fine light changes are handled by overlays.
|
||||
const lightBucket = Math.round((lighting.light || 0) * 8);
|
||||
const warmthBucket = Math.round((lighting.goldenStrength || 0) * 5);
|
||||
const weatherBucket = Math.floor((world.time || 0) / 8);
|
||||
return `${field}:${ground}:${weather}:${lightBucket}:${warmthBucket}:${weatherBucket}`;
|
||||
return `${field}:${ground}:${weather}:${phase}:${lightBucket}:${warmthBucket}`;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -547,7 +676,6 @@ function backgroundCacheKey(world, lighting) {
|
|||
|
||||
function invalidateRenderCaches(reason = "manual") {
|
||||
if (backgroundCache) backgroundCache.key = "";
|
||||
if (terrainCache) terrainCache.key = "";
|
||||
if (terrainChunkCache) {
|
||||
terrainChunkCache.key = "";
|
||||
terrainChunkCache.chunks?.clear?.();
|
||||
|
|
@ -558,13 +686,6 @@ function invalidateRenderCaches(reason = "manual") {
|
|||
// static visual noise. Rendering them into visible chunks avoids repeating
|
||||
// thousands of small strokes every frame and avoids redrawing the entire field
|
||||
// when a small terrain area changes.
|
||||
const terrainCache = {
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
w: 0,
|
||||
h: 0,
|
||||
key: "",
|
||||
};
|
||||
|
||||
const terrainChunkCache = {
|
||||
size: 256,
|
||||
|
|
@ -580,7 +701,7 @@ function isCachedTerrainItem(it) {
|
|||
}
|
||||
|
||||
function isBehindSpriteLayerItem(it) {
|
||||
return Boolean(it && !it.dead && (it.type === "bed" || it.type === "grass_bed" || it.type === "splat"));
|
||||
return Boolean(it && !it.dead && (it.type === "rope" || it.type === "rod" || it.type === "bed" || it.type === "grass_bed"));
|
||||
}
|
||||
|
||||
function renderLayerSortY(entity) {
|
||||
|
|
@ -592,7 +713,6 @@ function renderLayerSortY(entity) {
|
|||
|
||||
function renderLayerKindRank(entity) {
|
||||
if (!entity) return 0;
|
||||
if (entity.kind === "ant_worker" || entity.kind === "ant_queen") return 1;
|
||||
if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -657,7 +777,7 @@ function redrawTerrainChunk(chunk, worldRef, lighting) {
|
|||
}
|
||||
|
||||
function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) {
|
||||
const end = window.TarinaiPerf?.begin?.("render.terrain") || null;
|
||||
const end = window.TarinaiPerf.begin("render.terrain");
|
||||
try {
|
||||
const key = terrainCacheKey(worldRef, lighting);
|
||||
if (terrainChunkCache.key !== key || worldRef.terrainDirtyGlobal || (worldRef.terrainDirty && !worldRef.terrainDirtyChunks?.size)) {
|
||||
|
|
@ -691,38 +811,6 @@ function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) {
|
|||
}
|
||||
}
|
||||
|
||||
function ensureTerrainCache(worldRef, lighting) {
|
||||
// Compatibility fallback for older callers. The main renderer uses chunks.
|
||||
const w = Math.max(1, Math.floor(worldRef.w || 1));
|
||||
const h = Math.max(1, Math.floor(worldRef.h || 1));
|
||||
const key = `${terrainCacheKey(worldRef, lighting)}:fallback:v${worldRef.terrainVersion || 0}`;
|
||||
if (!terrainCache.canvas) {
|
||||
terrainCache.canvas = document.createElement("canvas");
|
||||
terrainCache.ctx = terrainCache.canvas.getContext("2d");
|
||||
}
|
||||
if (!worldRef.terrainDirty && terrainCache.w === w && terrainCache.h === h && terrainCache.key === key) return terrainCache.canvas;
|
||||
terrainCache.w = w;
|
||||
terrainCache.h = h;
|
||||
terrainCache.key = key;
|
||||
terrainCache.canvas.width = w;
|
||||
terrainCache.canvas.height = h;
|
||||
const c = terrainCache.ctx;
|
||||
c.clearRect(0, 0, w, h);
|
||||
const oldPointer = worldRef.pointer;
|
||||
try {
|
||||
if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false };
|
||||
for (const it of worldRef.items || []) if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting);
|
||||
worldRef.terrainDirty = false;
|
||||
return terrainCache.canvas;
|
||||
} catch (err) {
|
||||
console.error("terrain cache redraw failed", err);
|
||||
terrainCache.key = "";
|
||||
return null;
|
||||
} finally {
|
||||
if (oldPointer) worldRef.pointer = oldPointer;
|
||||
}
|
||||
}
|
||||
|
||||
function visibleWorldRect(worldRef, margin = 160) {
|
||||
const scale = worldRef.viewScale ? worldRef.viewScale() : 1;
|
||||
const vw = (worldRef.viewportW || worldRef.w || 1000) / Math.max(0.01, scale);
|
||||
|
|
@ -740,8 +828,8 @@ function visibleWorldRect(worldRef, margin = 160) {
|
|||
function renderRadiusForEntity(entity) {
|
||||
if (!entity) return 40;
|
||||
if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2);
|
||||
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2);
|
||||
if (entity.type === "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 === "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 === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
|
||||
if (entity.kind === "queen") return 42;
|
||||
|
|
@ -759,15 +847,6 @@ function isEntityVisibleInRect(entity, rect, extra = 0) {
|
|||
return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom;
|
||||
}
|
||||
|
||||
function renderStackSignature(worldRef) {
|
||||
return [
|
||||
(worldRef.items || []).length,
|
||||
(worldRef.tarinai || []).length,
|
||||
(worldRef.ants || []).length,
|
||||
worldRef.terrainVersion || 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function syncCarriedPlushieToOwner(it, worldRef) {
|
||||
if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return false;
|
||||
const owner = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById);
|
||||
|
|
@ -780,65 +859,30 @@ function syncCarriedPlushieToOwner(it, worldRef) {
|
|||
return Math.hypot((it.x || 0) - (ox || 0), (it.y || 0) - (oy || 0)) > 0.25;
|
||||
}
|
||||
|
||||
function ensureWorldRenderStack(worldRef) {
|
||||
if (!worldRef._renderStack) {
|
||||
worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
|
||||
}
|
||||
const stack = worldRef._renderStack;
|
||||
const signature = renderStackSignature(worldRef);
|
||||
if (!worldRef.drawListDirty && stack.signature === signature) return stack;
|
||||
|
||||
stack.backItems.length = 0;
|
||||
stack.layered.length = 0;
|
||||
stack.carriedPlushies.length = 0;
|
||||
stack.lodgedPins.length = 0;
|
||||
|
||||
for (const it of worldRef.items || []) {
|
||||
if (!it || it.dead || isCachedTerrainItem(it)) continue;
|
||||
if (isPinType(it.type) && it.pinState === "lodged") {
|
||||
stack.lodgedPins.push(it);
|
||||
continue;
|
||||
}
|
||||
if (it.isStructure && it.type === "plushie" && it.carriedById) {
|
||||
stack.carriedPlushies.push(it);
|
||||
continue;
|
||||
}
|
||||
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
|
||||
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
|
||||
}
|
||||
for (const t of worldRef.tarinai || []) {
|
||||
if (t && !t.dead) stack.layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
|
||||
}
|
||||
for (const a of worldRef.ants || []) {
|
||||
if (a && !a.dead) stack.layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
|
||||
}
|
||||
|
||||
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
|
||||
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
|
||||
stack.signature = signature;
|
||||
worldRef.drawListDirty = false;
|
||||
return stack;
|
||||
}
|
||||
|
||||
|
||||
function collectVisibleRenderStack(worldRef, visibleRect) {
|
||||
const end = window.TarinaiPerf?.begin?.("render.stackBuild") || null;
|
||||
const end = window.TarinaiPerf.begin("render.stackBuild");
|
||||
try {
|
||||
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
|
||||
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [] };
|
||||
const stack = worldRef._visibleRenderStack;
|
||||
stack.backItems.length = 0;
|
||||
stack.layered.length = 0;
|
||||
stack.carriedPlushies.length = 0;
|
||||
stack.lodgedPins.length = 0;
|
||||
if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0;
|
||||
const seen = new Set();
|
||||
const addItem = (it) => {
|
||||
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
|
||||
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it) || !isEntityVisibleInRect(it, visibleRect)) return;
|
||||
seen.add(it);
|
||||
if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") {
|
||||
if (isPinType(it.type) && it.pinState === "lodged") {
|
||||
stack.lodgedPins.push(it);
|
||||
return;
|
||||
}
|
||||
if (isPinType(it.type) && it.pinState === "ball_lodged") {
|
||||
(stack.ballLodgedPins || (stack.ballLodgedPins = [])).push(it);
|
||||
return;
|
||||
}
|
||||
if (it.isStructure && it.type === "plushie" && it.carriedById) {
|
||||
stack.carriedPlushies.push(it);
|
||||
return;
|
||||
|
|
@ -853,6 +897,8 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
|
|||
};
|
||||
worldRef.ensureSpatial?.("render-visible");
|
||||
if (worldRef.spatial?.nearbyRectInto) {
|
||||
// Pull only visible spatial buckets, then do a render-radius check for
|
||||
// oversized sprites/effects that extend outside their origin cell.
|
||||
const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []);
|
||||
itemScratch.length = 0;
|
||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
|
||||
|
|
@ -1213,7 +1259,7 @@ function drawGardenBed(w, h, lighting) {
|
|||
}
|
||||
|
||||
function drawLightRays(ctx, w, h, lighting) {
|
||||
if (lighting.quality !== "cinematic" || lighting.goldenStrength <= 0.08) return;
|
||||
if (lighting.goldenStrength <= 0.08) return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "screen";
|
||||
const rayAlpha = lighting.goldenStrength * LIGHTING_TUNING.rayAlpha;
|
||||
|
|
@ -1255,9 +1301,7 @@ function drawAtmosphericLighting(ctx, w, h, lighting) {
|
|||
}
|
||||
ctx.restore();
|
||||
|
||||
if (lighting.quality === "cinematic") {
|
||||
drawRadialGlow(ctx, w * 0.5, h * 0.46, Math.max(w, h) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
const vignette = ctx.createRadialGradient(w * 0.5, h * 0.46, Math.min(w, h) * 0.18, w * 0.5, h * 0.48, Math.max(w, h) * 0.76);
|
||||
|
|
@ -1314,7 +1358,7 @@ function weatherLabel(weather) {
|
|||
}
|
||||
|
||||
function renderPerfBegin(label) {
|
||||
return window.TarinaiPerf?.begin?.(label) || null;
|
||||
return window.TarinaiPerf.begin(label);
|
||||
}
|
||||
|
||||
function renderPerfEnd(end) {
|
||||
|
|
@ -1386,9 +1430,9 @@ function render() {
|
|||
|
||||
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
|
||||
const renderStack = collectVisibleRenderStack(world, visibleRect);
|
||||
window.TarinaiPerf?.setMetric?.("render.visibleBackItems", renderStack.backItems.length);
|
||||
window.TarinaiPerf?.setMetric?.("render.visibleLayered", renderStack.layered.length);
|
||||
window.TarinaiPerf?.setMetric?.("render.visibleEffects", (world.effects || []).length);
|
||||
window.TarinaiPerf.setMetric("render.visibleBackItems", renderStack.backItems.length);
|
||||
window.TarinaiPerf.setMetric("render.visibleLayered", renderStack.layered.length);
|
||||
window.TarinaiPerf.setMetric("render.visibleEffects", (world.effects || []).length);
|
||||
|
||||
const endBackItems = renderPerfBegin("render.draw.backItems");
|
||||
for (const it of renderStack.backItems) {
|
||||
|
|
@ -1398,9 +1442,37 @@ function render() {
|
|||
|
||||
// Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai.
|
||||
|
||||
const drawnAttachedPinIds = new Set();
|
||||
const drawAttachedPinsForEntity = (entity) => {
|
||||
if (!entity) return;
|
||||
if (TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem.drawAttachedForEntity) {
|
||||
TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem.drawAttachedForEntity({ renderStack, entity, visibleRect, worldRef: world, time: world.time, drawnIds: drawnAttachedPinIds, isVisible: isEntityVisibleInRect, ctx, lighting });
|
||||
return;
|
||||
}
|
||||
const isBall = entity.type === "ball";
|
||||
const isTarinaiEntity = typeof Tarinai !== "undefined" && entity instanceof Tarinai;
|
||||
if (isBall) {
|
||||
for (const it of renderStack.ballLodgedPins || []) {
|
||||
if (!it || drawnAttachedPinIds.has(it.id) || it.pinBallId !== entity.id) continue;
|
||||
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
drawnAttachedPinIds.add(it.id);
|
||||
}
|
||||
}
|
||||
if (isTarinaiEntity) {
|
||||
for (const it of renderStack.lodgedPins || []) {
|
||||
if (!it || drawnAttachedPinIds.has(it.id) || it.pinTargetId !== entity.id) continue;
|
||||
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
drawnAttachedPinIds.add(it.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const endLayered = renderPerfBegin("render.draw.layered");
|
||||
for (const entry of renderStack.layered) {
|
||||
if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting);
|
||||
if (isEntityVisibleInRect(entry.entity, visibleRect)) {
|
||||
entry.entity.draw(ctx, world.time, lighting);
|
||||
drawAttachedPinsForEntity(entry.entity);
|
||||
}
|
||||
}
|
||||
renderPerfEnd(endLayered);
|
||||
|
||||
|
|
@ -1409,11 +1481,20 @@ function render() {
|
|||
syncCarriedPlushieToOwner(it, world);
|
||||
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
}
|
||||
for (const it of renderStack.lodgedPins) {
|
||||
for (const it of renderStack.ballLodgedPins || []) {
|
||||
if (!it || drawnAttachedPinIds.has(it.id)) continue;
|
||||
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
drawnAttachedPinIds.add(it.id);
|
||||
}
|
||||
for (const it of renderStack.lodgedPins) {
|
||||
if (!it || drawnAttachedPinIds.has(it.id)) continue;
|
||||
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
drawnAttachedPinIds.add(it.id);
|
||||
}
|
||||
renderPerfEnd(endAttachments);
|
||||
|
||||
drawToolTargetHoverHighlight(ctx, world);
|
||||
|
||||
const endEffects = renderPerfBegin("render.draw.effects");
|
||||
for (const ef of world.effects) {
|
||||
if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
|
||||
|
|
@ -1427,46 +1508,34 @@ function render() {
|
|||
if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
|
||||
renderPerfEnd(endLighting);
|
||||
|
||||
const drawAtScreenPosition = (entity, drawFn) => {
|
||||
if (!entity) return;
|
||||
const ox = entity.x;
|
||||
const oy = entity.y;
|
||||
const screen = world.worldToScreen ? world.worldToScreen(ox, oy) : { x: ox, y: oy };
|
||||
entity.x = screen.x;
|
||||
entity.y = screen.y;
|
||||
try {
|
||||
drawFn();
|
||||
} finally {
|
||||
entity.x = ox;
|
||||
entity.y = oy;
|
||||
}
|
||||
};
|
||||
const endUiOverlay = renderPerfBegin("render.uiOverlay");
|
||||
drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
|
||||
drawPointerItemTooltip(ctx, world);
|
||||
drawShootCursorOverlay(ctx, world);
|
||||
drawSelectedToolCursorIcon(ctx, world);
|
||||
renderPerfEnd(endUiOverlay);
|
||||
|
||||
const endHud = renderPerfBegin("render.hud");
|
||||
// Time HUD
|
||||
ctx.save();
|
||||
const hudW = 150;
|
||||
const hudH = 56;
|
||||
const hudW = 126;
|
||||
const hudH = 50;
|
||||
const w = screenW, h = screenH;
|
||||
const hudX = w - hudW - 22;
|
||||
const hudY = 16;
|
||||
ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)";
|
||||
roundedRect(ctx, hudX, hudY, hudW, hudH, 18);
|
||||
roundedRect(ctx, hudX, hudY, hudW, hudH, 14);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = light > 0.42 ? "rgba(118,103,83,0.12)" : "rgba(206,213,242,0.15)";
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#2a241d";
|
||||
ctx.font = "bold 16px ui-rounded, sans-serif";
|
||||
ctx.font = "bold 13px ui-rounded, sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`${world.day}\u65e5\u76ee`, hudX + 24, hudY + 25);
|
||||
ctx.font = "12px ui-rounded, sans-serif";
|
||||
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)";
|
||||
ctx.fillText(`${world.phaseName()} / ${world.clockString()} / ${weatherLabel(world.weather)}`, hudX + 24, hudY + 45);
|
||||
const hudTemp = world.currentTemperature ?? world.updateTemperature?.(0) ?? 15;
|
||||
ctx.fillText(`${world.phaseName()} ${weatherLabel(world.weather)} ${Math.round(hudTemp)}℃`, hudX + 10, hudY + 38);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
|
|
@ -1490,18 +1559,18 @@ function drawPointerItemTooltip(ctx, world) {
|
|||
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}…`;
|
||||
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];
|
||||
if (s.includes("、")) {
|
||||
const parts = s.split("、");
|
||||
if (s.includes("\u3001")) {
|
||||
const parts = s.split("\u3001");
|
||||
const out = [];
|
||||
let line = "";
|
||||
for (const part of parts) {
|
||||
const candidate = line ? `${line}、${part}` : part;
|
||||
const candidate = line ? `${line}\u3001${part}` : part;
|
||||
if (ctx.measureText(candidate).width <= maxW) line = candidate;
|
||||
else {
|
||||
if (line) out.push(line);
|
||||
|
|
@ -1515,7 +1584,7 @@ function drawPointerItemTooltip(ctx, world) {
|
|||
};
|
||||
let lines = [];
|
||||
for (const line of rawLines) lines.push(...wrapLine(line, maxTextW));
|
||||
if (lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("、"), maxTextW)];
|
||||
if (lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("\u3001"), maxTextW)];
|
||||
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
|
||||
const lineH = 17;
|
||||
const w = Math.min(280, Math.max(92, textW + 22));
|
||||
|
|
@ -1534,39 +1603,25 @@ function drawPointerItemTooltip(ctx, world) {
|
|||
ctx.fillStyle = "#fff8e6";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
lines.forEach((line, i) => ctx.fillText(line, x + 11, y + 11 + lineH * i + lineH / 2));
|
||||
const separator = info?.kind === "tarinai";
|
||||
lines.forEach((line, i) => {
|
||||
ctx.fillText(line, x + 11, y + 11 + lineH * i + lineH / 2);
|
||||
if (separator && i < lines.length - 1) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.38;
|
||||
ctx.strokeStyle = "rgba(255,248,220,0.62)";
|
||||
ctx.lineWidth = 0.8;
|
||||
const sy = y + 11 + lineH * (i + 1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 10, sy);
|
||||
ctx.lineTo(x + w - 10, sy);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSelectedCard(ctx, world, lighting) {
|
||||
const t = world.selected;
|
||||
if (!t || t.dead || !world.tarinai.includes(t)) return;
|
||||
const w = world.viewportW || world.w;
|
||||
const h = world.viewportH || world.h;
|
||||
const cardW = 152;
|
||||
const cardH = 80;
|
||||
let x = t.x + 18;
|
||||
let y = t.y - 100;
|
||||
x = clamp(x, 10, w - cardW - 10);
|
||||
y = clamp(y, 10, h - cardH - 10);
|
||||
ctx.save();
|
||||
ctx.fillStyle = lighting.light > 0.42 ? "rgba(255,250,239,0.82)" : "rgba(239,243,255,0.74)";
|
||||
roundedRect(ctx, x, y, cardW, cardH, 14);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(84,68,48,0.12)";
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "rgba(42,36,29,0.86)";
|
||||
ctx.font = "bold 11px ui-rounded, sans-serif";
|
||||
ctx.fillText(t.name, x + 10, y + 18);
|
||||
ctx.font = "10px ui-rounded, sans-serif";
|
||||
const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : "";
|
||||
const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : "";
|
||||
ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35);
|
||||
const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : "";
|
||||
ctx.fillText(`\u7dcf\u5408\u7684\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}/${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, x + 10, y + 52);
|
||||
ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function roundedRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
|
|
@ -1579,4 +1634,4 @@ function roundedRect(ctx, x, y, w, h, r) {
|
|||
}
|
||||
|
||||
|
||||
window.TarinaiRender = { invalidateRenderCaches, randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, fillScreenFallback, drawSelectedCard, render, roundedRect };
|
||||
window.TarinaiRender = { invalidateRenderCaches };
|
||||
|
|
|
|||
|
|
@ -15,20 +15,15 @@
|
|||
if (typeof syncTopButtons === "function") syncTopButtons();
|
||||
}
|
||||
|
||||
function syncRestoreDependents(worldRef = global.world, context = {}) {
|
||||
worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" });
|
||||
function syncRestoreDependents(worldRef = global.world) {
|
||||
renderWorldAfterRestore(worldRef);
|
||||
}
|
||||
|
||||
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
|
||||
const result = Snapshot.restoreSnapshot(snapshot, worldRef);
|
||||
if (options.syncUi !== false) syncRestoreDependents(worldRef, { source: options.source || "snapshot", snapshotVersion: result?.snapshotVersion || Snapshot.version });
|
||||
if (options.syncUi !== false) syncRestoreDependents(worldRef);
|
||||
return result;
|
||||
}
|
||||
|
||||
global.TarinaiRestoreCoordinator = {
|
||||
restoreSnapshot,
|
||||
syncRestoreDependents,
|
||||
renderWorldAfterRestore,
|
||||
};
|
||||
global.TarinaiRestoreCoordinator = { restoreSnapshot };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
255
js/save_codec.js
|
|
@ -5,14 +5,12 @@
|
|||
const SaveSchema = global.TarinaiSaveSchema;
|
||||
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
|
||||
if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for save_codec.js");
|
||||
const EXPORT_PREFIX = "たり";
|
||||
const RAW_CODEC = "生";
|
||||
const DEFLATE_CODEC = "縮";
|
||||
const EXPORT_PREFIX = "\u305f\u308a";
|
||||
const RAW_CODEC = "\u751f";
|
||||
const DEFLATE_CODEC = "\u7e2e";
|
||||
const {
|
||||
BINARY_SCHEMA_VERSION,
|
||||
FIELD_IDS,
|
||||
GROUND_IDS,
|
||||
ITEM_TYPE_IDS,
|
||||
STRUCTURE_TYPE_IDS,
|
||||
SERVING_FOOD_TYPE_IDS,
|
||||
PIN_TYPE_IDS,
|
||||
|
|
@ -30,10 +28,10 @@
|
|||
const addRange = (from, to) => {
|
||||
for (let code = from; code <= to && chars.length < SAVE_TEXT_BASE; code++) chars.push(String.fromCharCode(code));
|
||||
};
|
||||
// 正規化で分解されにくい仮名とCJK統合漢字だけを使う。
|
||||
addRange(0x3041, 0x3096); // ひらがな
|
||||
addRange(0x30A1, 0x30FA); // カタカナ
|
||||
addRange(0x4E00, 0x9FFF); // 常用漢字を含むCJK統合漢字。必要数に達したら停止。
|
||||
// \u6b63\u898f\u5316\u3067\u5206\u89e3\u3055\u308c\u306b\u304f\u3044\u4eee\u540d\u3068CJK\u7d71\u5408\u6f22\u5b57\u3060\u3051\u3092\u4f7f\u3046\u3002
|
||||
addRange(0x3041, 0x3096); // \u3072\u3089\u304c\u306a
|
||||
addRange(0x30A1, 0x30FA); // \u30ab\u30bf\u30ab\u30ca
|
||||
addRange(0x4E00, 0x9FFF); // \u5e38\u7528\u6f22\u5b57\u3092\u542b\u3080CJK\u7d71\u5408\u6f22\u5b57\u3002\u5fc5\u8981\u6570\u306b\u9054\u3057\u305f\u3089\u505c\u6b62\u3002
|
||||
return chars.join("");
|
||||
})();
|
||||
const SAVE_TEXT_REVERSE = (() => {
|
||||
|
|
@ -232,68 +230,6 @@
|
|||
return readBitFields(reader, Array.from({ length: count }, () => bits)).map(v => v * Math.max(1, step));
|
||||
}
|
||||
|
||||
function makeProfile(snapshot = {}) {
|
||||
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
||||
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
|
||||
return {
|
||||
format: "TJ1",
|
||||
schema: BINARY_SCHEMA_VERSION,
|
||||
alphabetSize: SAVE_TEXT_BASE,
|
||||
counts: { tarinai: tarinaiRows.length, items: itemRows.length },
|
||||
sections: {},
|
||||
itemTypes: {},
|
||||
compression: {},
|
||||
};
|
||||
}
|
||||
|
||||
function addProfileBytes(profile, key, bytes = 0) {
|
||||
if (!profile || !key) return;
|
||||
const n = Math.max(0, Number(bytes) || 0);
|
||||
profile.sections[key] = (profile.sections[key] || 0) + n;
|
||||
}
|
||||
|
||||
function trackBytes(writer, profile, key, fn) {
|
||||
if (!profile || !writer) return fn();
|
||||
const start = writer.bytes.length;
|
||||
const result = fn();
|
||||
addProfileBytes(profile, key, writer.bytes.length - start);
|
||||
return result;
|
||||
}
|
||||
|
||||
function addItemTypeProfile(profile, typeId, bytes = 0) {
|
||||
if (!profile) return;
|
||||
const type = ITEM_TYPE_IDS[typeId] || `type_${typeId}`;
|
||||
const entry = profile.itemTypes[type] || { count: 0, bytes: 0 };
|
||||
entry.count += 1;
|
||||
entry.bytes += Math.max(0, Number(bytes) || 0);
|
||||
profile.itemTypes[type] = entry;
|
||||
}
|
||||
|
||||
function finalizeProfile(profile, rawBytes, packedBytes, text, codec = "R") {
|
||||
if (!profile) return null;
|
||||
const raw = rawBytes?.length || 0;
|
||||
const packed = packedBytes?.length || 0;
|
||||
const payloadChars = Math.max(0, String(text || "").length - EXPORT_PREFIX.length - 1);
|
||||
profile.sections.totalRaw = raw;
|
||||
profile.compression = {
|
||||
codec,
|
||||
rawBytes: raw,
|
||||
packedBytes: packed,
|
||||
packedDelta: raw - packed,
|
||||
ratio: raw > 0 ? packed / raw : 0,
|
||||
prefixChars: EXPORT_PREFIX.length + 1,
|
||||
payloadChars,
|
||||
totalChars: String(text || "").length,
|
||||
alphabetSize: SAVE_TEXT_BASE,
|
||||
containsBacktick: false,
|
||||
japaneseText: true,
|
||||
bitsPerChar: SAVE_TEXT_BITS,
|
||||
asciiOnly: false,
|
||||
};
|
||||
return profile;
|
||||
}
|
||||
|
||||
|
||||
async function compressBytes(bytes) {
|
||||
if (typeof CompressionStream === "function") {
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
|
||||
|
|
@ -301,7 +237,7 @@
|
|||
}
|
||||
return bytes;
|
||||
}
|
||||
async function decompressBytes(bytes, codec = "R") {
|
||||
async function decompressBytes(bytes, codec) {
|
||||
if (codec === DEFLATE_CODEC) {
|
||||
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
|
|
@ -311,14 +247,6 @@
|
|||
return bytes;
|
||||
}
|
||||
|
||||
function writeFixed(writer, arr = [], count = 0, signed = true) {
|
||||
for (let i = 0; i < count; i++) signed ? writer.s(arr[i] || 0) : writer.u(arr[i] || 0);
|
||||
}
|
||||
function readFixed(reader, count = 0, signed = true) {
|
||||
const out = [];
|
||||
for (let i = 0; i < count; i++) out.push(signed ? reader.s() : reader.u());
|
||||
return out;
|
||||
}
|
||||
function writeFixedPacked(writer, arr = [], count = 0, bits = 8, step = 1) {
|
||||
writeQuantizedBitFields(writer, arr, count, bits, step);
|
||||
}
|
||||
|
|
@ -360,56 +288,7 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function generatedNameCode(value = "") {
|
||||
const name = String(value || "");
|
||||
if (!name.startsWith("は")) return -1;
|
||||
const stop = name.endsWith("っ");
|
||||
const core = name.slice(1, stop ? -1 : undefined);
|
||||
if (core.length > 6) return -1;
|
||||
let bits = 0;
|
||||
for (let i = 0; i < core.length; i++) {
|
||||
const ch = core.charAt(i);
|
||||
if (ch !== "う" && ch !== "ぅ") return -1;
|
||||
if (ch === "ぅ") bits |= (1 << i);
|
||||
}
|
||||
return core.length | (stop ? 8 : 0) | (bits << 4);
|
||||
}
|
||||
function nameFromGeneratedCode(code = 0) {
|
||||
const middleLen = code & 7;
|
||||
const stop = !!(code & 8);
|
||||
const bits = code >> 4;
|
||||
let name = "は";
|
||||
for (let i = 0; i < middleLen; i++) name += (bits & (1 << i)) ? "ぅ" : "う";
|
||||
if (stop) name += "っ";
|
||||
return name;
|
||||
}
|
||||
function writeName(writer, value = "") {
|
||||
const code = generatedNameCode(value);
|
||||
if (code >= 0) {
|
||||
writer.u(code + 1);
|
||||
return;
|
||||
}
|
||||
writer.u(0);
|
||||
writer.str(value || "");
|
||||
}
|
||||
function readName(reader) {
|
||||
const code = reader.u();
|
||||
if (code > 0) return nameFromGeneratedCode(code - 1);
|
||||
return reader.str();
|
||||
}
|
||||
function valuesDiffer(a, b) { return (Number(a) || 0) !== (Number(b) || 0); }
|
||||
function writeFixedDiff(writer, base = [], current = [], count = 0, signed = false) {
|
||||
let mask = 0;
|
||||
for (let i = 0; i < count; i++) if (valuesDiffer(current?.[i], base?.[i])) mask |= (1 << i);
|
||||
writer.u(mask);
|
||||
for (let i = 0; i < count; i++) if (mask & (1 << i)) signed ? writer.s(current[i] || 0) : writer.u(current[i] || 0);
|
||||
}
|
||||
function readFixedDiff(reader, base = [], count = 0, signed = false) {
|
||||
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
|
||||
const mask = reader.u();
|
||||
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = signed ? reader.s() : reader.u();
|
||||
return out;
|
||||
}
|
||||
function writeTimers(writer, timers = []) {
|
||||
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
|
||||
let mask = 0;
|
||||
|
|
@ -467,20 +346,21 @@
|
|||
}
|
||||
|
||||
function seedPersonalityArray(seed = "", flags = 0) {
|
||||
// ずんちどれいは誕生時性格も全軸 -1 なので、差分基準も0固定にする。
|
||||
// \u305a\u3093\u3061\u3069\u308c\u3044\u306f\u8a95\u751f\u6642\u6027\u683c\u3082\u5168\u8ef8 -1 \u306a\u306e\u3067\u3001\u5dee\u5206\u57fa\u6e96\u30820\u56fa\u5b9a\u306b\u3059\u308b\u3002
|
||||
if ((Number(flags) || 0) & (1 << 5)) return [0, 0, 0, 0];
|
||||
return global.TarinaiSeedFactory?.personalityArray?.(seed) || [100, 100, 100, 100];
|
||||
return global.TarinaiSeedFactory.personalityArray(seed) || [100, 100, 100, 100];
|
||||
}
|
||||
|
||||
function writeTarinai(writer, row = [], profile = null, coordState = null) {
|
||||
function writeTarinai(writer, row = [], coordState = null) {
|
||||
const pinIdx = row[15] ?? -1;
|
||||
const nestIdx = row[16] ?? -1;
|
||||
const stateId = row[17]?.[0] || 0;
|
||||
const stateTarget = row[17]?.[1] ?? -1;
|
||||
const genetics = Array.isArray(row[18]) ? row[18] : [];
|
||||
const fightStats = Array.isArray(row[19]) ? row[19] : [];
|
||||
const birthSeed = row[1] || "";
|
||||
trackBytes(writer, profile, "tarinai.flags", () => writeBitFields(writer, [row[0] || 0], [10]));
|
||||
trackBytes(writer, profile, "tarinai.seed", () => writer.str(birthSeed));
|
||||
trackBytes(writer, profile, "tarinai.core", () => {
|
||||
writeBitFields(writer, [row[0] || 0], [10]);
|
||||
writer.str(birthSeed);
|
||||
const x = toSafeInt(row[2] || 0);
|
||||
const y = toSafeInt(row[3] || 0);
|
||||
writer.s(x - (coordState?.x || 0));
|
||||
|
|
@ -489,27 +369,24 @@
|
|||
writer.u(row[4] || 0);
|
||||
writer.u(row[5] || 1);
|
||||
writeBitFields(writer, [row[6] || 0, row[7] || 0, row[8] || 0], [8, 8, 8]);
|
||||
});
|
||||
trackBytes(writer, profile, "tarinai.needs", () => writeFixedPacked(writer, row[9], 6, 6, 2));
|
||||
trackBytes(writer, profile, "tarinai.traits", () => {
|
||||
writeFixedPacked(writer, row[9], 6, 6, 2);
|
||||
writeFixedDiffPacked(writer, seedPersonalityArray(birthSeed, row[0] || 0), row[10], 4, 8);
|
||||
});
|
||||
trackBytes(writer, profile, "tarinai.family", () => {
|
||||
writeIndexList(writer, row[11]);
|
||||
});
|
||||
trackBytes(writer, profile, "tarinai.relationships", () => writeRelationships(writer, row[12]));
|
||||
trackBytes(writer, profile, "tarinai.timers", () => writeTimers(writer, row[13]));
|
||||
trackBytes(writer, profile, "tarinai.modes", () => writeModes(writer, row[14]));
|
||||
trackBytes(writer, profile, "tarinai.tail", () => {
|
||||
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;
|
||||
writeBitFields(writer, [tailMask], [3]);
|
||||
if (genetics.length) tailMask |= 8;
|
||||
if (fightStats.length) tailMask |= 16;
|
||||
writeBitFields(writer, [tailMask], [5]);
|
||||
if (tailMask & 1) writer.u(pinIdx);
|
||||
if (tailMask & 2) writer.u(nestIdx);
|
||||
if (tailMask & 4) { writeBitFields(writer, [stateId], [3]); writer.s(stateTarget); }
|
||||
});
|
||||
if (tailMask & 4) { writeBitFields(writer, [stateId], [1]); writer.s(stateTarget); }
|
||||
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];
|
||||
|
|
@ -526,11 +403,13 @@
|
|||
const relationships = readRelationships(reader);
|
||||
const timers = readTimers(reader);
|
||||
const modes = readModes(reader);
|
||||
const [tailMask] = readBitFields(reader, [3]);
|
||||
const [tailMask] = readBitFields(reader, [5]);
|
||||
const pinIdx = (tailMask & 1) ? reader.u() : -1;
|
||||
const nestIdx = (tailMask & 2) ? reader.u() : -1;
|
||||
const stateInfo = (tailMask & 4) ? [readBitFields(reader, [3])[0], reader.s()] : [0, -1];
|
||||
return [flags, birthSeed, x, y, age, generation, hunger, energy, sleepPressure, needs, current, parents, relationships, timers, modes, pinIdx, nestIdx, stateInfo];
|
||||
const stateInfo = (tailMask & 4) ? [readBitFields(reader, [1])[0], reader.s()] : [0, -1];
|
||||
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 = []) {
|
||||
|
|
@ -544,6 +423,9 @@
|
|||
writer.str(extra[0] || "");
|
||||
} else if (typeId === 6) { // duplicator
|
||||
writer.s(extra[0] ?? -1);
|
||||
} else if (typeId === 42) { // robot_cleaner
|
||||
writer.u(Math.max(0, Math.min(0x3f, Number(extra[0] ?? 3) | 0)));
|
||||
writer.u(extra[1] ? 1 : 0);
|
||||
} else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
|
||||
writer.str(JSON.stringify(extra || []));
|
||||
} else if (typeId === 21) { // ball
|
||||
|
|
@ -568,6 +450,7 @@
|
|||
if (typeId === 1) return [reader.u(), reader.s(), reader.s()];
|
||||
if (typeId === 22) return [reader.str()];
|
||||
if (typeId === 6) return [reader.s()];
|
||||
if (typeId === 42) return [reader.u(), reader.u()];
|
||||
if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
|
||||
try {
|
||||
const parsed = JSON.parse(reader.str() || "[]");
|
||||
|
|
@ -595,18 +478,14 @@
|
|||
for (let i = 0; i < 16; i++) out += (((x >> i) & 1) * (2 ** (2 * i))) + (((y >> i) & 1) * (2 ** (2 * i + 1)));
|
||||
return out;
|
||||
}
|
||||
function writeItemPayload(writer, row = [], typeId = 0, coordState = null, profile = null) {
|
||||
const start = writer.bytes.length;
|
||||
trackBytes(writer, profile, "items.core", () => {
|
||||
function writeItemPayload(writer, row = [], typeId = 0, coordState = null) {
|
||||
const x = toSafeInt(row[1] || 0);
|
||||
const y = toSafeInt(row[2] || 0);
|
||||
writer.s(x - (coordState?.x || 0));
|
||||
writer.s(y - (coordState?.y || 0));
|
||||
if (coordState) { coordState.x = x; coordState.y = y; }
|
||||
if (commonItemAmountNeeded(typeId)) writer.s(row[3] || 0);
|
||||
});
|
||||
trackBytes(writer, profile, "items.extra", () => writeItemExtra(writer, typeId, row[4] || []));
|
||||
addItemTypeProfile(profile, typeId, writer.bytes.length - start);
|
||||
writeItemExtra(writer, typeId, row[4] || []);
|
||||
}
|
||||
function readItemPayload(reader, typeId = 0, coordState = null) {
|
||||
const x = (coordState?.x || 0) + reader.s();
|
||||
|
|
@ -633,14 +512,14 @@
|
|||
}
|
||||
return blocks;
|
||||
}
|
||||
function writeItemBlocks(writer, rows = [], profile = null) {
|
||||
function writeItemBlocks(writer, rows = []) {
|
||||
const blocks = itemBlocksFromRows(rows);
|
||||
writer.u(blocks.length);
|
||||
for (const [typeId, list] of blocks) {
|
||||
writer.u(typeId);
|
||||
writer.u(list.length);
|
||||
const coordState = { x: 0, y: 0 };
|
||||
for (const row of list) writeItemPayload(writer, row, typeId, coordState, profile);
|
||||
for (const row of list) writeItemPayload(writer, row, typeId, coordState);
|
||||
}
|
||||
}
|
||||
function readItemBlocks(reader) {
|
||||
|
|
@ -656,45 +535,33 @@
|
|||
}
|
||||
|
||||
function fieldValue(index = 0) { return FIELD_IDS[Number(index) | 0] || "garden"; }
|
||||
function groundValue(index = 0) { return GROUND_IDS[Number(index) | 0] || "soil"; }
|
||||
function summaryFromPayload(w = [], tarinaiCount = 0, itemCount = 0) {
|
||||
function summaryFromPayload(w = [], tarinaiCount = 0) {
|
||||
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
||||
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
||||
const totalTime = (Number(w[3]) || 0) / 10;
|
||||
const day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
|
||||
const time10 = Math.round((totalTime - (day - 1) * dayLength) * 10);
|
||||
return [day, tarinaiCount, fieldValue(w[0]), time10, itemCount, groundValue(w[1])];
|
||||
return [day, tarinaiCount, fieldValue(w[0])];
|
||||
}
|
||||
|
||||
function encodeBinarySnapshot(snapshot, options = {}) {
|
||||
function encodeBinarySnapshot(snapshot) {
|
||||
if (!snapshot || snapshot.v !== Snapshot.version || snapshot.a !== "tj1") throw new Error("snapshot version mismatch");
|
||||
const profile = options?.profile ? makeProfile(snapshot) : null;
|
||||
const writer = new BinaryWriter();
|
||||
trackBytes(writer, profile, "header", () => {
|
||||
writer.b(BINARY_SCHEMA_VERSION);
|
||||
writer.u(Snapshot.version);
|
||||
});
|
||||
trackBytes(writer, profile, "world", () => writeWorld(writer, snapshot.w || []));
|
||||
writeWorld(writer, snapshot.w || []);
|
||||
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
||||
trackBytes(writer, profile, "tarinai.count", () => writer.u(tarinaiRows.length));
|
||||
const tarinaiStart = writer.bytes.length;
|
||||
writer.u(tarinaiRows.length);
|
||||
const tarCoordState = { x: 0, y: 0 };
|
||||
for (const row of tarinaiRows) writeTarinai(writer, row || [], profile, tarCoordState);
|
||||
addProfileBytes(profile, "tarinai.total", writer.bytes.length - tarinaiStart);
|
||||
for (const row of tarinaiRows) writeTarinai(writer, row || [], tarCoordState);
|
||||
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
|
||||
const itemStart = writer.bytes.length;
|
||||
trackBytes(writer, profile, "items.blocks", () => writeItemBlocks(writer, itemRows, profile));
|
||||
addProfileBytes(profile, "items.total", writer.bytes.length - itemStart);
|
||||
const bytes = writer.out();
|
||||
if (profile) return { bytes, profile };
|
||||
return bytes;
|
||||
writeItemBlocks(writer, itemRows);
|
||||
return writer.out();
|
||||
}
|
||||
|
||||
function decodeBinarySnapshot(bytes) {
|
||||
const reader = new BinaryReader(bytes);
|
||||
const schema = reader.b();
|
||||
if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
|
||||
reader.schemaVersion = schema;
|
||||
const snapshotVersion = reader.u();
|
||||
if (snapshotVersion !== Snapshot.version) throw new Error("unsupported binary save version");
|
||||
const w = readWorld(reader);
|
||||
|
|
@ -703,9 +570,8 @@
|
|||
const tarCoordState = { x: 0, y: 0 };
|
||||
for (let i = 0; i < tCount; i++) t.push(readTarinai(reader, tarCoordState));
|
||||
const items = readItemBlocks(reader);
|
||||
const itemCount = items.length;
|
||||
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
|
||||
return { v: Snapshot.version, a: "tj1", c: Date.now(), m: summaryFromPayload(w, t.length, items.length), w, t, i: items };
|
||||
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items };
|
||||
}
|
||||
|
||||
async function packBytesForSave(bytes) {
|
||||
|
|
@ -718,18 +584,10 @@
|
|||
return { codec, packed };
|
||||
}
|
||||
|
||||
async function encodeSnapshotWithProfile(snapshot) {
|
||||
const encoded = encodeBinarySnapshot(snapshot, { profile: true });
|
||||
const bytes = encoded.bytes || encoded;
|
||||
const profile = encoded.profile || null;
|
||||
const { codec, packed } = await packBytesForSave(bytes);
|
||||
const text = `${EXPORT_PREFIX}${codec}${jp2048Encode(packed)}`;
|
||||
finalizeProfile(profile, bytes, packed, text, codec);
|
||||
return { text, profile };
|
||||
}
|
||||
|
||||
async function encodeSnapshot(snapshot) {
|
||||
return (await encodeSnapshotWithProfile(snapshot)).text;
|
||||
const bytes = encodeBinarySnapshot(snapshot);
|
||||
const { codec, packed } = await packBytesForSave(bytes);
|
||||
return `${EXPORT_PREFIX}${codec}${jp2048Encode(packed)}`;
|
||||
}
|
||||
|
||||
async function decodeSnapshot(text) {
|
||||
|
|
@ -743,20 +601,7 @@
|
|||
}
|
||||
|
||||
global.TarinaiSaveCodec = {
|
||||
EXPORT_PREFIX,
|
||||
RAW_CODEC,
|
||||
DEFLATE_CODEC,
|
||||
SAVE_TEXT_ALPHABET,
|
||||
SAVE_TEXT_BASE,
|
||||
SAVE_TEXT_BITS,
|
||||
jp2048Encode,
|
||||
jp2048Decode,
|
||||
encodeSnapshot,
|
||||
encodeSnapshotWithProfile,
|
||||
decodeSnapshot,
|
||||
encodeBinarySnapshot,
|
||||
decodeBinarySnapshot,
|
||||
BinaryWriter,
|
||||
BinaryReader,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@
|
|||
// Owns stable save identifiers shared by snapshot_system.js and save_codec.js.
|
||||
// Current-format save identifiers only.
|
||||
(function (global) {
|
||||
const SNAPSHOT_VERSION = 11;
|
||||
const BINARY_SCHEMA_VERSION = 14;
|
||||
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"]);
|
||||
const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]);
|
||||
const MOOD_IDS = Object.freeze(["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]);
|
||||
const STATE_IDS = Object.freeze(["idle", "sleep", "panic", "sunbath", "fight", "seek_food", "seek_bed", "wander"]);
|
||||
const MOOD_IDS = Object.freeze(["relaxed", "devastation", "crisis", "confusion", "fearful", "disease_spread", "weakened", "breeding", "happy", "overcrowded", "isolated"]);
|
||||
const STATE_IDS = Object.freeze(["idle", "sleep"]);
|
||||
const POWER_MODE_IDS = Object.freeze(["", "protein", "niteropu"]);
|
||||
const SIZE_MODE_IDS = Object.freeze(["", "giant_drug", "dwarf_drug"]);
|
||||
const LIFE_MODE_IDS = Object.freeze(["", "mercury"]);
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi",
|
||||
"sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
|
||||
"stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h",
|
||||
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block"
|
||||
"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) {
|
||||
|
|
@ -31,28 +31,27 @@
|
|||
const STRUCTURE_TYPE_IDS = typeIds(["grass_bed", "plushie"]);
|
||||
const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]);
|
||||
const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]);
|
||||
const FENCE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]);
|
||||
const MECHANICAL_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator"]);
|
||||
const LINK_ITEM_TYPE_IDS = typeIds(["rope", "rod"]);
|
||||
const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator", "rope", "rod"]);
|
||||
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]);
|
||||
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 ?? ""));
|
||||
return i >= 0 ? i : fallback;
|
||||
}
|
||||
|
||||
function canonicalMoodId(value = "relaxed") {
|
||||
const id = String(value || "relaxed");
|
||||
return MOOD_IDS.includes(id) ? id : "relaxed";
|
||||
}
|
||||
|
||||
function enumValue(list, index, fallback = "") {
|
||||
const i = Number(index) | 0;
|
||||
return list[i] ?? fallback ?? list[0] ?? "";
|
||||
}
|
||||
|
||||
function itemTypeId(type, fallback = -1) {
|
||||
return enumIndex(ITEM_TYPE_IDS, type, fallback);
|
||||
}
|
||||
|
||||
function itemTypeValue(index, fallback = "") {
|
||||
return enumValue(ITEM_TYPE_IDS, index, fallback);
|
||||
function moodValue(index, fallback = "relaxed") {
|
||||
const i = Number(index) | 0;
|
||||
return canonicalMoodId(MOOD_IDS[i] ?? fallback);
|
||||
}
|
||||
|
||||
global.TarinaiSaveSchema = Object.freeze({
|
||||
|
|
@ -62,6 +61,8 @@
|
|||
GROUND_IDS,
|
||||
WEATHER_IDS,
|
||||
MOOD_IDS,
|
||||
canonicalMoodId,
|
||||
moodValue,
|
||||
STATE_IDS,
|
||||
POWER_MODE_IDS,
|
||||
SIZE_MODE_IDS,
|
||||
|
|
@ -72,14 +73,9 @@
|
|||
STRUCTURE_TYPE_IDS,
|
||||
SERVING_FOOD_TYPE_IDS,
|
||||
PIN_TYPE_IDS,
|
||||
FENCE_ITEM_TYPE_IDS,
|
||||
MECHANICAL_ITEM_TYPE_IDS,
|
||||
LINK_ITEM_TYPE_IDS,
|
||||
JSON_EXTRA_ITEM_TYPE_IDS,
|
||||
ROTATABLE_ITEM_TYPE_IDS,
|
||||
enumIndex,
|
||||
enumValue,
|
||||
itemTypeId,
|
||||
itemTypeValue,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@
|
|||
function metaKey(slot) { return `${META_PREFIX}${slot}`; }
|
||||
function writeSlot(slot, hashText, meta = null) {
|
||||
const hash = String(hashText || "").trim();
|
||||
if (!hash.startsWith("たり")) 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() }));
|
||||
return { hash, meta: readMeta(slot) };
|
||||
}
|
||||
function readMeta(slot) {
|
||||
const raw = global.localStorage?.getItem(metaKey(slot));
|
||||
|
|
@ -32,5 +31,5 @@
|
|||
global.localStorage?.removeItem(slotKey(slot));
|
||||
global.localStorage?.removeItem(metaKey(slot));
|
||||
}
|
||||
global.TarinaiSaveStorage = { STORAGE_PREFIX, SLOT_COUNT, slotKey, writeSlot, readSlot, requireSlot, deleteSlot };
|
||||
global.TarinaiSaveStorage = { SLOT_COUNT, writeSlot, readSlot, requireSlot, deleteSlot };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -14,18 +14,13 @@
|
|||
}
|
||||
|
||||
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
|
||||
return Restore.restoreSnapshot(snapshot, worldRef, { source: options.source || "save", syncUi: options.syncUi });
|
||||
return Restore.restoreSnapshot(snapshot, worldRef, { syncUi: options.syncUi });
|
||||
}
|
||||
|
||||
async function encodeSnapshot(snapshot = createSnapshot(global.world)) {
|
||||
return Codec.encodeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
async function encodeSnapshotWithProfile(snapshot = createSnapshot(global.world)) {
|
||||
if (typeof Codec.encodeSnapshotWithProfile === "function") return Codec.encodeSnapshotWithProfile(snapshot);
|
||||
return { text: await Codec.encodeSnapshot(snapshot), profile: null };
|
||||
}
|
||||
|
||||
async function decodeSnapshot(text) {
|
||||
return Codec.decodeSnapshot(text);
|
||||
}
|
||||
|
|
@ -33,13 +28,13 @@
|
|||
async function saveSlot(slot) {
|
||||
const snapshot = createSnapshot(global.world);
|
||||
const hash = await encodeSnapshot(snapshot);
|
||||
return Storage.writeSlot(slot, hash, { c: snapshot.c, m: snapshot.m });
|
||||
return Storage.writeSlot(slot, hash, { m: snapshot.m });
|
||||
}
|
||||
|
||||
async function loadSlot(slot) {
|
||||
const hash = Storage.requireSlot(slot);
|
||||
const snapshot = await decodeSnapshot(hash);
|
||||
return restoreSnapshot(snapshot, global.world, { source: "slot" });
|
||||
return restoreSnapshot(snapshot, global.world);
|
||||
}
|
||||
|
||||
function deleteSlot(slot) { Storage.deleteSlot(slot); }
|
||||
|
|
@ -49,67 +44,12 @@
|
|||
if (!entry) return "\u7a7a\u304d";
|
||||
const meta = entry.m || entry;
|
||||
const m = meta.m || [];
|
||||
const date = meta.savedAt ? new Date(meta.savedAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : (meta.c ? new Date(meta.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明");
|
||||
const date = meta.savedAt ? new Date(meta.savedAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e";
|
||||
const day = m[0] || 1;
|
||||
const population = m[1] || 0;
|
||||
const fieldType = m[2] || "庭";
|
||||
const timeText = Number.isFinite(m[3]) ? `${Math.floor((m[3] / 10) % 24).toString().padStart(2, "0")}:00` : "";
|
||||
const hashLen = entry.hash ? ` / ${entry.hash.length}字` : "";
|
||||
return `${date} / ${fieldType} / ${day}日目 ${timeText} / ${population}匹${hashLen}`;
|
||||
}
|
||||
|
||||
function bytesText(value = 0) {
|
||||
const n = Math.max(0, Math.round(Number(value) || 0));
|
||||
return `${n.toLocaleString("ja-JP")}B`;
|
||||
}
|
||||
|
||||
function percentText(value = 0, total = 0) {
|
||||
const t = Number(total) || 0;
|
||||
if (t <= 0) return "-";
|
||||
return `${((Number(value) || 0) / t * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function profileRow(label, bytes, total) {
|
||||
return `<div><span>${htmlEscape(label)}</span><strong>${htmlEscape(bytesText(bytes))}</strong><em>${htmlEscape(percentText(bytes, total))}</em></div>`;
|
||||
}
|
||||
|
||||
function renderHashProfile(profile, dialog = ensureDialog()) {
|
||||
const box = dialog?.querySelector?.("#saveHashProfile");
|
||||
if (!box) return;
|
||||
if (!profile) { box.innerHTML = ""; return; }
|
||||
const sections = profile.sections || {};
|
||||
const comp = profile.compression || {};
|
||||
const raw = comp.rawBytes || sections.totalRaw || 0;
|
||||
const itemEntries = Object.entries(profile.itemTypes || {})
|
||||
.sort((a, b) => (b[1]?.bytes || 0) - (a[1]?.bytes || 0))
|
||||
.slice(0, 10);
|
||||
const topItems = itemEntries.length
|
||||
? itemEntries.map(([type, entry]) => `<span>${htmlEscape(type)} ${Number(entry.count || 0)}個 / ${htmlEscape(bytesText(entry.bytes || 0))}</span>`).join("")
|
||||
: `<span>なし</span>`;
|
||||
box.innerHTML = `
|
||||
<div class="save-profile-summary">
|
||||
<span>ハッシュ <strong>${Number(comp.totalChars || 0).toLocaleString("ja-JP")}字</strong></span>
|
||||
<span>圧縮前 <strong>${htmlEscape(bytesText(raw))}</strong></span>
|
||||
<span>圧縮後 <strong>${htmlEscape(bytesText(comp.packedBytes || 0))}</strong></span>
|
||||
<span>圧縮率 <strong>${comp.ratio ? `${(comp.ratio * 100).toFixed(1)}%` : "-"}</strong></span>
|
||||
<span>文字表 <strong>${comp.japaneseText ? "日本語2048" : "不明"}</strong></span>
|
||||
</div>
|
||||
<div class="save-profile-grid">
|
||||
${profileRow("ヘッダ", sections.header, raw)}
|
||||
${profileRow("ワールド", sections.world, raw)}
|
||||
${profileRow("たりない合計", sections["tarinai.total"], raw)}
|
||||
${profileRow("生成seed", sections["tarinai.seed"], raw)}
|
||||
${profileRow("基本値", sections["tarinai.core"], raw)}
|
||||
${profileRow("欲求", sections["tarinai.needs"], raw)}
|
||||
${profileRow("性格差分", sections["tarinai.traits"], raw)}
|
||||
${profileRow("家族", sections["tarinai.family"], raw)}
|
||||
${profileRow("関係", sections["tarinai.relationships"], raw)}
|
||||
${profileRow("タイマー/効果", (sections["tarinai.timers"] || 0) + (sections["tarinai.modes"] || 0) + (sections["tarinai.tail"] || 0), raw)}
|
||||
${profileRow("アイテム合計", sections["items.total"], raw)}
|
||||
${profileRow("アイテム基本", sections["items.core"], raw)}
|
||||
${profileRow("アイテム追加", sections["items.extra"], raw)}
|
||||
</div>
|
||||
<div class="save-profile-items"><b>アイテム上位:</b>${topItems}</div>`;
|
||||
const fieldType = m[2] || "\u5ead";
|
||||
const hashLen = entry.hash ? ` / ${entry.hash.length}\u5b57` : "";
|
||||
return `${date} / ${fieldType} / ${day}\u65e5\u76ee / ${population}\u5339${hashLen}`;
|
||||
}
|
||||
|
||||
function ensureDialog() {
|
||||
|
|
@ -123,15 +63,14 @@
|
|||
dialog.innerHTML = `
|
||||
<div class="field-dialog-panel save-dialog-panel">
|
||||
<h2 id="saveDialogTitle">\u30bb\u30fc\u30d6 / \u30ed\u30fc\u30c9</h2>
|
||||
<p class="hint save-warning">セーブは実験的機能であり保証対象外です。</p>
|
||||
<p class="hint save-warning">\u30bb\u30fc\u30d6\u306f\u5b9f\u9a13\u7684\u6a5f\u80fd\u3067\u3042\u308a\u4fdd\u8a3c\u5bfe\u8c61\u5916\u3067\u3059\u3002</p>
|
||||
<div id="saveSlotList" class="save-slot-list"></div>
|
||||
<div class="save-export-panel">
|
||||
<div class="save-export-actions">
|
||||
<button id="saveExportBtn" class="btn" type="button">呪文をセーブ</button>
|
||||
<button id="saveImportBtn" class="btn primary" type="button">呪文をロード</button>
|
||||
<button id="saveExportBtn" class="btn" type="button">\u546a\u6587\u3092\u30bb\u30fc\u30d6</button>
|
||||
<button id="saveImportBtn" class="btn primary" type="button">\u546a\u6587\u3092\u30ed\u30fc\u30c9</button>
|
||||
</div>
|
||||
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="セーブした呪文はコピペして保存してください。"></textarea>
|
||||
<div id="saveHashProfile" class="save-hash-profile" aria-live="polite"></div>
|
||||
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="\u30bb\u30fc\u30d6\u3057\u305f\u546a\u6587\u306f\u30b3\u30d4\u30da\u3057\u3066\u4fdd\u5b58\u3057\u3066\u304f\u3060\u3055\u3044\u3002"></textarea>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="saveCloseBtn" class="btn" type="button">\u9589\u3058\u308b</button>
|
||||
|
|
@ -154,9 +93,9 @@
|
|||
<div class="save-slot-title">\u30b9\u30ed\u30c3\u30c8 ${i}</div>
|
||||
<div class="save-slot-meta">${htmlEscape(formatSlotSummary(snapshot))}</div>
|
||||
<div class="save-slot-actions">
|
||||
<button class="btn mini" data-save-slot="${i}" type="button">セーブ</button>
|
||||
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>ロード</button>
|
||||
<button class="btn mini danger" data-delete-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>消す</button>
|
||||
<button class="btn mini" data-save-slot="${i}" type="button">\u30bb\u30fc\u30d6</button>
|
||||
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>\u30ed\u30fc\u30c9</button>
|
||||
<button class="btn mini danger" data-delete-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>\u6d88\u3059</button>
|
||||
</div>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
|
@ -173,7 +112,7 @@
|
|||
}
|
||||
|
||||
function bindSaveSystem() {
|
||||
const btn = document.getElementById("saveBtn") || global.ui?.saveBtn;
|
||||
const btn = document.getElementById("saveBtn");
|
||||
if (!btn || btn.dataset.saveBound === "1") return;
|
||||
btn.dataset.saveBound = "1";
|
||||
btn.addEventListener("click", () => {
|
||||
|
|
@ -194,76 +133,62 @@
|
|||
await saveSlot(slot);
|
||||
renderSlotList();
|
||||
global.audio?.notify?.();
|
||||
global.showToast?.(`スロット${slot}にセーブしました。`);
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002`);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
global.showToast?.("セーブに失敗しました。空き容量を確認してください。");
|
||||
global.showToast?.("\u30bb\u30fc\u30d6\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u7a7a\u304d\u5bb9\u91cf\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (loadBtn) {
|
||||
const slot = Number(loadBtn.dataset.loadSlot);
|
||||
if (!global.confirm?.(`スロット${slot}をロードします。現在の状態は上書きされます。`)) return;
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`)) return;
|
||||
try {
|
||||
await loadSlot(slot);
|
||||
closeDialog();
|
||||
global.audio?.notify?.();
|
||||
global.showToast?.(`スロット${slot}をロードしました。`);
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3057\u305f\u3002`);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
global.showToast?.("ロードに失敗しました。");
|
||||
global.showToast?.("\u30ed\u30fc\u30c9\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (deleteBtn) {
|
||||
const slot = Number(deleteBtn.dataset.deleteSlot);
|
||||
if (!global.confirm?.(`スロット${slot}を消しますか?`)) return;
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u6d88\u3057\u307e\u3059\u304b\uff1f`)) return;
|
||||
deleteSlot(slot);
|
||||
renderSlotList();
|
||||
global.audio?.delete?.();
|
||||
global.showToast?.(`スロット${slot}を消しました。`);
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u6d88\u3057\u307e\u3057\u305f\u3002`);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#saveCloseBtn")) closeDialog();
|
||||
if (e.target.closest("#saveExportBtn")) {
|
||||
const area = dialog.querySelector("#saveHashText");
|
||||
if (area) {
|
||||
const result = await encodeSnapshotWithProfile(createSnapshot(global.world));
|
||||
area.value = result.text || "";
|
||||
renderHashProfile(result.profile, dialog);
|
||||
area.value = await encodeSnapshot(createSnapshot(global.world));
|
||||
area.focus();
|
||||
area.select();
|
||||
}
|
||||
global.showToast?.("呪文をセーブしました。");
|
||||
global.showToast?.("\u546a\u6587\u3092\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002");
|
||||
}
|
||||
if (e.target.closest("#saveImportBtn")) {
|
||||
const area = dialog.querySelector("#saveHashText");
|
||||
const text = area?.value || "";
|
||||
if (!text.trim()) { global.showToast?.("ロードする呪文を貼り付けてください。"); return; }
|
||||
if (!global.confirm?.("呪文をロードします。現在の状態は上書きされます。")) return;
|
||||
if (!text.trim()) { global.showToast?.("\u30ed\u30fc\u30c9\u3059\u308b\u546a\u6587\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
|
||||
if (!global.confirm?.("\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return;
|
||||
try {
|
||||
restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" });
|
||||
restoreSnapshot(await decodeSnapshot(text), global.world);
|
||||
closeDialog();
|
||||
global.showToast?.("呪文からロードしました。");
|
||||
global.showToast?.("\u546a\u6587\u304b\u3089\u30ed\u30fc\u30c9\u3057\u307e\u3057\u305f\u3002");
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
global.showToast?.("ロードに失敗しました。呪文を確認してください。");
|
||||
global.showToast?.("\u30ed\u30fc\u30c9\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u546a\u6587\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.TarinaiSaveSystem = {
|
||||
createSnapshot,
|
||||
restoreSnapshot,
|
||||
encodeSnapshot,
|
||||
encodeSnapshotWithProfile,
|
||||
decodeSnapshot,
|
||||
saveSlot,
|
||||
loadSlot,
|
||||
deleteSlot,
|
||||
readSlot,
|
||||
bindSaveSystem,
|
||||
openDialog,
|
||||
};
|
||||
global.TarinaiSaveSystem = { bindSaveSystem };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
189
js/sim_core.js
|
|
@ -64,59 +64,8 @@ function stableUnit(seed, salt = "") {
|
|||
return ((h >>> 0) % 100000) / 100000;
|
||||
}
|
||||
|
||||
function stableKeyPart(value) {
|
||||
if (value == null) return "null";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
|
||||
if (typeof value === "string" || typeof value === "boolean") return String(value);
|
||||
if (typeof value === "object") {
|
||||
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
|
||||
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
|
||||
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
|
||||
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
|
||||
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function deterministicFrame(worldRef = null, hz = 60) {
|
||||
const t = Number(worldRef?.time || 0) || 0;
|
||||
return Math.floor(t * Math.max(1, Number(hz) || 60) + 1e-6);
|
||||
}
|
||||
|
||||
function deterministicUnit(worldRef = null, salt = "", ...parts) {
|
||||
const seed = String(worldRef?.worldSeed || "tarinai-world");
|
||||
const frame = deterministicFrame(worldRef, 60);
|
||||
const serial = Number(worldRef?._deterministicEpoch || 0) || 0;
|
||||
const key = [salt, frame, serial, ...parts.map(stableKeyPart)].join("|");
|
||||
return stableUnit(seed, key);
|
||||
}
|
||||
|
||||
function deterministicRange(worldRef = null, salt = "", min = 0, max = 1, ...parts) {
|
||||
const a = Number(min) || 0;
|
||||
const b = Number(max) || 0;
|
||||
return a + deterministicUnit(worldRef, salt, ...parts) * (b - a);
|
||||
}
|
||||
|
||||
function deterministicSigned(worldRef = null, salt = "", ...parts) {
|
||||
return deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1;
|
||||
}
|
||||
|
||||
function deterministicChance(worldRef = null, salt = "", chance = 0, ...parts) {
|
||||
const p = clamp(Number(chance) || 0, 0, 1);
|
||||
if (p <= 0) return false;
|
||||
if (p >= 1) return true;
|
||||
return deterministicUnit(worldRef, salt, ...parts) < p;
|
||||
}
|
||||
|
||||
function deterministicAngle(worldRef = null, salt = "", ...parts) {
|
||||
return deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.TarinaiDeterminism = { stableKeyPart, deterministicFrame, deterministicUnit, deterministicRange, deterministicSigned, deterministicChance, deterministicAngle };
|
||||
}
|
||||
|
||||
const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"];
|
||||
const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul", "temperatureOffset"];
|
||||
const GENETIC_LIMITS = Object.freeze({
|
||||
lifeSpanMul: [0.82, 1.24],
|
||||
attackMul: [0.80, 1.24],
|
||||
|
|
@ -125,8 +74,12 @@ const GENETIC_LIMITS = Object.freeze({
|
|||
});
|
||||
|
||||
function clampGeneticValue(key, value) {
|
||||
const fallback = key === "temperatureOffset" ? 0 : 1;
|
||||
const n = Number(value);
|
||||
const finite = Number.isFinite(n) ? n : fallback;
|
||||
if (key === "temperatureOffset") return finite;
|
||||
const range = GENETIC_LIMITS[key] || [0.75, 1.25];
|
||||
return clamp(Number(value) || 1, range[0], range[1]);
|
||||
return clamp(finite, range[0], range[1]);
|
||||
}
|
||||
|
||||
function randomGenetics(seed = "") {
|
||||
|
|
@ -135,13 +88,17 @@ function randomGenetics(seed = "") {
|
|||
attackMul: clampGeneticValue("attackMul", 0.80 + stableUnit(seed, "gene-attack") * 0.44),
|
||||
speedMul: clampGeneticValue("speedMul", 0.86 + stableUnit(seed, "gene-speed") * 0.32),
|
||||
sizeMul: clampGeneticValue("sizeMul", 0.84 + stableUnit(seed, "gene-size") * 0.34),
|
||||
temperatureOffset: clampGeneticValue("temperatureOffset", -5 + stableUnit(seed, "gene-temperature-offset") * 10),
|
||||
};
|
||||
}
|
||||
|
||||
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]);
|
||||
for (const key of GENETIC_KEYS) {
|
||||
const legacyKey = key === "attackMul" ? "fightMul" : key;
|
||||
out[key] = clampGeneticValue(key, source?.[key] ?? source?.[legacyKey] ?? fallback[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -150,8 +107,12 @@ function inheritedGenetics(a, b, seed = "") {
|
|||
const gb = normalizeGenetics(b?.genetics, b?.familyKey || b?.id || "b");
|
||||
const out = {};
|
||||
for (const key of GENETIC_KEYS) {
|
||||
const base = ((ga[key] || 1) + (gb[key] || 1)) / 2;
|
||||
const mutation = (stableUnit(seed, `gene-mut-${key}`) - 0.5) * 0.10;
|
||||
const fallback = key === "temperatureOffset" ? 0 : 1;
|
||||
const av = Number.isFinite(Number(ga[key])) ? Number(ga[key]) : fallback;
|
||||
const bv = Number.isFinite(Number(gb[key])) ? Number(gb[key]) : fallback;
|
||||
const base = (av + bv) / 2;
|
||||
const mutationScale = key === "temperatureOffset" ? 2.5 : 0.10;
|
||||
const mutation = (stableUnit(seed, `gene-mut-${key}`) - 0.5) * mutationScale;
|
||||
out[key] = clampGeneticValue(key, base + mutation);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -185,14 +146,6 @@ function tarinaiMaxEnergy(tarinai = null) {
|
|||
return Math.round(clamp(100 * tarinaiBodySizeRatio(tarinai), 28, 240));
|
||||
}
|
||||
|
||||
function clampTarinaiEnergy(tarinai = null) {
|
||||
if (!tarinai) return 100;
|
||||
const max = tarinaiMaxEnergy(tarinai);
|
||||
tarinai.maxEnergy = max;
|
||||
tarinai.energy = clamp(Number(tarinai.energy) || 0, 0, max);
|
||||
return max;
|
||||
}
|
||||
|
||||
function tarinaiEnergyRatio(tarinai = null) {
|
||||
const max = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai?.maxEnergy) || 100));
|
||||
return clamp((Number(tarinai?.energy) || 0) / max, 0, 1);
|
||||
|
|
@ -206,8 +159,6 @@ function tarinaiMetabolicHungerScale(tarinai = null) {
|
|||
return clamp(attack * speed * size, 0.18, 8.0);
|
||||
}
|
||||
|
||||
const PERSONALITIES = Object.freeze({});
|
||||
const PERSONALITY_IDS = [];
|
||||
const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"];
|
||||
const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 };
|
||||
const FRIEND_AFFINITY_THRESHOLD = 14;
|
||||
|
|
@ -256,14 +207,6 @@ function randomBirthPersonality(seed = "") {
|
|||
return out;
|
||||
}
|
||||
|
||||
function personalityFromParents(a, b) {
|
||||
const pa = ensurePersonality(a).currentPersonality;
|
||||
const pb = ensurePersonality(b).currentPersonality;
|
||||
const out = {};
|
||||
for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(((pa[key] || 0) + (pb[key] || 0)) / 2 + rand(-0.2, 0.2));
|
||||
return out;
|
||||
}
|
||||
|
||||
function getTraitStrength(value) {
|
||||
const v = clampPersonalityValue(value);
|
||||
if (v >= 0.75) return { direction: 1, strength: "strong" };
|
||||
|
|
@ -512,6 +455,28 @@ class Effect {
|
|||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (this.type === "shoot_impact") {
|
||||
const s = this.size * (0.65 + (1 - alpha) * 0.55);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = this.color || "rgba(255,226,150,0.92)";
|
||||
ctx.lineWidth = Math.max(1.2, this.size * 0.16);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const a = this.seed + i * 0.897;
|
||||
const inner = s * randSeed(this.seed + i + 2, 0.16, 0.34);
|
||||
const outer = s * randSeed(this.seed + i + 9, 0.72, 1.18) + (1 - alpha) * this.size * 1.1;
|
||||
ctx.globalAlpha = alpha * randSeed(this.seed + i + 31, 0.42, 0.95);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner);
|
||||
ctx.lineTo(Math.cos(a) * outer, Math.sin(a) * outer);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = alpha * 0.75;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.70)";
|
||||
ctx.lineWidth = Math.max(1, this.size * 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, s * 0.68 + (1 - alpha) * this.size * 0.65, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (this.type === "fight") {
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
|
|
@ -597,8 +562,38 @@ class Effect {
|
|||
ctx.fill();
|
||||
}
|
||||
} else if (this.type === "bubble") {
|
||||
const w = Math.max(30, 14 + this.text.length * 7);
|
||||
const h = 20;
|
||||
ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
|
||||
const maxTextW = 118;
|
||||
const lineH = 14;
|
||||
const ellipsize = (text, maxW) => {
|
||||
let s = String(text || "");
|
||||
if (ctx.measureText(s).width <= maxW) return s;
|
||||
while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) s = s.slice(0, -1);
|
||||
return `${s}…`;
|
||||
};
|
||||
const wrap = (text, maxW) => {
|
||||
const src = String(text || "").replace(/\s+/g, " ").trim() || " ";
|
||||
const units = src.includes(" ") ? src.split(/(\s+)/).filter(Boolean) : Array.from(src);
|
||||
const out = [];
|
||||
let line = "";
|
||||
for (const unit of units) {
|
||||
const candidate = line ? `${line}${unit}` : unit;
|
||||
if (ctx.measureText(candidate).width <= maxW) line = candidate;
|
||||
else {
|
||||
if (line) out.push(line.trim());
|
||||
line = unit.trim();
|
||||
if (ctx.measureText(line).width > maxW) { out.push(ellipsize(line, maxW)); line = ""; }
|
||||
}
|
||||
if (out.length >= 3) break;
|
||||
}
|
||||
if (line && out.length < 3) out.push(line.trim());
|
||||
if (out.length > 2) out[1] = ellipsize([out[1], ...out.slice(2)].join(""), maxW);
|
||||
return out.slice(0, 2).map(line => ellipsize(line, maxW));
|
||||
};
|
||||
const lines = wrap(this.text, maxTextW);
|
||||
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
|
||||
const w = Math.max(30, Math.min(142, textW + 18));
|
||||
const h = Math.max(20, 10 + lines.length * lineH);
|
||||
ctx.globalAlpha = Math.min(1, alpha * 1.25);
|
||||
ctx.fillStyle = "rgba(255, 252, 240, 0.92)";
|
||||
ctx.strokeStyle = "rgba(78, 64, 48, 0.22)";
|
||||
|
|
@ -615,10 +610,11 @@ class Effect {
|
|||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = this.color || "rgba(42,36,29,0.78)";
|
||||
ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(this.text, 0, -10);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i], 0, -h + 8 + lineH * i + lineH / 2);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -664,21 +660,6 @@ class SpatialGrid {
|
|||
this.dynamicHazardCells.clear();
|
||||
}
|
||||
|
||||
clearStaticItems(opts = {}) {
|
||||
this.staticItemCells.clear();
|
||||
this.staticObstacleCells.clear();
|
||||
this.staticFoodCells.clear();
|
||||
this.staticHazardCells.clear();
|
||||
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
||||
}
|
||||
|
||||
clearDynamicItems(opts = {}) {
|
||||
this.dynamicItemCells.clear();
|
||||
this.dynamicObstacleCells.clear();
|
||||
this.dynamicFoodCells.clear();
|
||||
this.dynamicHazardCells.clear();
|
||||
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
||||
}
|
||||
|
||||
clearTarinai() {
|
||||
this.tarinaiCells.clear();
|
||||
|
|
@ -717,6 +698,8 @@ class SpatialGrid {
|
|||
const minY = Math.floor(Math.min(top, bottom) / this.cellSize);
|
||||
const maxY = Math.floor(Math.max(top, bottom) / this.cellSize);
|
||||
const cellCount = Math.max(1, (maxX - minX + 1) * (maxY - minY + 1));
|
||||
// Very large bodies would pollute most buckets; keep them in their origin
|
||||
// cell and let callers use their own radius/AABB checks after retrieval.
|
||||
if (cellCount > 144) return this.add(map, entity);
|
||||
for (let cy = minY; cy <= maxY; cy += 1) {
|
||||
for (let cx = minX; cx <= maxX; cx += 1) {
|
||||
|
|
@ -733,11 +716,8 @@ class SpatialGrid {
|
|||
|
||||
boundsForTrait(it, trait) {
|
||||
const mech = (typeof window !== "undefined" ? window : globalThis).TarinaiMechanicalSystem;
|
||||
if (mech?.isMechanicalType?.(it?.type)) {
|
||||
if (trait === "hazard" && it.type === "poison_block") return mech.hazardBoundsAabb?.(it) || mech.boundsAabb?.(it);
|
||||
return mech.boundsAabb?.(it);
|
||||
}
|
||||
const ropePath = it?.type === "rope" ? ((typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem?.particles?.(it) || null) : null;
|
||||
if (mech?.isMechanicalType?.(it?.type)) return mech.boundsAabb?.(it);
|
||||
const ropePath = it?.type === "rope" ? ((typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem.particles(it) || null) : null;
|
||||
if (ropePath && ropePath.length) {
|
||||
let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
|
||||
for (const p of ropePath) {
|
||||
|
|
@ -777,7 +757,7 @@ class SpatialGrid {
|
|||
const velocity = body?.velocity || {};
|
||||
return motor.powered !== false || Math.abs(Number(velocity.linear || 0)) > 0.05;
|
||||
}
|
||||
if (type === "poison_block") return Boolean(globalRef.TarinaiMechanicalSystem?.passiveItemAwake?.(it));
|
||||
if (type === "poison_block") return Boolean(globalRef.TarinaiMechanicalSystem.passiveItemAwake(it));
|
||||
if ((it.dropTimer || 0) > 0) return true;
|
||||
if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true;
|
||||
if (it.isStructure && it.type === "plushie" && it.carriedById) return true;
|
||||
|
|
@ -789,11 +769,11 @@ class SpatialGrid {
|
|||
const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells;
|
||||
const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells;
|
||||
const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells;
|
||||
const hasObstacle = typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v");
|
||||
const hasFood = typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)));
|
||||
const hasHazard = typeof itemHasTrait === "function" && itemHasTrait(type, "hazard");
|
||||
const hasObstacle = itemHasTrait(type, "obstacle");
|
||||
const hasFood = itemHasTrait(type, "food_interest");
|
||||
const hasHazard = itemHasTrait(type, "hazard");
|
||||
if (type === "poison_block") {
|
||||
const body = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem?.ensureBody?.(it, null, { syncFromLegacy: false });
|
||||
const body = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem.ensureBody(it, null, { syncFromLegacy: false });
|
||||
const solid = body?.collision ? body.collision.solid !== false : true;
|
||||
if (solid && hasObstacle) this.addTrait(obstacleMap, it, "obstacle");
|
||||
if (hasHazard) this.addTrait(hazardMap, it, "hazard");
|
||||
|
|
@ -894,6 +874,8 @@ class SpatialGrid {
|
|||
|
||||
nearbyInto(map, x, y, radius, out = [], filterDistance = false) {
|
||||
const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
|
||||
// AABB-indexed entities can appear in several buckets. The per-query stamp
|
||||
// dedupes without allocating a Set on every hot spatial lookup.
|
||||
const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1);
|
||||
const minX = Math.floor((x - r) / this.cellSize);
|
||||
const maxX = Math.floor((x + r) / this.cellSize);
|
||||
|
|
@ -928,6 +910,7 @@ class SpatialGrid {
|
|||
|
||||
nearbyRectInto(map, rect, out = []) {
|
||||
if (!rect) return out;
|
||||
// See nearbyInto(): multi-cell insertions require per-query dedupe.
|
||||
const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1);
|
||||
const minX = Math.floor((rect.left || 0) / this.cellSize);
|
||||
const maxX = Math.floor((rect.right || 0) / this.cellSize);
|
||||
|
|
|
|||