Compare commits

..

4 commits

Author SHA1 Message Date
2636d580a8 aaa 2026-07-10 16:40:06 +09:00
f1c3af2b89 あ 2026-07-09 16:08:11 +09:00
8f9f5b5100 duu 2026-07-05 18:01:36 +09:00
88f618cadf 1.6 2026-07-03 20:57:02 +09:00
130 changed files with 7475 additions and 2499 deletions

Binary file not shown.

View file

@ -0,0 +1 @@
level=none expiry=0

View file

@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.103`.
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.16.18`.
- Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render.
- Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches.
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.
@ -59,4 +59,25 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Keep intentional global exports for compatibility with regression checks and browser runtime; remove public facades only after repo-wide caller checks.
- Prefer focused edits in the owning subsystem; split modules are intentional compatibility boundaries.
- Avoid reintroducing `simulationQualityTier` branches unless a real simulation-quality setting is restored; current quality controls are visual/detail controls.
- `High` display quality may include quality-preserving optimizations, but should not reduce simulation cadence, object limits, or visible fidelity in a noticeable way.
- Save format compatibility with old text saves is not required when intentionally shortening/replacing save hash text; keep current save/load internally consistent instead.
- Run `node --check js/*.js` and `python scripts/regression_check.py` for behavior changes.
## Current Maintenance Notes
This section records current boundaries only. Version-by-version cleanup history and completed work logs are intentionally omitted.
### Live Compatibility Surfaces
- Ordered global-IIFE loading is still part of the app contract; check repo-wide callers before removing bare globals or facade objects.
- Keep public event names with active listeners: `audio:play`, `selection:changed`, `tool:selected`, `world:phase`, `log:entry`, and `tool:placed`. Telemetry-only emit calls should stay removed.
- Save compatibility with old exported text is not required, but the current save/load round-trip must remain internally consistent.
- Tool icon ownership is `TOOL_DEFINITIONS.icon`; UI code converts asset paths for CSS variables. Avoid restoring CSS-local `url(...)` definitions.
- Shared geometry/display/domain helpers should stay in their owning helper modules unless a dedicated namespace migration is being done.
### Open Architecture Candidates
- Full namespace migration for behavior/action/needs/tool helpers. This requires rewriting many bare global call sites and updating regression contracts.
- Effect-specific spatial indexing, fuller delta-history storage, and high-speed collision partitioning. These need new indexes or semantics beyond cleanup.
- UI/text-side meaning consolidation, including labels, wrapping/measurement helpers, and duplicated scalar helpers such as `num`, `finiteOr`, `isObject`, and `clonePlain`.
- Further `World.prototype` reduction is possible, but only for helpers proven not to be runtime-facing canvas/UI/tool APIs.

View file

@ -1,13 +1,29 @@
tarinai_water_balloon_fan_ui_v1
- Fan drawing now shows the actual forward wind cone/range.
- Mobile rotator/reciprocator editor dialogs are scrollable; reciprocator canvas shares editor sizing.
- Balloon tool is replaced by water balloon visuals/text.
- Water balloons splash water when popped; low-openness tarinai panic and flee with behavior text: 水風船が割れておどろいている。
- Version: 39.15.116
tarinai_compact_save_stage4
- Save schema 33 omits standard item amounts and stores only an optional non-standard block base plus per-item differences.
- Relationship targets are sorted and delta-encoded within each Tarinai row.
- World seeds use a canonical 64-bit hexadecimal identity and occupy exactly 8 bytes in the binary save.
- The binary header contains one schema byte; its high bit signals whether a string table follows. Empty string tables and the duplicate snapshot version are omitted.
- Deflate state is folded into the low bit of the frame-length varuint, eliminating the separate compression byte.
- 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map.
- Previous binary schemas are intentionally unsupported.
- Version: 39.16.18
- CompressionStream and DecompressionStream are drained concurrently to avoid browser backpressure stalls.
tarinai_king_fix_v10
- Fire tool button hidden; flames spawn only from flame firecracker.
- Burning tarinai and ants draw lightweight flame-shaped overlays.
- Flame firecracker is redder and shows a countdown timer; normal firecracker is darker.
- Standalone flame and fire particles use a simpler flame silhouette.
- Version: 39.15.102
- Load confirmation uses the in-game dialog; fan wind is realtime; zunchi tool preview opacity, carried plushie depth, and blanket grass rules were corrected.
- Passive spring: removed time-driven oscillation; force now depends on extension/compression and relative endpoint velocity.
- Signal effects use the exported TarinaiEffect constructor.
- Pressure-switch placement/editor overlays display the configured detection radius.
- v39.16.18: removed redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping; unified one-way-fence and bounce corrections while retaining debug diagnostics and defensive recovery paths.
- Earlier mechanism pass: stronger passive spring, persistent detector ranges, robot-cleaner signal power, and electric shock effect.
- v39.16.17: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper.
- v39.16.16: link rendering no longer depends on midpoint spatial visibility; bounce fences launch Tarinai without shock damage; passive springs are more under-damped and elastic.
- v39.16.16: population chart includes per-bucket births/deaths; rotators accept signed motor speed; champion personality bonuses inherit through descendants; champion-pair mate weighting is moderately reduced.
- v39.16.16: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction.
- v39.16.16: one-way fences allow Tarinai forward crossing in swept collision; chart legend dots toggle series; wire tools attach to Tarinai/ants; energized living endpoints panic and take damage; selected data reports electrocution.

View file

@ -1,6 +1,6 @@
{
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
"version": "39.15.103",
"version": "39.16.18",
"entrypoints": {
"app": "index.html",
"runtime": "js/main.js",

View file

@ -1,7 +1,129 @@
## v39.16.17 mechanical one-way fence runtime fix
- Mechanical objects now query their canonical physics-body velocity when resolving one-way-fence contacts.
- This removes the `bodyFor is not defined` runtime failure while preserving the existing directional fence behavior.
## v39.16.16 directional fences, chart controls, and living wire endpoints
- Tarinai can physically cross one-way fences in the arrow direction, including swept/high-speed movement.
- Colony chart color dots toggle individual series.
- Wire and insulated wire can attach to Tarinai and ants. Energized living endpoints take damage and panic regardless of insulation; only bare wire damages incidental contact.
- Selected Tarinai data shows `感電している。` while shocked.
## v39.16.16 colony lineage and directional routing
- Population history overlays interval birth and death counts.
- Rotators preserve negative motor speed and rotate in reverse.
- Tarinai Champion boosts to aggression, openness, and sociability pass to descendants with generational damping.
- Champion-pair mating preference is slightly reduced.
- One-way fence placement previews show the allowed direction.
- Pathfinding treats one-way fences as passable only along their arrow direction.
## v39.16.16 colony graph, reverse rotator, and champion lineage
- コロニー統計の個体数グラフへ、2時間区間ごとの誕生数と死亡数を追加した。
- 回転体のモーター速度は負値を保持し、負の角度方向へ逆回転できる。
- たりない王の攻撃性・開放性・社交性補正は子孫の誕生時性格へ減衰継承される。
- たりない王同士の繁殖優先倍率を5.0から4.0へ下げ、繁殖確率をやや抑えた。
- 保存形式は変更しない。
# tarinai_ Game Features
## v39.16.16 link, bounce-fence, and spring fixes
- ヒモ、棒、バネ、電線、絶縁電線は、接続線が画面内を横切る場合、中点の空間セルが画面外でも描画される。
- バウンス柵はたりないを外力インパルスで確実に吹き飛ばし、柵自身が作る急激な速度変化では衝撃ダメージを与えない。
- バネは受動式のまま剛性を高め、減衰と強制位置補正を下げ、より大きく長く往復する。
## v39.16.13 detector and fence changes
- 感圧スイッチを「検知器」へ改名し、道具UI上で通常の柵と位置を入れ替えた。
- 検知器は軸平行の矩形範囲で対象を数える。専用UIから横幅・縦幅・対象・必要数を設定する。
- 柵の配置境界判定を実際の細長い形状に合わせ、フィールド上下端近くへ配置できるようにした。
- バウンス柵の反発係数、最低射出速度、最大速度を引き上げ、衝突直後に強く跳ねるようにした。
- ワープゲートを道具、描画、更新、信号、保存形式から削除した。
- 一方通行柵を追加した。矢印方向には通過でき、逆方向のみ衝突する。
- バイナリスキーマ36、スナップショット30。旧形式との読み込み互換は持たない。
## v39.16.11 mechanism fixes and passive spring
- 検知器は矩形範囲内の「たりない」または「アイテム」を数え、設定個数以上で電線へ信号を送る。
- 一方通行柵は矢印方向だけ通過でき、逆方向のたりない・アイテム・機構を遮る。
- バウンス柵は衝突対象へ強い即時反発を与える。
- バネは棒と同じ2点接続方式で設置する。自動伸縮はせず、外力による伸び縮みに対して受動的に反発し、弾性運動する。
- 電線と絶縁電線はアイテム同士を接続し、複数本を経由して信号を伝える。通常電線は通電中に接触したたりないへダメージを与え、絶縁電線は与えない。
- バイナリスキーマ34、スナップショット28。v39.16.10と保存形式は同じ。
## v39.16.9 load UI and item behavior fixes
- Replaced browser-native load confirmations with an in-game confirmation window.
- Kept fans in the realtime scheduler lane so wind applies continuously instead of as periodic gusts.
- Made the zunchi tool preview use the fresh, fully opaque stage.
- Rendered carried plushies at the same depth position as their owner.
- Disabled grass spawning, growth, placement capacity, and existing grass on the fluffy blanket ground.
## v39.16.8 compact amount, relationship, seed, and framing save
- アイテム種類の標準amountは保存せず、標準値と異なる共通値または個別差分だけを保存する。共通値候補は実バイト数で選択する。
- 個体関係の対象インデックスを昇順に並べ、前の対象との差分として保存する。
- worldSeedを固定16進64bitへ統一し、バイナリ上では8バイト固定で保存する。
- バイナリ先頭はスキーマ1バイトへ統合し、上位bitで文字列テーブルの有無を示す。空の文字列テーブルと重複したスナップショット版番号は保存しない。
- 圧縮フラグをフレーム長の下位bitへ統合し、独立した1バイトを削除する。
- 15bit文字化の32768文字テーブルと逆引きMapを廃止し、Unicodeコードポイントの算術変換へ変更する。
- バイナリスキーマ33、スナップショット27。旧形式との読み込み互換は持たない。
## v39.16.7 column-oriented creature save and packed item flags
- 個体の固定情報を項目別の列指向ビットプレーンへ変更し、全個体で0の列は保存しない。
- 睡眠状態、遺伝差分、戦績差分の存在は各ID・差分マスクから直接判定し、重複していた3bit/個体を削除する。
- アイテムの共通追加状態、amount差分、追加状態差分のフラグを全ブロック共通の連続ビット列へ格納する。
- 共通追加状態は候補ごとに専用バイナリを試算し、全件個別保存より実際に小さい場合だけ採用する。
- バイナリスキーマ32、スナップショット26。旧形式との読み込み互換は持たない。
## v39.16.4 compact identity and normal-item binary save
- 個体のbirthSeed文字列を保存せず、出生番号と32bitハッシュへ分解する。`worldSeed + birthSerial`で再現できる個体はハッシュも省略する。
- 新規出生のbirthSeedは親文字列を含めず、親子関係と遺伝情報は既存の専用フィールドで保持する。
- 通常アイテムの追加情報を型別専用バイナリへ移行し、JSONテキストを保存しない。未知の追加配列も数値・文字列の型付きバイナリで保存する。
- 同じ種類のアイテムはamountの最頻値をブロックに1回だけ保存し、同一追加状態が複数ある場合も共有する。個別アイテムには差分だけを保存する。
- バイナリスキーマ30、スナップショット24。旧形式との読み込み互換は持たない。
## v39.16.3 physics binary save and reciprocator cargo contacts
- 回転体・毒ブロック・往復体・ロープ・ロッドの追加情報を、JSON文字列ではなく専用バイナリで保存する。型名、キー名、デフォルト値、重複座標、標準輪郭、インデックス参照済み接続先の文字列情報を省略する。
- バイナリスキーマは29。v39.16.2のスキーマ28は読み込み互換を維持する。
- 往復体はボール、石、画鋲などの可動物理アイテムを押し出すが、それらとの接触では方向、レール位相、モーター速度を変更しない。
## v39.16.2 reciprocator collision and placement alignment
- 往復体は、進行方向にある機構、静的障害物、物理アイテムへ衝突した時点で方向転換する。接触分離によって重なりが解消されても反転判定を失わない。
- 静的障害物との判定から往復体を除外しない。高速移動時は障害物付近でサブステップ判定を増やし、すり抜けを抑える。
- 往復体の設置前オーバーレイと実配置は同じ角度初期化処理を使用し、物理ボディー角度とレール軸を同じ順序で確定する。
- セーブ形式は変更しない。
## v39.16.1 debug save-size breakdown
- `?debug=1` でセーブまたは呪文出力を実行すると、デバッグオーバーレイにバイナリ内訳、文字数、エンコード時間が表示される。
- 内訳は形式情報、文字列テーブル、ワールド、個体基本情報、個体シード、個体関係、アイテム索引、通常アイテム、物理アイテムに分けて実占有バイトを集計する。
- 通常アイテム・物理アイテムの追加情報について、バイナリ内の占有量とJSON UTF-8元サイズを併記する。ロード後は直近のデコード時間も表示する。
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.
## v39.16.0 mechanical interaction and limit audit
- 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。
- 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。
- コロニー上限は中央生成APIで常時適用され、通常実行時に上限を迂回するオプションは持たない。既存要素やセーブ復元要素は削除せず、その後の新規生成のみを抑止する。
## v39.15.165 behavior and colony controls
- たりない王は、繁殖可能な別のたりない王を広い範囲から優先して探し、通常より強い繁殖欲求と短い再繁殖待機時間を持つ。
- 往復体の新規配置時の標準方向は90度。
- 毒ブロックの新規配置時の標準輪郭は小型(半径44、輪郭104×40、太さ11)。
- コロニーパネルで、たりない個体数と生存オブジェクト総数の上限を設定できる。空欄または0は上限なし。既存要素は削除せず、新規追加だけを抑止する。
- 上限値はスナップショットとバイナリセーブに保存される。
## 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.
@ -76,10 +198,10 @@ The core loop is:
### Physics
- **ヒモ / rope**: connect two targets with a flexible link.
- **ヒモ / rope**: connect two targets with a flexible, inextensible link that may go slack but does not lengthen.
- **棒 / 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.
- **バウンス柵 / bounce fence**: high-restitution fence that strongly launches 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.
@ -143,6 +265,11 @@ Tarinai can be pushed by physics objects, carried or affected by ants, dragged b
- **Grass ecology**: grass grows, feeds tarinai, comforts them, can become bed material, and has a small chance to reduce fire before burning.
- **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.
- **Season temperature**: automatic summer climate is roughly 5℃ hotter than the previous baseline; manual temperature control remains exact to the slider value.
## Crowded Scene Performance
Crowded fields are an intended sandbox outcome. Performance work should prefer cached, typed, spatial, dirty, or incremental lookups over lowering object limits. High display quality should preserve visible fidelity and simulation rules while avoiding repeated hidden scans in render, tooltip, family tree, collision, effect, fire, and maintenance paths.
## Disease And Recovery
@ -168,25 +295,6 @@ Recovery depends on the disease or effect. Water, rest, food, medicine, zunda ju
## Save, Reset, And Persistence
- The app supports manual save and restore through browser storage.
- Current saves are allowed to use compact/hash-style text. Old save text does not need to remain loadable when the save format is intentionally shortened or replaced, so load failure is acceptable after intentional save-format changes as long as the current save/load path stays internally consistent.
- Reset opens field selection and starts a new field state.
- Service worker caching allows the static app shell to remain available after load, depending on browser support.
## 39.15.116 土管外観・内部温度
- 土管の外観を、丸い口と側面が見える斜め上からの視点に変更。
- 土管内の体感温度を20℃へ近づける内部温度補正を追加。補正幅は最大7℃。
- 土管ツールチップの内部気温表示にも、土管内補正後の温度を反映。
## 39.15.115 水風船の飛散強化・扇風機首振り設定
- 水風船破裂時に水滴が放射状に飛び、短時間移動してから水たまり状に落ち着く。
- 配置済み扇風機をクリックすると首振り設定UIを開ける。個別に首振りON/OFF、向き、振り幅、速度を設定可能。
- 首振り中の扇風機は風の範囲表示と実際の風判定が現在角度に追従する。
## 39.15.115 土管
- 生活カテゴリに「土管」を追加。丸い口が見える配置アイテムとして表示。
- たりないが巣箱のように土管の中へ入り、内部の個体は巣箱系ツールチップで確認可能。
- 土管から出る時は、複数ある場合は別のランダムな土管から出る。
- 土管内でも眠れるが、寝心地と睡眠優先順位は干草寝床の一段下。

View file

@ -2,11 +2,6 @@
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.
@ -18,16 +13,18 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
## Runtime Shape
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.103`.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.18`.
- v39.16.18 changes: redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping were removed; one-way-fence and bounce corrections now use shared canonical helpers.
- 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.
- Generated/static app shell: `app_manifest.json`, `service-worker.js`, `FILES.json`, 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/domain_ids.js`, `js/item_type_catalog.js`, `js/data.js`, `js/ground_types.js`: stable domain ids, current item-type save catalog, core config, field/ground definitions.
- `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.
@ -57,31 +54,5 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All`
## UI media isolation / scroll repair
- 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.
## 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.
## Family polish
- Family tree generation row spacing is tightened.
- Family tree personality tags also use a 2x2 grid (up to 4 visible tags).
- Sprinkler effective cleaning radius is increased to 3x the previous value and still filters targets by actual distance.
## Owned object panic / shoot effect
- 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.
### v39.16.16
Colony birth/death chart lines, reverse rotators, champion-trait inheritance, reduced champion-pair mating, and directional one-way-fence routing.

View file

@ -1,5 +1,5 @@
{
"version": "39.15.116",
"version": "39.16.18",
"css": [
"css/base.css",
"css/layout.css",
@ -10,6 +10,7 @@
"js": [
"js/version.js",
"js/event_bus.js",
"js/domain_ids.js",
"js/registry_base.js",
"js/disease_registry.js",
"js/sound_pack.js",
@ -35,13 +36,16 @@
"js/constraint_system.js",
"js/physics_world_system.js",
"js/physics_projection_system.js",
"js/signal_system.js",
"js/assets.js",
"js/audio.js",
"js/perf_profiler.js",
"js/display_helpers.js",
"js/render.js",
"js/sim_core.js",
"js/tarinai_seed_factory.js",
"js/structures.js",
"js/item_type_catalog.js",
"js/items.js",
"js/item_type_initializers.js",
"js/item_lifecycle_support.js",

View file

@ -402,35 +402,6 @@ h1 {
.tool[data-tool="dwarf_drug"] .tool-sprite-preview,
.tool[data-tool="oshibyo"] .tool-sprite-preview { width: 22px; height: 22px; left: 5px; }
.tool[data-tool="poke"], .tool[data-tool="water_hose"] { --tool-icon-zoom: 0.92; }
.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.webp"); }
.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.webp"); }
.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.webp"); }
.tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.webp"); }
.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"); }
.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.webp"); }
.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.webp"); }
.tool[data-tool="signboard"] { --tool-icon: url("../assets/ui/tool_signboard.webp"); }
.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp"); }
.tool[data-tool="pushpin"] { --tool-icon: url("../assets/ui/tool_pushpin.webp"); }
.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp"); }
/* Generated parameter item tool icons. */
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp"); }
.signboard-editor {
position: fixed;
@ -648,6 +619,32 @@ h1 {
background: rgba(255,255,255,0.84);
padding: 4px 6px;
}
.signal-channel-label {
font-size: 13px;
color: var(--ink);
font-weight: 700;
}
.signal-channel-picker {
display: grid;
grid-template-columns: repeat(3, minmax(44px, 1fr));
gap: 7px;
}
.signal-channel-picker button {
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(84,68,48,0.18);
background: rgba(255,255,255,0.78);
color: var(--ink);
font: 700 14px ui-rounded, sans-serif;
cursor: pointer;
}
.signal-channel-picker button:hover { background: rgba(255,248,232,0.98); }
.signal-channel-picker button.active {
background: rgba(78,179,224,0.22);
border-color: rgba(42,133,184,0.58);
box-shadow: inset 0 0 0 1px rgba(42,133,184,0.18);
}
.rotator-editor-tools {
display: flex;
flex-wrap: wrap;
@ -732,3 +729,32 @@ h1 {
.fan-editor-controls label { grid-template-columns: 1fr; align-items: stretch; }
.fan-editor-panel { width: calc(100vw - 16px); }
}
/* v39.15.130: only in-game overlay windows scroll when they exceed the viewport.
Right-panel cards and tool categories intentionally keep the older stable layout. */
.field-dialog,
.signboard-editor,
.rotator-editor {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
align-items: flex-start;
justify-items: center;
box-sizing: border-box;
padding: 12px;
}
.field-dialog-panel,
.signboard-editor-panel,
.rotator-editor-panel {
max-height: calc(100dvh - 24px);
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.save-confirm-dialog { z-index: 36; }
.save-confirm-panel { width: min(390px, 100%); }
.save-confirm-message { margin-bottom: 8px; color: var(--ink); font-size: 14px; }

View file

@ -790,19 +790,24 @@
top: 50%;
transform: translate(-50%, -50%);
}
@media (max-width: 980px) {
.tool-category[data-tool-category="operate"]:not(.collapsed) { top: 58px; }
.tool-category[data-tool-category="operate"] .tool-shortcut {
position: absolute;
right: 3px;
bottom: 2px;
min-width: 13px;
height: 13px;
box-sizing: border-box;
padding: 0 3px;
border: 1px solid rgba(82,128,190,0.26);
border-radius: 5px;
background: rgba(255,255,255,0.88);
color: rgba(48,74,112,0.86);
font: 700 9px/12px system-ui, sans-serif;
text-align: center;
pointer-events: none;
}
@media (max-width: 980px) {
body.touch-mobile-ui .tool-tip-popover {
max-width: min(280px, calc(100vw - 24px));
}
}
/* 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; }
@ -839,3 +844,42 @@
.temperature-slider:disabled {
opacity: 0.52;
}
.colony-limit-control {
margin: 8px 0 4px;
padding: 10px 11px;
border: 1px solid rgba(118, 92, 61, 0.16);
border-radius: 14px;
background: rgba(255, 251, 244, 0.56);
}
.colony-limit-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
color: rgba(68, 55, 41, 0.84);
font-size: 12px;
}
.colony-limit-head small { color: var(--muted); font-size: 10px; }
.colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 8px; }
.colony-limit-row { display: grid; grid-template-columns: minmax(0, 1fr) 54px; gap: 5px 7px; align-items: center; min-width: 0; font-size: 11px; color: var(--muted); }
.colony-limit-row span { grid-column: 1 / -1; min-width: 0; }
.colony-limit-slider { width: 100%; min-width: 0; accent-color: rgba(84, 138, 72, 0.92); }
.colony-limit-number { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid rgba(100, 78, 52, 0.22); border-radius: 8px; padding: 5px 6px; background: rgba(255,255,255,0.78); color: var(--ink); font: inherit; text-align: right; }
.colony-limit-status { margin-top: 7px; color: rgba(68, 55, 41, 0.78); font-size: 10px; line-height: 1.35; }
/* v39.15.131: keep the folded tool card usable when other right-side cards are expanded. */
.tool-card.collapsed {
min-height: 64px !important;
height: auto !important;
flex: 0 0 auto !important;
flex-basis: auto !important;
overflow: visible !important;
}
.tool-card.collapsed .panel-card-toggle {
min-height: 46px;
}
.tool-card.collapsed .panel-card-body {
display: none !important;
}

View file

@ -1,12 +1,6 @@
/* CSS split file; preserve manifest load order. */
.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.webp"); }
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); }
.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); }
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); --tool-icon-zoom: 1.36; }
.tool[data-tool="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; }
/* CSS split file; preserve manifest load order. Tool image paths come from TOOL_DEFINITIONS. */
.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; }
.tool[data-tool="shoot"] { --tool-icon-zoom: 1.48; }
.tool.tool-emoji-icon { --tool-icon: none; }
.tool.tool-emoji-icon::before {
@ -28,13 +22,13 @@
.tool[data-tool="reciprocator"].tool-emoji-icon::before,
.tool[data-tool="undo"].tool-emoji-icon::before,
.tool[data-tool="redo"].tool-emoji-icon::before { font-size: 29px; font-family: var(--font, sans-serif); }
.tool[data-tool="delete"],
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; }
.tool[data-tool="delete"] { --tool-icon-zoom: 1.42; }
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.20; }
.tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; }
.tool[data-tool="area_delete"].tool-emoji-icon::before {
font-size: 31px;
line-height: 1;
color: #f3a7aa;
color: #5d7fb9;
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));
@ -303,24 +297,6 @@ meter {
.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; }
.topbar { align-items: flex-start; flex-direction: column; }
.top-left { align-items: flex-start; flex-direction: column; }
.brand { align-items: center; flex-direction: row; gap: 10px; }
.layout { grid-template-columns: 1fr; }
.panel { max-height: none; overflow: visible; }
#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)); }
}
/* Clearer tool category grouping. */
.tool-category[data-tool-category="operate"] { --tool-cat-a: rgba(224,239,255,0.82); --tool-cat-b: rgba(203,226,255,0.74); --tool-cat-line: rgba(82,128,190,0.26); }
.tool-category[data-tool-category="food"] { --tool-cat-a: rgba(236,252,219,0.82); --tool-cat-b: rgba(214,244,194,0.74); --tool-cat-line: rgba(96,156,72,0.26); }

View file

@ -227,7 +227,16 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
align-items: stretch;
gap: 8px;
}
.top-left { gap: 8px; }
.top-left {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.brand {
align-items: center;
flex-direction: row;
gap: 10px;
}
.brand h1,
h1 {
max-width: 100%;
@ -265,9 +274,16 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
gap: 10px;
overflow: visible;
}
.field-choice-grid { grid-template-columns: 1fr; }
.reset-preset-choice-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#gameCanvas {
height: var(--field-mobile-height, 66vh);
min-height: var(--field-mobile-min-height, 460px);
}
.stage-wrap {
position: relative;
width: 100%;
min-height: var(--field-mobile-min-height, 460px);
overflow: hidden;
}
.panel {

View file

@ -137,22 +137,10 @@ body::before {
min-width: 0;
white-space: nowrap;
}
.family-genetic-stats b,
.family-fight-stat b {
.family-genetic-stats 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);
@ -363,12 +351,18 @@ body::before {
white-space: nowrap;
}
.colony-chart-legend i {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10);
cursor: pointer;
transition: opacity 120ms ease, transform 120ms ease;
}
.colony-chart-legend i:hover,
.colony-chart-legend i:focus-visible { transform: scale(1.18); outline: none; }
.colony-chart-legend .series-hidden { opacity: 0.42; text-decoration: line-through; }
.colony-chart-legend .series-hidden i { background: transparent !important; box-shadow: inset 0 0 0 2px currentColor; }
/* Family tree personality tags: consolidated final cascade. */

View file

@ -4,14 +4,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</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.116" />
<link rel="stylesheet" href="css/layout.css?v=39.15.116" />
<link rel="stylesheet" href="css/panel.css?v=39.15.116" />
<link rel="stylesheet" href="css/components.css?v=39.15.116" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.116" />
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.18" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.18" />
<link rel="shortcut icon" href="favicon.ico?v=39.16.18" />
<link rel="stylesheet" href="css/base.css?v=39.16.18" />
<link rel="stylesheet" href="css/layout.css?v=39.16.18" />
<link rel="stylesheet" href="css/panel.css?v=39.16.18" />
<link rel="stylesheet" href="css/components.css?v=39.16.18" />
<link rel="stylesheet" href="css/mobile.css?v=39.16.18" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -71,7 +71,7 @@
<aside class="panel">
<nav id="panelQuickTabs" class="panel-quick-tabs" aria-label="&#x53F3;&#x30E1;&#x30CB;&#x30E5;&#x30FC;&#x306E;&#x30BF;&#x30D6;">
<button type="button" data-panel-tab="game">&#x672C;&#x4F53;</button>
<button type="button" data-panel-tab="selected">&#x305F;&#x308A;&#x306A;&#x3044;&#x30C7;&#x30FC;&#x30BF;</button>
<button type="button" data-panel-tab="selected">&#x305F;&#x308A;&#x306A;&#x3044;<br>&#x30C7;&#x30FC;&#x30BF;</button>
<button type="button" data-panel-tab="tools">&#x9053;&#x5177;</button>
<button type="button" data-panel-tab="colony">&#x30B3;&#x30ED;&#x30CB;&#x30FC;</button>
<button type="button" data-panel-tab="events">&#x30A4;&#x30D9;&#x30F3;&#x30C8;</button>
@ -85,7 +85,7 @@
<div id="selectedInfo" class="selected-info empty">&#x305F;&#x308A;&#x306A;&#x3044;&#x3092;&#x9078;&#x629E;&#x3057;&#x3066;&#x304F;&#x3060;&#x3055;&#x3044;</div>
</section>
<section id="toolCard" class="card tool-card collapsible-card" data-card-key="tools">
<section id="toolCard" class="card tool-card collapsible-card">
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">&#x9053;&#x5177;</span><span class="panel-card-state">&#x6298;&#x308A;&#x7573;&#x307F;</span></button>
<div class="panel-card-body">
<div class="tool-body">
@ -94,11 +94,11 @@
</div>
</section>
<section class="card colony-card collapsible-card" data-card-key="colony">
<section class="card colony-card collapsible-card">
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">&#x30B3;&#x30ED;&#x30CB;&#x30FC;</span><span class="panel-card-state">&#x6298;&#x308A;&#x7573;&#x307F;</span></button>
<div class="panel-card-body">
<div class="stats colony-stats">
<div class="stat"><span>&#x500B;&#x4F53;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x7DCF;&#x6570;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x6B7B;&#x4EA1;</span><strong id="statDead">0</strong></div>
<div class="stat"><span>&#x72B6;&#x614B;</span><strong id="statColonyMood">&#x5B89;&#x5B9A;</strong></div>
<div class="stat ground-stat" data-ground-toggle role="button" tabindex="0"><span>&#x5730;&#x9762;</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="&#x5730;&#x9762;&#x3092;&#x304D;&#x308A;&#x304B;&#x3048;&#x308B;"><strong id="statGroundType">&#x7802;</strong></button></div>
@ -107,13 +107,21 @@
<div class="temperature-control-head"><span>&#x6C17;&#x6E29;</span><strong id="temperatureValue">10&#x2103;</strong><label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> &#x81EA;&#x52D5;</label></div>
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x500B;&#x4F53;&#x6570;</button><button data-chart="life">&#x6B32;&#x6C42;</button><button data-chart="objects">&#x7269;</button></div>
<div class="colony-limit-control" aria-label="コロニー上限設定">
<div class="colony-limit-head"><span>上限設定</span><small>0は上限なし</small></div>
<div class="colony-limit-grid">
<label class="colony-limit-row"><span>たりない個体数</span><input id="tarinaiPopulationLimitSlider" class="colony-limit-slider" type="range" min="0" max="300" step="1" value="0"><input id="tarinaiPopulationLimitInput" class="colony-limit-number" type="number" min="0" max="999" step="1" inputmode="numeric" placeholder="0"></label>
<label class="colony-limit-row"><span>オブジェクト総数</span><input id="objectLimitSlider" class="colony-limit-slider" type="range" min="0" max="1200" step="1" value="0"><input id="objectLimitInput" class="colony-limit-number" type="number" min="0" max="9999" step="1" inputmode="numeric" placeholder="0"></label>
</div>
<div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div>
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x500B;&#x4F53;&#x6570;</button><button data-chart="life">&#x6B32;&#x6C42;</button><button data-chart="objects">環境</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div>
<div id="colonyChart" class="colony-chart" aria-label="&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x306E;&#x63A8;&#x79FB;&#x30B0;&#x30E9;&#x30D5;" role="img"></div>
</div>
</section>
<section class="card log-card collapsible-card" data-card-key="events">
<section class="card log-card collapsible-card">
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">&#x30A4;&#x30D9;&#x30F3;&#x30C8;</span><span class="panel-card-state">&#x6298;&#x308A;&#x7573;&#x307F;</span></button>
<div class="panel-card-body">
<div id="log" class="log"></div>
@ -186,30 +194,26 @@
<div id="visualSettingsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="visualSettingsDialogTitle">
<div class="field-dialog-panel visual-settings-dialog-panel">
<h2 id="visualSettingsDialogTitle">&#x8868;&#x793A;&#x8A2D;&#x5B9A;</h2>
<p class="hint">&#x91CD;&#x3044;&#x8868;&#x73FE;&#x3092;&#x624B;&#x52D5;&#x3067;&#x8ABF;&#x6574;&#x3057;&#x307E;&#x3059;&#x3002;&#x500B;&#x4F53;&#x6570;&#x3084;&#x7269;&#x306E;&#x4E0A;&#x9650;&#x306F;&#x5909;&#x3048;&#x307E;&#x305B;&#x3093;&#x3002;</p>
<p class="hint">&#x81EA;&#x52D5;&#x3067;&#x306F;FPS&#x306B;&#x5FDC;&#x3058;&#x3066;3&#x6BB5;&#x968E;&#x306E;&#x8CA0;&#x8377;&#x5236;&#x5FA1;&#x3092;&#x884C;&#x3044;&#x307E;&#x3059;&#x3002;&#x9AD8;&#x306F;&#x30B7;&#x30DF;&#x30E5;&#x30EC;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x9593;&#x5F15;&#x304D;&#x306A;&#x3057;&#x3001;&#x4F4E;&#x306F;&#x5E38;&#x6642;&#x8EFD;&#x91CF;&#x5316;&#x3067;&#x3059;&#x3002;</p>
<div class="visual-setting-row">
<div><strong>&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;</strong><span>&#x7834;&#x7247;&#x3001;&#x96E8;&#x3001;&#x5149;&#x306E;&#x6F14;&#x51FA;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;">
<button type="button" data-visual-setting="effects" data-visual-value="off">OFF</button>
<button type="button" data-visual-setting="effects" data-visual-value="low">&#x4F4E;</button>
<button type="button" data-visual-setting="effects" data-visual-value="mid">&#x4E2D;</button>
<button type="button" data-visual-setting="effects" data-visual-value="high">&#x9AD8;</button>
<button type="button" data-visual-setting="effects" data-visual-value="high">ON</button>
</div>
</div>
<div class="visual-setting-row">
<div><strong>&#x5F71;</strong><span>&#x5730;&#x9762;&#x306E;&#x843D;&#x3061;&#x5F71;&#x3068;&#x30B9;&#x30D7;&#x30E9;&#x30A4;&#x30C8;&#x306E;&#x5F71;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x5F71;">
<button type="button" data-visual-setting="shadows" data-visual-value="off">OFF</button>
<button type="button" data-visual-setting="shadows" data-visual-value="low">&#x4F4E;</button>
<button type="button" data-visual-setting="shadows" data-visual-value="mid">&#x4E2D;</button>
<button type="button" data-visual-setting="shadows" data-visual-value="high">&#x9AD8;</button>
<button type="button" data-visual-setting="shadows" data-visual-value="high">ON</button>
</div>
</div>
<div class="visual-setting-row">
<div><strong>&#x63CF;&#x753B;</strong><span>&#x88C5;&#x98FE;&#x3068;&#x89E3;&#x50CF;&#x5EA6;&#x306E;&#x8A2D;&#x5B9A;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x63CF;&#x753B;">
<button type="button" data-visual-setting="details" data-visual-value="auto">&#x81EA;&#x52D5;</button>
<button type="button" data-visual-setting="details" data-visual-value="low">&#x4F4E;</button>
<button type="button" data-visual-setting="details" data-visual-value="mid">&#x4E2D;</button>
<button type="button" data-visual-setting="details" data-visual-value="high">&#x9AD8;</button>
</div>
</div>
@ -257,164 +261,168 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.15.116" defer></script>
<script src="js/event_bus.js?v=39.15.116" defer></script>
<script src="js/registry_base.js?v=39.15.116" defer></script>
<script src="js/disease_registry.js?v=39.15.116" defer></script>
<script src="js/sound_pack.js?v=39.15.116" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.116" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.116" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.116" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.116" defer></script>
<script src="js/item_food_definitions.js?v=39.15.116" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.116" defer></script>
<script src="js/item_registry.js?v=39.15.116" defer></script>
<script src="js/data.js?v=39.15.116" defer></script>
<script src="js/ground_types.js?v=39.15.116" defer></script>
<script src="js/input_mode_manager.js?v=39.15.116" defer></script>
<script src="js/math.js?v=39.15.116" defer></script>
<script src="js/geometry_helpers.js?v=39.15.116" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.116" defer></script>
<script src="js/physics_helpers.js?v=39.15.116" defer></script>
<script src="js/placement_preview_system.js?v=39.15.116" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.116" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.116" defer></script>
<script src="js/mechanical_system.js?v=39.15.116" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.116" defer></script>
<script src="js/constraint_system.js?v=39.15.116" defer></script>
<script src="js/physics_world_system.js?v=39.15.116" defer></script>
<script src="js/physics_projection_system.js?v=39.15.116" defer></script>
<script src="js/assets.js?v=39.15.116" defer></script>
<script src="js/audio.js?v=39.15.116" defer></script>
<script src="js/perf_profiler.js?v=39.15.116" defer></script>
<script src="js/render.js?v=39.15.116" defer></script>
<script src="js/sim_core.js?v=39.15.116" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.116" defer></script>
<script src="js/structures.js?v=39.15.116" defer></script>
<script src="js/items.js?v=39.15.116" defer></script>
<script src="js/item_type_initializers.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.116" defer></script>
<script src="js/item_update_policy.js?v=39.15.116" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.116" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.116" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.116" defer></script>
<script src="js/item_runtime.js?v=39.15.116" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.116" defer></script>
<script src="js/item_render_helpers.js?v=39.15.116" defer></script>
<script src="js/item_render_runtime.js?v=39.15.116" defer></script>
<script src="js/burn_motion_util.js?v=39.15.116" defer></script>
<script src="js/ants.js?v=39.15.116" defer></script>
<script src="js/health.js?v=39.15.116" defer></script>
<script src="js/tarinai.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.116" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.116" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.116" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.116" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.116" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.116" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.116" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.116" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.116" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.116" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.116" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.116" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.116" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.116" defer></script>
<script src="js/tarinai_render.js?v=39.15.116" defer></script>
<script src="js/world.js?v=39.15.116" defer></script>
<script src="js/world_view.js?v=39.15.116" defer></script>
<script src="js/family_graph.js?v=39.15.116" defer></script>
<script src="js/world_reset_presets.js?v=39.15.116" defer></script>
<script src="js/world_family_social.js?v=39.15.116" defer></script>
<script src="js/impact_core_system.js?v=39.15.116" defer></script>
<script src="js/impact_response_system.js?v=39.15.116" defer></script>
<script src="js/collision_response_system.js?v=39.15.116" defer></script>
<script src="js/world_combat_effects.js?v=39.15.116" defer></script>
<script src="js/world_environment.js?v=39.15.116" defer></script>
<script src="js/world_temperature_system.js?v=39.15.116" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.116" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.116" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.116" defer></script>
<script src="js/world_ants_system.js?v=39.15.116" defer></script>
<script src="js/weather_system.js?v=39.15.116" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.116" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.116" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.116" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.116" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.116" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.116" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.116" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.116" defer></script>
<script src="js/simulation_systems.js?v=39.15.116" defer></script>
<script src="js/system_order.js?v=39.15.116" defer></script>
<script src="js/simulation.js?v=39.15.116" defer></script>
<script src="js/colony_situation_system.js?v=39.15.116" defer></script>
<script src="js/world_update.js?v=39.15.116" defer></script>
<script src="js/world_tool_actions.js?v=39.15.116" defer></script>
<script src="js/world_placement_log.js?v=39.15.116" defer></script>
<script src="js/world_event_effects.js?v=39.15.116" defer></script>
<script src="js/command_dispatcher.js?v=39.15.116" defer></script>
<script src="js/text_catalog.js?v=39.15.116" defer></script>
<script src="js/ui.js?v=39.15.116" defer></script>
<script src="js/ui_log.js?v=39.15.116" defer></script>
<script src="js/ui_helpers.js?v=39.15.116" defer></script>
<script src="js/ui_selected.js?v=39.15.116" defer></script>
<script src="js/save_schema.js?v=39.15.116" defer></script>
<script src="js/snapshot_system.js?v=39.15.116" defer></script>
<script src="js/restore_coordinator.js?v=39.15.116" defer></script>
<script src="js/history_system.js?v=39.15.116" defer></script>
<script src="js/save_codec.js?v=39.15.116" defer></script>
<script src="js/save_storage.js?v=39.15.116" defer></script>
<script src="js/save_system.js?v=39.15.116" defer></script>
<script src="js/ui_tooltips.js?v=39.15.116" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.116" defer></script>
<script src="js/ui_ground.js?v=39.15.116" defer></script>
<script src="js/ui_tools.js?v=39.15.116" defer></script>
<script src="js/ui_input_shared.js?v=39.15.116" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.116" defer></script>
<script src="js/ui_input_touch.js?v=39.15.116" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.116" defer></script>
<script src="js/ui_bind.js?v=39.15.116" defer></script>
<script src="js/ui_charts.js?v=39.15.116" defer></script>
<script src="js/ui_family_data.js?v=39.15.116" defer></script>
<script src="js/ui_family_async.js?v=39.15.116" defer></script>
<script src="js/ui_family_layout.js?v=39.15.116" defer></script>
<script src="js/ui_family_paths.js?v=39.15.116" defer></script>
<script src="js/ui_family_render.js?v=39.15.116" defer></script>
<script src="js/main.js?v=39.15.116" defer></script>
<script src="js/debug_tools.js?v=39.15.116" defer></script>
<script src="js/version.js?v=39.16.18" defer></script>
<script src="js/event_bus.js?v=39.16.18" defer></script>
<script src="js/domain_ids.js?v=39.16.18" defer></script>
<script src="js/registry_base.js?v=39.16.18" defer></script>
<script src="js/disease_registry.js?v=39.16.18" defer></script>
<script src="js/sound_pack.js?v=39.16.18" defer></script>
<script src="js/deterministic_helpers.js?v=39.16.18" defer></script>
<script src="js/item_tool_metadata.js?v=39.16.18" defer></script>
<script src="js/item_tool_definitions.js?v=39.16.18" defer></script>
<script src="js/item_visual_definitions.js?v=39.16.18" defer></script>
<script src="js/item_food_definitions.js?v=39.16.18" defer></script>
<script src="js/item_effect_definitions.js?v=39.16.18" defer></script>
<script src="js/item_registry.js?v=39.16.18" defer></script>
<script src="js/data.js?v=39.16.18" defer></script>
<script src="js/ground_types.js?v=39.16.18" defer></script>
<script src="js/input_mode_manager.js?v=39.16.18" defer></script>
<script src="js/math.js?v=39.16.18" defer></script>
<script src="js/geometry_helpers.js?v=39.16.18" defer></script>
<script src="js/collision_footprint_system.js?v=39.16.18" defer></script>
<script src="js/physics_helpers.js?v=39.16.18" defer></script>
<script src="js/placement_preview_system.js?v=39.16.18" defer></script>
<script src="js/pin_attachment_system.js?v=39.16.18" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.16.18" defer></script>
<script src="js/mechanical_system.js?v=39.16.18" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.16.18" defer></script>
<script src="js/constraint_system.js?v=39.16.18" defer></script>
<script src="js/physics_world_system.js?v=39.16.18" defer></script>
<script src="js/physics_projection_system.js?v=39.16.18" defer></script>
<script src="js/signal_system.js?v=39.16.18" defer></script>
<script src="js/assets.js?v=39.16.18" defer></script>
<script src="js/audio.js?v=39.16.18" defer></script>
<script src="js/perf_profiler.js?v=39.16.18" defer></script>
<script src="js/display_helpers.js?v=39.16.18" defer></script>
<script src="js/render.js?v=39.16.18" defer></script>
<script src="js/sim_core.js?v=39.16.18" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.16.18" defer></script>
<script src="js/structures.js?v=39.16.18" defer></script>
<script src="js/item_type_catalog.js?v=39.16.18" defer></script>
<script src="js/items.js?v=39.16.18" defer></script>
<script src="js/item_type_initializers.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_support.js?v=39.16.18" defer></script>
<script src="js/item_update_policy.js?v=39.16.18" defer></script>
<script src="js/fire_runtime_system.js?v=39.16.18" defer></script>
<script src="js/robot_cleaner_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.16.18" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.16.18" defer></script>
<script src="js/item_runtime.js?v=39.16.18" defer></script>
<script src="js/structure_lifecycle.js?v=39.16.18" defer></script>
<script src="js/item_render_helpers.js?v=39.16.18" defer></script>
<script src="js/item_render_runtime.js?v=39.16.18" defer></script>
<script src="js/burn_motion_util.js?v=39.16.18" defer></script>
<script src="js/ants.js?v=39.16.18" defer></script>
<script src="js/health.js?v=39.16.18" defer></script>
<script src="js/tarinai.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_spec.js?v=39.16.18" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.16.18" defer></script>
<script src="js/tarinai_identity_social.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_state.js?v=39.16.18" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_effects.js?v=39.16.18" defer></script>
<script src="js/tarinai_needs_core.js?v=39.16.18" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.16.18" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.16.18" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.16.18" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.16.18" defer></script>
<script src="js/tarinai_needs_items.js?v=39.16.18" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.18" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.16.18" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.16.18" defer></script>
<script src="js/tarinai_runtime.js?v=39.16.18" defer></script>
<script src="js/tarinai_render.js?v=39.16.18" defer></script>
<script src="js/world.js?v=39.16.18" defer></script>
<script src="js/world_view.js?v=39.16.18" defer></script>
<script src="js/family_graph.js?v=39.16.18" defer></script>
<script src="js/world_reset_presets.js?v=39.16.18" defer></script>
<script src="js/world_family_social.js?v=39.16.18" defer></script>
<script src="js/impact_core_system.js?v=39.16.18" defer></script>
<script src="js/impact_response_system.js?v=39.16.18" defer></script>
<script src="js/collision_response_system.js?v=39.16.18" defer></script>
<script src="js/world_combat_effects.js?v=39.16.18" defer></script>
<script src="js/world_environment.js?v=39.16.18" defer></script>
<script src="js/world_temperature_system.js?v=39.16.18" defer></script>
<script src="js/world_pathfinding_system.js?v=39.16.18" defer></script>
<script src="js/world_grass_placement_system.js?v=39.16.18" defer></script>
<script src="js/world_spatial_budget.js?v=39.16.18" defer></script>
<script src="js/world_ants_system.js?v=39.16.18" defer></script>
<script src="js/weather_system.js?v=39.16.18" defer></script>
<script src="js/item_update_scheduler.js?v=39.16.18" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_policy.js?v=39.16.18" defer></script>
<script src="js/simulation_environment_system.js?v=39.16.18" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.16.18" defer></script>
<script src="js/simulation_effects_system.js?v=39.16.18" defer></script>
<script src="js/simulation_creature_system.js?v=39.16.18" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.16.18" defer></script>
<script src="js/simulation_ambient_system.js?v=39.16.18" defer></script>
<script src="js/simulation_systems.js?v=39.16.18" defer></script>
<script src="js/system_order.js?v=39.16.18" defer></script>
<script src="js/simulation.js?v=39.16.18" defer></script>
<script src="js/colony_situation_system.js?v=39.16.18" defer></script>
<script src="js/world_update.js?v=39.16.18" defer></script>
<script src="js/world_tool_actions.js?v=39.16.18" defer></script>
<script src="js/world_placement_log.js?v=39.16.18" defer></script>
<script src="js/world_event_effects.js?v=39.16.18" defer></script>
<script src="js/command_dispatcher.js?v=39.16.18" defer></script>
<script src="js/text_catalog.js?v=39.16.18" defer></script>
<script src="js/ui.js?v=39.16.18" defer></script>
<script src="js/ui_log.js?v=39.16.18" defer></script>
<script src="js/ui_helpers.js?v=39.16.18" defer></script>
<script src="js/ui_selected.js?v=39.16.18" defer></script>
<script src="js/save_schema.js?v=39.16.18" defer></script>
<script src="js/snapshot_system.js?v=39.16.18" defer></script>
<script src="js/restore_coordinator.js?v=39.16.18" defer></script>
<script src="js/history_system.js?v=39.16.18" defer></script>
<script src="js/save_codec.js?v=39.16.18" defer></script>
<script src="js/save_storage.js?v=39.16.18" defer></script>
<script src="js/save_system.js?v=39.16.18" defer></script>
<script src="js/ui_tooltips.js?v=39.16.18" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.16.18" defer></script>
<script src="js/ui_ground.js?v=39.16.18" defer></script>
<script src="js/ui_tools.js?v=39.16.18" defer></script>
<script src="js/ui_input_shared.js?v=39.16.18" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.16.18" defer></script>
<script src="js/ui_input_touch.js?v=39.16.18" defer></script>
<script src="js/ui_input_mouse.js?v=39.16.18" defer></script>
<script src="js/ui_bind.js?v=39.16.18" defer></script>
<script src="js/ui_charts.js?v=39.16.18" defer></script>
<script src="js/ui_family_data.js?v=39.16.18" defer></script>
<script src="js/ui_family_async.js?v=39.16.18" defer></script>
<script src="js/ui_family_layout.js?v=39.16.18" defer></script>
<script src="js/ui_family_paths.js?v=39.16.18" defer></script>
<script src="js/ui_family_render.js?v=39.16.18" defer></script>
<script src="js/main.js?v=39.16.18" defer></script>
<script src="js/debug_tools.js?v=39.16.18" defer></script>
</body>
</html>

View file

@ -100,7 +100,6 @@ class AntActor {
this.burnWanderAngle = rand(0, Math.PI * 2);
this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.0);
this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(10, this.r * 2.8), life: 0.18, color: "rgba(255,110,36,0.56)" }));
this.world?.emit?.("fire:ignite-ant", { ant: this, source });
return true;
}
@ -260,7 +259,20 @@ class AntActor {
if (this.dead) return;
const hpBefore = this.hp;
this.age += dt;
if (this.burning || (this.burnTimer || 0) > 0.02) {
if (Number(this.electricShockUntil || 0) > Number(this.world?.time || 0)) {
this.state = "panic";
this.targetId = "";
this.targetToken = 0;
this.targetRef = null;
this.nextTurn = Math.min(Number(this.nextTurn || 0), 0);
this.wanderAngle = Number(this.wanderAngle || 0) + rand(-2.4, 2.4);
const shockSpeed = (this.kind === "queen" ? ANT_QUEEN_SPEED : ANT_WORKER_RETURN_SPEED) * 1.75;
this.vx = Math.cos(this.wanderAngle) * shockSpeed;
this.vy = Math.sin(this.wanderAngle) * shockSpeed;
this.x = clamp(this.x + this.vx * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
this.y = clamp(this.y + this.vy * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
if (this.hp <= 0) this.dieAsCorpse(this.homeNest?.() || null);
} else if (this.burning || (this.burnTimer || 0) > 0.02) {
this.updateBurning(dt);
if (this.hp <= 0) this.dieAsCorpse(this.homeNest?.() || null);
} else if (this.kind === "queen") this.updateQueen(dt);
@ -297,7 +309,6 @@ class AntActor {
target.fearTimer = Math.max(target.fearTimer || 0, 0.55);
target.thought = "\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b";
target.recordChangeCause?.("\u30a2\u30ea\u306b\u904b\u3070\u308c\u305f", "\u72b6\u614b");
this.world?.emit?.("ant:grabbed", { ant: this, target });
audio.antDrag?.();
this.world.drawListDirty = true;
return;
@ -361,7 +372,6 @@ class AntActor {
target.antDraggedBy = this.id;
if (leadHauler && (this.world?.time || 0) - (this.lastCarryEventAt || -999) > 0.75) {
this.lastCarryEventAt = this.world?.time || 0;
this.world?.emit?.("ant:carried", { ant: this, target, lead: true, haulers });
}
target.target = null;
if (targetWasSleeping) {
@ -415,10 +425,9 @@ class AntActor {
if (this.dead) return;
this.dead = true;
audio.antDie?.();
this.world?.emit?.("ant:died", { ant: this, home, reason: "hp" });
const corpse = new Item("ant_corpse", this.x, this.y);
corpse.amount = 24;
this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse);
this.world.addItem?.(corpse, "ant-corpse");
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
if (home) syncAntNestCount(this.world, home);
this.world.ensureItemBuckets?.("ant-corpse");
@ -443,9 +452,9 @@ class AntActor {
base.antCount = ANT_NEST_START_COUNT;
base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT);
if (!force && this.world.placementBlocked?.(base)) return null;
const addedNest = this.world.addItem?.(base, "ant-nest-built") || null;
if (!addedNest) return null;
audio.antNest?.();
this.world.addItem?.(base, "ant-nest-built") || this.world.items.push(base);
this.world.emit?.("ant:nest-built", { nest: base, queen: this });
this.world.ensureSpatial?.("ant-nest-built");
this.world.drawListDirty = true;
this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event");

View file

@ -3,6 +3,7 @@ const images = new Map();
const imageMetrics = new Map();
const imagePromises = new Map();
const imageAssetIndex = new Map();
const imageLastUsedAt = new Map();
const DECOR_ASSETS = [
{ id: "zunchi", path: "assets/objects/zunchi.webp" },
@ -69,6 +70,7 @@ const scaledSpriteCache = new Map();
const stainOverlayCache = new Map();
const SPRITE_CACHE_LIMIT = 128;
const STAIN_CACHE_LIMIT = 64;
const IMAGE_KEEP_IDS = new Set(["smile"]);
const SUNBATH_IMAGE_IDS = new Set(SPRITES
.map(asset => asset?.id || "")
@ -182,6 +184,33 @@ function markImageLoaded(asset, img) {
});
}
function touchImage(id) {
if (id) imageLastUsedAt.set(id, Date.now());
}
function clearCanvasCacheForImage(id) {
const prefix = `${id}:`;
for (const key of [...scaledSpriteCache.keys()]) if (String(key).startsWith(prefix)) scaledSpriteCache.delete(key);
for (const key of [...stainOverlayCache.keys()]) if (String(key).startsWith(prefix)) stainOverlayCache.delete(key);
}
function unloadImageAsset(id) {
if (!id || IMAGE_KEEP_IDS.has(id)) return false;
const img = images.get(id);
if (!img) return false;
clearCanvasCacheForImage(id);
images.delete(id);
imagePromises.delete(id);
imageLastUsedAt.delete(id);
try {
img.onload = null;
img.onerror = null;
img.removeAttribute?.("src");
img.src = "";
} catch (_) {}
return true;
}
function isImageReady(id) {
const img = images.get(id);
return !!(img && img.complete && (img.naturalWidth || img.width));
@ -191,16 +220,19 @@ function loadImageAsset(asset, priority = "auto") {
if (!asset) return Promise.resolve(null);
setupAssetIndex();
primeImageMetrics(asset);
if (isImageReady(asset.id)) return Promise.resolve(images.get(asset.id));
if (isImageReady(asset.id)) { touchImage(asset.id); return Promise.resolve(images.get(asset.id)); }
if (imagePromises.has(asset.id)) return imagePromises.get(asset.id);
const img = images.get(asset.id) || new Image();
img.decoding = "async";
if ("fetchPriority" in img) img.fetchPriority = priority;
img.alphaBounds = ALPHA_BOUNDS[asset.id] || null;
images.set(asset.id, img);
const promise = new Promise((resolve) => {
img.onload = () => { markImageLoaded(asset, img); resolve(img); };
let promise;
promise = new Promise((resolve) => {
img.onload = () => { markImageLoaded(asset, img); touchImage(asset.id); resolve(img); };
img.onerror = () => { resolve(null); };
}).finally(() => {
if (imagePromises.get(asset.id) === promise) imagePromises.delete(asset.id);
});
imagePromises.set(asset.id, promise);
if (!img.src) img.src = window.TARINAI_APP?.withVersion ? window.TARINAI_APP.withVersion(asset.path) : asset.path;
@ -212,6 +244,7 @@ function ensureImage(id) {
const asset = imageAssetIndex.get(id);
if (!asset) return null;
primeImageMetrics(asset);
touchImage(id);
const existing = images.get(id);
if (!existing || !existing.src) loadImageAsset(asset, "low");
return images.get(id) || null;
@ -219,9 +252,9 @@ function ensureImage(id) {
function getRenderableImage(id, fallbackId = "smile") {
if (id) ensureImage(id);
if (isImageReady(id)) return images.get(id);
if (isImageReady(id)) { touchImage(id); return images.get(id); }
if (fallbackId && fallbackId !== id) ensureImage(fallbackId);
if (isImageReady(fallbackId)) return images.get(fallbackId);
if (isImageReady(fallbackId)) { touchImage(fallbackId); return images.get(fallbackId); }
return null;
}
@ -239,19 +272,44 @@ function loadImages(onProgress = null) {
return Promise.all(tasks).then(() => undefined);
}
function trimImageMemory(aggressive = false) {
trimCanvasCache(scaledSpriteCache, aggressive ? 48 : SPRITE_CACHE_LIMIT);
trimCanvasCache(stainOverlayCache, aggressive ? 16 : STAIN_CACHE_LIMIT);
function trimImageMemory(mode = "normal") {
const aggressive = mode === true || mode === "aggressive" || mode === "hidden";
const hidden = mode === "hidden";
trimCanvasCache(scaledSpriteCache, aggressive ? 24 : SPRITE_CACHE_LIMIT);
trimCanvasCache(stainOverlayCache, aggressive ? 8 : STAIN_CACHE_LIMIT);
const now = Date.now();
const maxLoaded = hidden ? 1 : (aggressive ? 20 : 48);
const maxIdleMs = hidden ? 0 : (aggressive ? 20000 : 120000);
const candidates = [];
for (const [id, img] of images) {
if (!img || IMAGE_KEEP_IDS.has(id)) continue;
const lastUsed = imageLastUsedAt.get(id) || 0;
candidates.push({ id, lastUsed });
}
candidates.sort((a, b) => a.lastUsed - b.lastUsed);
let loadedCount = images.size;
let released = 0;
for (const entry of candidates) {
if (!hidden && loadedCount <= maxLoaded && now - entry.lastUsed <= maxIdleMs) continue;
if (unloadImageAsset(entry.id)) {
loadedCount -= 1;
released += 1;
}
}
return { released, loaded: images.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size };
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.hidden) trimImageMemory(true);
if (document.hidden) trimImageMemory("hidden");
});
}
if (typeof window !== "undefined") {
window.setInterval(() => trimImageMemory(false), 45000);
window.setInterval(() => {
const profile = window.TarinaiPerf?.simulationOptimizationTier?.();
trimImageMemory(profile === "aggressive" ? "aggressive" : "normal");
}, 30000);
}
function startBackgroundImageLoading() {
@ -265,10 +323,17 @@ function startBackgroundImageLoading() {
const schedule = window.requestIdleCallback
? (fn, timeout = 1200) => window.requestIdleCallback(fn, { timeout })
: (fn) => setTimeout(fn, 60);
schedule(() => loadGroup(EARLY_IMAGE_IDS, "auto"), 450);
schedule(() => {
for (const asset of [...SPRITES, ...DECOR_ASSETS]) loadImageAsset(asset, "low");
trimImageMemory(false);
const profile = window.TarinaiPerf?.simulationOptimizationTier?.() || "balanced";
const ids = profile === "aggressive" ? [...EARLY_IMAGE_IDS].slice(0, 18) : EARLY_IMAGE_IDS;
loadGroup(ids, "auto");
}, 450);
schedule(() => {
const profile = window.TarinaiPerf?.simulationOptimizationTier?.() || "balanced";
if (profile === "full") {
for (const asset of [...SPRITES, ...DECOR_ASSETS]) loadImageAsset(asset, "low");
}
trimImageMemory(profile === "aggressive" ? "aggressive" : "normal");
}, 2200);
}
@ -298,3 +363,13 @@ function getImageMetrics(idOrImg) {
setupAssetIndex();
if (typeof window !== "undefined") {
window.TarinaiAssetsMemory = Object.freeze({
trim: trimImageMemory,
unload: unloadImageAsset,
stats() {
return { loaded: images.size, pending: imagePromises.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size };
},
});
}

View file

@ -93,9 +93,6 @@ const audio = {
const a = new Audio(path);
a.preload = "auto";
a.volume = this.sampleVolumeFor(key);
a.addEventListener("error", () => {
window.TarinaiEvents.emit("audio:sample-error", { id: key, path });
}, { once: true });
this.samples[key] = a;
}
},

View file

@ -15,10 +15,6 @@
const aabbOverlap = Geometry.aabbOverlap;
const rectOverlapInfo = Geometry.rectOverlapInfo;
const rectsOverlap = Geometry.rectsOverlap;
function pointInRect(x, y, rect, padding = 0) {
return Geometry.pointInRect(x, y, rect, padding);
}
function rectCircleOverlap(rect, cx, cy, radius, margin = 0) {
if (!rect) return false;
const r = Math.max(0, num(radius));
@ -46,7 +42,7 @@
}
return Math.max(14, reach);
}
const radiusFor = typeof global.itemRadiusFor === "function" ? global.itemRadiusFor : ((type, fallback = 12) => fallback);
const radiusFor = global.itemRadiusFor;
if (item.type === "duplicator") {
// \u4FDD\u5B58\u6E08\u307F\u30C7\u30FC\u30BF\u306B\u53E4\u3044 r=34 \u304C\u6B8B\u3063\u3066\u3044\u3066\u3082\u3001\u914D\u7F6E\u5224\u5B9A\u306F\u5916\u89B3\u306B\u8FD1\u3044\u534A\u5F84\u3067\u6271\u3046\u3002
return Math.max(14, Math.min(24, num(item.r, radiusFor(item.type, 24)) || radiusFor(item.type, 24)));
@ -75,7 +71,7 @@
let hit = false;
if (rects.length) {
for (const rect of rects) {
if (pointInRect(x, y, rect, padding)) hit = true;
if (Geometry.pointInRect(x, y, rect, padding)) hit = true;
bestDistance = Math.min(bestDistance, Math.hypot(num(x) - num(rect.cx, item.x), num(y) - num(rect.cy, item.y)));
}
}
@ -95,8 +91,8 @@
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem.isMechanicalType(item.type) || item.type === "rope" || item.type === "rod" || 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", "one_way_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem.isMechanicalType(item.type) || ["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type) || isFenceItemType(item.type));
if (!important.has(item.type) && !isPhysicsItem) return false;
const itemRects = placementRectsFor(worldRef, item);
const itemRadius = itemFootprintRadius(worldRef, item, itemRects);
@ -108,8 +104,8 @@
const otherRects = placementRectsFor(worldRef, other);
const otherRadius = itemFootprintRadius(worldRef, other, otherRects);
const hasMechanism = global.TarinaiMechanicalSystem.isMechanicalType(item.type) || global.TarinaiMechanicalSystem.isMechanicalType(other.type);
const itemIsFence = typeof global.isFenceItemType === "function" ? global.isFenceItemType(item.type) : /fence/.test(String(item.type || ""));
const otherIsFence = typeof global.isFenceItemType === "function" ? global.isFenceItemType(other.type) : /fence/.test(String(other.type || ""));
const itemIsFence = global.isFenceItemType(item.type);
const otherIsFence = global.isFenceItemType(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;
@ -129,14 +125,16 @@
}
global.TarinaiCollisionFootprints = Object.freeze({
// Mechanical-body narrowphase consumes the same SAT primitives as
// placement/hit testing. Keep these delegates public so collision-enabled
// rotators, reciprocators, and poison blocks can physically interact.
rectLocalPoint,
rectWorldVector,
rectCorners,
rectAxes,
projectPoints,
aabbOverlap,
rectOverlapInfo,
rectLocalPoint,
rectWorldVector,
pointInRect,
rectsOverlap,
rectCircleOverlap,
isSoftPlacementIgnoredType,

View file

@ -26,6 +26,7 @@
const now = worldRef.time || 0;
if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map();
const balls = ballLikeItems(worldRef);
let solved = 0;
for (const a of balls) {
if (!a || a.dead || !isBallLikeType(a.type)) continue;
const ar = a.r || 18;
@ -88,6 +89,7 @@
const relNormal = rvx * nx + rvy * ny;
if (relNormal > 0 && d >= hitRadius - 0.5) continue;
worldRef.ballCollisionMemo.set(key, now);
solved += 1;
const restitution = (a.type === "balloon" || b.type === "balloon") ? 0.58 : 0.86;
const impulse = -(1 + restitution) * relNormal / 2;
if (Number.isFinite(impulse)) {
@ -109,6 +111,7 @@
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)" }));
}
}
return solved;
}
@ -238,10 +241,15 @@
const relativeSpeed = Math.hypot(relVx, relVy);
const playIntent = t.state === "play_ball" || t.target === ball;
const playful = t.shouldApplyPersonalityBehavior?.("openness", 1);
const impulseBase = isBalloon ? 34 : 48;
const impulse = clamp(impulseBase + relativeSpeed * (isBalloon ? 0.48 : 0.72) + (playIntent ? (isBalloon ? 34 : 52) : 0) + (playful ? (isBalloon ? 18 : 24) : 0), impulseBase, isDangerous ? 360 : (isBalloon ? 165 : 260));
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + nx * impulse + (t.vx || 0) * (isBalloon ? 0.32 : 0.52);
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + ny * impulse + (t.vy || 0) * (isBalloon ? 0.32 : 0.52);
const balloonPlayLikeBall = isBalloon && (playIntent || playful);
const impulseBase = isBalloon && !balloonPlayLikeBall ? 34 : 48;
const impulse = clamp(
impulseBase + relativeSpeed * (isBalloon && !balloonPlayLikeBall ? 0.48 : 0.72) + (playIntent ? 52 : 0) + (playful ? 24 : 0),
impulseBase,
isDangerous ? 360 : (isBalloon && !balloonPlayLikeBall ? 165 : 260)
);
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : (isBalloon && !balloonPlayLikeBall ? 0.72 : 0.92)) + nx * impulse + (t.vx || 0) * (isBalloon && !balloonPlayLikeBall ? 0.32 : 0.52);
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : (isBalloon && !balloonPlayLikeBall ? 0.72 : 0.92)) + ny * impulse + (t.vy || 0) * (isBalloon && !balloonPlayLikeBall ? 0.32 : 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);
@ -270,18 +278,36 @@
}
function resolveTarinaiHighSpeedCollisions(worldRef, dt) {
function highSpeedThreshold() {
return PHYSICAL_DAMAGE_SPEED_THRESHOLD;
}
function resolveTarinaiHighSpeedCollisions(worldRef, dt, opts = {}) {
if (!worldRef) return undefined;
const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD;
const threshold = Math.max(1, Number(opts.threshold || PHYSICAL_DAMAGE_SPEED_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 providedFastSources = Array.isArray(opts.fastSources);
const liveSource = providedFastSources ? opts.fastSources : (worldRef.tarinai || []);
const fastSources = [];
for (const t of liveSource) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox(t)) continue;
if (Math.hypot(t.vx || 0, t.vy || 0) >= threshold) fastSources.push(t);
}
if (!fastSources.length) return;
const live = providedFastSources ? null : [];
if (!providedFastSources) {
for (const t of worldRef.tarinai || []) {
if (t && !t.dead && !worldRef.isTarinaiHiddenInNestBox(t)) live.push(t);
}
if (live.length < 2) return;
live.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 maxCandidates = Math.max(24, Math.min(320, fastSources.length * 12));
const seenPairs = new Set();
for (let i = 0; i < fastSources.length; i += 1) {
const a = fastSources[i];
const avx = a.vx || 0;
const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy);
@ -292,10 +318,16 @@
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 moveDist = Math.hypot(asx, asy);
const queryX = ax0 + asx * 0.5;
const queryY = ay0 + asy * 0.5;
const queryRadius = Math.max(140, (a.radius || 22) * 1.6 + moveDist + threshold * Math.max(0.016, Number(dt || 0.016) || 0.016) + 96);
const nearby = typeof worldRef.nearbyTarinai === "function" ? (worldRef.nearbyTarinai(queryX, queryY, queryRadius, true) || []) : (live || worldRef.tarinai || []);
for (const b of nearby) {
if (!b || b === a || b.dead || worldRef.isTarinaiHiddenInNestBox(b)) continue;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (seenPairs.has(key)) continue;
seenPairs.add(key);
if (now - (worldRef.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
const bvx = b.vx || 0;
const bvy = b.vy || 0;
@ -390,11 +422,11 @@
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({
highSpeedThreshold,
resolveBallBallCollisions,
resolveBallInteractions,
resolveTarinaiHighSpeedCollisions,

View file

@ -66,7 +66,6 @@
if (!worldRef) return fail("missing_world");
if (!target || target.dead) return fail("missing_target");
target.favorite = !target.favorite;
emit(worldRef, "selection:favorite-changed", { selected: target, favorite: Boolean(target.favorite) });
return ok({ selected: target, favorite: Boolean(target.favorite) });
}
@ -83,7 +82,6 @@
if (!worldRef.toolSizes) worldRef.toolSizes = {};
worldRef.toolSizes[id] = next;
worldRef.toolSize = next;
emit(worldRef, "tool:size-changed", { toolId: id, size: next });
return ok({ toolId: id, size: next });
}
@ -103,7 +101,6 @@
function togglePaused(worldRef) {
if (!worldRef) return fail("missing_world");
worldRef.paused = !worldRef.paused;
emit(worldRef, "simulation:pause-changed", { paused: Boolean(worldRef.paused) });
return ok({ paused: Boolean(worldRef.paused) });
}
@ -113,7 +110,6 @@
const list = Array.isArray(speeds) && speeds.length ? speeds.map(Number).filter(Number.isFinite) : Array.from(DEFAULT_SPEEDS);
const i = list.indexOf(Number(worldRef.speed) || 1);
worldRef.speed = list[(i + 1 + list.length) % list.length];
emit(worldRef, "simulation:speed-changed", { speed: worldRef.speed });
return ok({ speed: worldRef.speed });
}
@ -125,7 +121,6 @@
if (typeof worldRef.handleClick !== "function") return fail("missing_pointer_handler");
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 || "" });
}

View file

@ -16,22 +16,17 @@
return bodyApi()?.ensureConstraint?.(item, worldRef || item?.world || null, { syncFromLegacy: opts.syncFromLegacy === true }) || item?.physicsConstraint || null;
}
function applyConstraintState(item) { return bodyApi()?.applyConstraintState?.(item, item?.physicsConstraint) || false; }
function normalizeConstraintState(item) { return bodyApi()?.normalizeConstraintState?.(item, item?.physicsConstraint) || item?.physicsConstraint || null; }
function commitConstraint(item) {
if (!item || (item.type !== "rope" && item.type !== "rod")) return null;
if (!item || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type)) return null;
const c = ensureConstraint(item, item.world || null, { syncFromLegacy: false });
if (!c) return null;
normalizeConstraintState(item);
return c;
}
function commitMechanicalBody(item, reason = "constraint-moved-body") {
function commitMechanicalBody(item) {
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.
// Constraint pulls mutate item.x/y as a scratch pose; preserve that pose in
// the authoritative physics body before the next frame begins.
bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
return true;
}
@ -58,28 +53,12 @@
function markConstraintSpatialDirty(worldRef, item, reason = "constraint-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 = Number(item.x || 0) || 0;
const y = Number(item.y || 0) || 0;
const r = Number(item.r || item.radius || 0) || 0;
const lastX = Number(item._lastConstraintSpatialDirtyX);
const lastY = Number(item._lastConstraintSpatialDirtyY);
const lastR = Number(item._lastConstraintSpatialDirtyR);
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._nextConstraintSpatialDirtyAt || 0);
if (moved || due) {
item._lastConstraintSpatialDirtyX = x;
item._lastConstraintSpatialDirtyY = y;
item._lastConstraintSpatialDirtyR = r;
item._nextConstraintSpatialDirtyAt = now + Math.max(0.016, Number(minInterval || 0.065) || 0.065);
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;
const marked = global.TarinaiPhysicsProjectionSystem.markSpatialDirty(world, item, reason, threshold, minInterval) ?? false;
if (world.constraintDirtyStatsThisFrame) {
const key = marked ? "marked" : "coalesced";
world.constraintDirtyStatsThisFrame[key] = (world.constraintDirtyStatsThisFrame[key] || 0) + 1;
}
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.coalesced = (world.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
return false;
return marked;
}
function mechanicalPowered(item) {
@ -89,8 +68,6 @@
function commitConstraintMovedMechanical(item, reason = "constraint-projection") {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
markConstraintSpatialDirty(item.world, item, reason, 0.22, 0.045);
item.world && (item.world.drawListDirty = true);
return true;
@ -115,7 +92,21 @@
if (!endpoint || !worldRef) return null;
const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.fuzzyFallback === true;
const cached = endpoint._ref;
if (cached && !cached.dead && (cached.id === endpoint.id || endpoint.kind === "tarinai")) return cached;
if (cached && !cached.dead && (cached.id === endpoint.id || endpoint.kind === "tarinai" || endpoint.kind === "ant")) return cached;
if (endpoint.kind === "ant") {
let found = (worldRef.ants || []).find(ant => ant && !ant.dead && ant.id === endpoint.id) || null;
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
const source = worldRef.nearbyAnts?.(endpoint.x, endpoint.y, 48, true) || worldRef.ants || [];
found = source.find(ant => ant && !ant.dead && distXY(ant.x, ant.y, endpoint.x, endpoint.y) <= Math.max(16, (ant.r || 6) * 2.4)) || null;
if (found) {
endpoint.id = found.id;
endpoint.fuzzyResolve = false;
endpoint.fuzzyFallback = false;
}
}
endpoint._ref = found || null;
return found || null;
}
if (endpoint.kind === "tarinai") {
let found = null;
if (worldRef.liveTarinaiById) found = worldRef.liveTarinaiById(endpoint.id, endpoint.liveToken ?? null) || null;
@ -269,7 +260,7 @@
return localDelta > 0.001 || worldDelta > 0.001;
};
for (const item of worldRef.items || []) {
if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) continue;
if (!item || item.dead || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type)) continue;
const a = remap(ep(item, 0));
const b = remap(ep(item, 1));
if (a || b) {
@ -287,9 +278,9 @@
function endpointWorld(endpoint, worldRef, depth = 0, seen = null) {
const obj = resolveEndpointObject(endpoint, worldRef);
if (obj && (obj.type === "rope" || obj.type === "rod") && worldRef) obj.world = worldRef;
if (obj && ["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && worldRef) obj.world = worldRef;
if (!obj) return Number.isFinite(endpoint?.x) && Number.isFinite(endpoint?.y) ? { x: endpoint.x, y: endpoint.y, obj: null } : null;
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint.attachT))) {
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && Number.isFinite(Number(endpoint.attachT))) {
if (depth > 6) return { x: obj.x || 0, y: obj.y || 0, obj };
const guard = seen || new Set();
if (guard.has(obj.id)) return { x: obj.x || 0, y: obj.y || 0, obj };
@ -326,7 +317,7 @@
if (obj.type === "balloon") return 0.22;
if (obj.type === "ball") return 0.7;
if (obj.type === "zunchi") return 0.55;
if (obj.type === "rope" || obj.type === "rod") return 1.6;
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type)) return 1.6;
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;
@ -344,7 +335,7 @@
const moveX = dx * strength;
const moveY = dy * strength;
if (Math.hypot(moveX, moveY) < 0.001) return false;
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint?.attachT))) {
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && Number.isFinite(Number(endpoint?.attachT))) {
const t = clamp(Number(endpoint.attachT || 0), 0, 1);
let changed = false;
const wA = 1 - t;
@ -418,7 +409,7 @@
const delta = clamp(torque * 0.00018, -0.11, 0.11);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0015) return false;
pset(obj, "spin", clamp(ps(obj, "spin", 0) * 0.82 + delta, -1.45, 1.45), "constraint-passive-rotator-project");
commitMechanicalBody(obj, "constraint-passive-rotator-project");
commitMechanicalBody(obj);
return true;
}
if (obj.type && obj.type.includes("fence")) return false;
@ -446,11 +437,11 @@
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.085 / (step * 60) : 0.46 / (step * 60);
const compliance = mode === "rod" ? 0.085 / (step * 60) : 0;
const target = delta / (1 + compliance);
const cap = mode === "rod"
? Math.max(6.0, Math.min(46, length * maxFraction))
: Math.max(1.5, length * maxFraction);
: Math.max(12, length * maxFraction);
const corr = clamp(target, -cap, cap);
let changed = false;
changed = moveEndpointObject(pa.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), ep(item, 0), dt, strength, 0, mode) || changed;
@ -459,7 +450,15 @@
};
let changed = false;
if (mode === "rope") {
changed = solveOnce(a, b, 0.58, 0.044) || changed;
// A rope may go slack, but must not become longer than its recorded length.
// Re-read anchors between projections because the endpoint objects move.
const iterations = 8;
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;
if (distXY(aa.x, aa.y, bb.x, bb.y) <= length + 0.25) break;
changed = solveOnce(aa, bb, 1.0, 0.35) || changed;
}
} else {
const iterations = 5;
for (let i = 0; i < iterations; i += 1) {
@ -738,6 +737,21 @@
}
}
}
for (let iter = 0; iter < 4; iter += 1) {
ps[0].x = a.x; ps[0].y = a.y;
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
for (let i = 0; i < ps.length - 1; i += 1) {
const p1 = ps[i], p2 = ps[i + 1];
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const d = Math.max(0.001, Math.hypot(dx, dy));
if (d <= rest + 0.02) continue;
const corrX = dx * ((d - rest) / d);
const corrY = dy * ((d - rest) / d);
if (i === 0) { p2.x -= corrX; p2.y -= corrY; }
else if (i + 1 === ps.length - 1) { p1.x += corrX; p1.y += corrY; }
else { p1.x += corrX * 0.5; p1.y += corrY * 0.5; p2.x -= corrX * 0.5; p2.y -= corrY * 0.5; }
}
}
ps[0].x = a.x; ps[0].y = a.y;
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
const mid = ps[Math.floor(ps.length * 0.5)] || ps[0];
@ -832,13 +846,126 @@
return changed;
}
function endpointVelocity(obj, dt = 0.016) {
if (!obj) return { x: 0, y: 0 };
if (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) {
return { x: ps(obj, "xv", Number(obj.vx || 0) || 0), y: ps(obj, "yv", Number(obj.vy || 0) || 0) };
}
const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016));
const vx = Number(obj.vx);
const vy = Number(obj.vy);
return {
x: Number.isFinite(vx) ? vx : ((Number(obj.x || 0) - Number(obj.prevX ?? obj.x ?? 0)) / step),
y: Number.isFinite(vy) ? vy : ((Number(obj.y || 0) - Number(obj.prevY ?? obj.y ?? 0)) / step),
};
}
function addSpringVelocity(obj, dvx, dvy, worldRef) {
if (!obj || obj.dead || obj._heldByPlayer || obj.playerHeld) return false;
if (!Number.isFinite(dvx) || !Number.isFinite(dvy) || Math.hypot(dvx, dvy) < 0.001) return false;
if (obj.radius && obj.name) {
// Tarinai steering and state updates damp ordinary velocity quickly. Put
// most spring recoil in the external impulse channel so the body keeps
// oscillating instead of immediately cancelling the bounce.
obj.vx = (Number(obj.vx) || 0) + dvx * 0.14;
obj.vy = (Number(obj.vy) || 0) + dvy * 0.14;
obj.impulseVx = (Number(obj.impulseVx) || 0) + dvx * 1.26;
obj.impulseVy = (Number(obj.impulseVy) || 0) + dvy * 1.26;
global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(obj, 0.016, "spring-recoil");
return true;
}
if (obj.type === "poison_block") {
pset(obj, "xv", ps(obj, "xv", 0) + dvx, "spring-impulse");
pset(obj, "yv", ps(obj, "yv", 0) + dvy, "spring-impulse");
global.TarinaiMechanicalSystem.wakeItem?.(obj, "spring-impulse");
return true;
}
if (obj.type === "reciprocator") {
const axis = global.TarinaiMechanicalSystem.axis?.(obj) || reciprocatorAxis(obj);
const along = dvx * axis.x + dvy * axis.y;
if (Math.abs(along) < 0.001) return false;
pset(obj, "slideSpeed", ps(obj, "slideSpeed", 0) + along, "spring-rail-impulse");
global.TarinaiMechanicalSystem.wakeItem?.(obj, "spring-rail-impulse");
return true;
}
if (obj.type === "rotator" || (obj.type && obj.type.includes("fence"))) return false;
obj.vx = (Number(obj.vx) || 0) + dvx;
obj.vy = (Number(obj.vy) || 0) + dvy;
global.TarinaiItemUpdateScheduler?.wakeItem?.(worldRef || obj.world || null, obj, 0.35);
return true;
}
function applyPassiveSpring(item, a, b, dt, rest, worldRef) {
if (!a?.obj || !b?.obj) return false;
const dx = b.x - a.x;
const dy = b.y - a.y;
const distance = Math.max(0.001, Math.hypot(dx, dy));
const delta = distance - rest;
const nx = dx / distance;
const ny = dy / distance;
const va = endpointVelocity(a.obj, dt);
const vb = endpointVelocity(b.obj, dt);
const relativeSpeed = (vb.x - va.x) * nx + (vb.y - va.y) * ny;
const ma = endpointMass(a.obj);
const mb = endpointMass(b.obj);
const ia = Number.isFinite(ma) ? 1 / Math.max(0.1, ma) : 0;
const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0;
const inverseSum = ia + ib;
if (inverseSum <= 0) return false;
const step = Math.max(0.008, Math.min(0.04, Number(dt || 0.016) || 0.016));
// Hooke force plus light damping. There is no time-driven target: the
// spring only reacts when external motion stretches or compresses it.
// Deliberately under-damped and stiff: it remains passive, but external
// displacement produces a large, visibly oscillating recoil.
const stiffness = 92.0;
const damping = 0.06;
const impulse = clamp((delta * stiffness + relativeSpeed * damping) * step, -250, 250);
let changed = false;
if (Math.abs(impulse) > 0.015) {
changed = addSpringVelocity(a.obj, nx * impulse * (ia / inverseSum), ny * impulse * (ia / inverseSum), worldRef) || changed;
changed = addSpringVelocity(b.obj, -nx * impulse * (ib / inverseSum), -ny * impulse * (ib / inverseSum), worldRef) || changed;
}
// A small positional correction prevents unbounded stretching while
// retaining enough inertia for visible elastic recoil.
if (Math.abs(delta) > 1.0) {
const correction = clamp(delta * 0.004, -0.95, 0.95);
changed = moveEndpointObject(a.obj, nx * correction * (ia / inverseSum), ny * correction * (ia / inverseSum), ep(item, 0), step, 0.92, 0, "spring") || changed;
changed = moveEndpointObject(b.obj, -nx * correction * (ib / inverseSum), -ny * correction * (ib / inverseSum), ep(item, 1), step, 0.92, 0, "spring") || changed;
}
return changed;
}
function updateSpringLink(item, dt, worldRef) {
const before = beginLinkUpdate(item, worldRef, "spring");
if (!before) return false;
if (before.detached) return true;
const { a, b } = before;
const rest = primeLinkState(item, a, b);
const changed = applyPassiveSpring(item, a, b, dt, rest, worldRef);
const fa = endpointWorld(ep(item, 0), worldRef) || a;
const fb = endpointWorld(ep(item, 1), worldRef) || b;
if (fa && fb) {
item.x = (fa.x + fb.x) * 0.5;
item.y = (fa.y + fb.y) * 0.5;
item.r = Math.max(18, distXY(fa.x, fa.y, fb.x, fb.y) * 0.5);
ep(item, 0).x = fa.x; ep(item, 0).y = fa.y;
ep(item, 1).x = fb.x; ep(item, 1).y = fb.y;
const residual = Math.abs(distXY(fa.x, fa.y, fb.x, fb.y) - rest);
if (changed || residual > 0.8) lset(item, "awakeUntil", Number(worldRef?.time || 0) + 0.36);
}
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "spring-constraint", 0.22, 0.035);
return changed;
}
function updateLink(item, dt, worldRef) {
if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) return false;
if (!item || item.dead || !["rope", "rod", "spring"].includes(item.type)) return false;
item.world = worldRef || item.world || null;
applyConstraintState(item);
let changed = false;
if (item.type === "rope") changed = updateFlexibleLink(item, dt, worldRef);
else if (item.type === "rod") changed = updateRigidLink(item, dt, worldRef);
else if (item.type === "spring") changed = updateSpringLink(item, dt, worldRef);
commitConstraint(item);
return changed;
}
@ -898,6 +1025,7 @@
}
}
}
if (item.type === "spring") return true;
const interval = item.type === "rope" ? 0.32 : 0.55;
if ((Number(item._nextPassiveLinkUpdateAt || 0) || 0) <= now) {
item._nextPassiveLinkUpdateAt = now + interval;
@ -914,7 +1042,7 @@
function linkScheduleScore(item, worldRef) {
const now = Number(worldRef?.time || 0) || 0;
let score = item?.type === "rod" ? 2.0 : 1.0;
let score = item?.type === "spring" ? 6.0 : (item?.type === "rod" ? 2.0 : 1.0);
if (ls(item, "awakeUntil", 0) > now) score += 4.0;
if (item?._linkLastStamp && item._linkPendingStamp && item._linkLastStamp !== item._linkPendingStamp) score += 2.5;
score += Math.min(3, Math.hypot(ls(item, "midVx", 0), ls(item, "midVy", 0)) / 40);
@ -931,6 +1059,7 @@
const links = [];
for (const item of worldRef.itemsOfType("rope") || []) if (item && !item.dead) links.push(item);
for (const item of worldRef.itemsOfType("rod") || []) if (item && !item.dead) links.push(item);
for (const item of worldRef.itemsOfType("spring") || []) if (item && !item.dead) links.push(item);
if (!links.length) {
worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 };
return 0;
@ -947,8 +1076,8 @@
let rodCorrections = 0;
const pending = [];
for (const item of ordered) {
ensureConstraint(item, worldRef, { syncFromLegacy: false });
applyConstraintState(item);
if (opts.statePrepared === true) ensureConstraint(item, worldRef, { syncFromLegacy: false });
else applyConstraintState(item);
visited += 1;
if (!linkNeedsUpdate(item, worldRef)) {
skipped += 1;
@ -980,16 +1109,13 @@
}
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
return global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
}
const api = Object.freeze({
updateFlexibleLink,
updateRigidLink,
updateSpringLink,
updateWorld,
});
global.TarinaiConstraintSystem = api;
global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance, snapEndpointToTarget, remapLinksForEditedMechanicalItem });
global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, snapEndpointToTarget, remapLinksForEditedMechanicalItem });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -54,6 +54,7 @@ const CONFIG = {
reproductionCooldown: 18,
dayLength: 120,
standardTemperature: 15,
summerTemperatureBoost: 5,
climateTemperatureMin: -20,
climateTemperatureMax: 50,
temperatureMin: -Infinity,

View file

@ -3,6 +3,7 @@
(function () {
const params = new URLSearchParams(location.search || "");
const enabled = params.get("debug") === "1" || params.get("debug") === "true";
window.__tarinaiDebugOverlayEnabled = enabled;
if (!enabled) return;
const host = document.createElement("div");
@ -102,6 +103,30 @@
return Object.entries(map || {}).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([k, v]) => `${k}:${v}`).join(", ") || "-";
}
function saveStatsLines() {
const codec = window.TarinaiSaveCodec;
const stats = codec?.getLastEncodeStats?.();
if (!stats) return [];
const load = codec?.getLastDecodeStats?.();
const sections = stats.sections || {};
const details = stats.details || {};
const rawTotal = Math.max(1, Number(stats.rawBinaryBytes || stats.binaryBytes) || 0);
const part = key => {
const bytes = Number(sections[key]) || 0;
return `${bytes}B/${Math.round(bytes / rawTotal * 100)}%`;
};
const ageSeconds = Math.max(0, Math.round((Date.now() - (Number(stats.createdAt) || Date.now())) / 1000));
const decode = load ? ` decode:${fmtMs(load.decodeMs)}ms` : "";
return [
`save stats ${stats.hashChars || 0} chars stored:${stats.binaryBytes || 0}B raw:${stats.rawBinaryBytes || stats.binaryBytes || 0}B ${stats.compressed ? "deflate" : "plain"} encode:${fmtMs(stats.encodeMs)}ms${decode} age:${ageSeconds}s`,
` save format:${part("format")} strings:${part("stringTable")}(${details.stringTableEntries || 0}) world:${part("world")}`,
` save tarinai basic:${part("tarinaiBasic")} seed:${part("tarinaiSeed")} rel:${part("tarinaiRelationships")} count:${details.tarinaiCount || 0} relRows:${details.relationshipRows || 0} parents:${details.parentLinks || 0}`,
` save items index:${part("itemIndex")} normal:${part("normalItems")}(${details.normalItemCount || 0}) physics:${part("physicsItems")}(${details.physicsItemCount || 0})`,
` save extra normal:${details.normalItemExtraEncodedBytes || 0}B(raw ${details.normalItemExtraRawUtf8Bytes || 0}B) physics:${details.physicsItemExtraEncodedBytes || 0}B(raw ${details.physicsItemExtraRawUtf8Bytes || 0}B) seedRaw:${details.birthSeedRawUtf8Bytes || 0}B`,
` save accounted:${stats.accountedBytes || 0}B unaccounted:${stats.unaccountedBytes || 0}B compressed:${stats.compressedBytes || 0}B saved:${stats.compressionSavedBytes || 0}B text:${stats.textBits || 15}bit payload:${stats.payloadChars || 0} chars`,
];
}
function perfLines(perf) {
const self = (perf.topSelf || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 10);
const incl = (perf.topInclusive || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 6);
@ -123,6 +148,9 @@
if ((diag.creatures?.smooth || 0) > 0) hints.push(`smooth motion ${diag.creatures.smooth}`);
if ((diag.scheduler?.activeRealtime || 0) > 120) hints.push(`realtime items ${diag.scheduler.activeRealtime}`);
if ((diag.scheduler?.heap || 0) > 700) hints.push(`scheduled heap ${diag.scheduler.heap}`);
if ((diag.effects?.skipped || 0) > 0) hints.push(`effect update skip ${diag.effects.skipped}`);
if ((diag.effectRender?.skipped || 0) > 0) hints.push(`effect draw skip ${diag.effectRender.skipped}`);
if ((diag.creatures?.fullBudgetSkips || 0) > 0) hints.push(`creature bg skip ${diag.creatures.fullBudgetSkips}`);
if ((diag.items?.bucketRebuildsTotal || 0) > 0 && diag.items?.bucketsDirty) hints.push("item buckets dirty");
const m = diag.scheduler?.mechanicalStats || diag.items?.mechanicalStats || diag.scheduler?.physics?.mechanical || null;
if (m?.candidates > 250 || m?.pairs > 180) hints.push(`mechanical candidates ${m.candidates || "?"} pairs ${m.pairs || m.solved || "?"}`);
@ -144,19 +172,23 @@
const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?";
const creature = diag.creatures || {};
const scheduler = diag.scheduler || {};
const effects = diag.effects || {};
const effectRender = diag.effectRender || {};
const terrain = diag.terrain || {};
const lines = [
`tarinai v${window.TARINAI_VERSION || "?"} debug profiler:${perf.enabled ? "on" : "off"}${Date.now() < copiedMessageUntil ? " copied" : " copy:button/select"}`,
`fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms visual effects:${visual.effects || "?"} shadows:${visual.shadows || "?"} details:${visual.details || perf.tier || "?"} dprScale:${perf.dprScale || 1}`,
`fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms profile:${perf.profile || "?"} visual effects:${visual.effects || "?"} shadows:${visual.shadows || "?"} details:${visual.details || perf.tier || "?"} dprScale:${perf.dprScale || 1}`,
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} items ${(w?.items || []).length} ants ${(w?.ants || []).length} effects ${(w?.effects || []).length}`,
`visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}`,
`creatures full:${creature.full ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
...saveStatsLines(),
`visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}/${effectRender.budget ?? metrics["render.effectDrawBudget"] ?? "?"} skip:${effectRender.skipped ?? metrics["render.effectDrawSkipped"] ?? "?"}`,
`creatures full:${creature.full ?? 0} bg:${creature.fullBudgetUsed ?? 0}/${creature.fullBudget ?? "?"} bgSkip:${creature.fullBudgetSkips ?? 0} offSkip:${creature.offscreenSkipped ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
`creature lanes ${topMap(creature.lanes, 6)} smoothStates ${topMap(creature.smoothStates, 4)}`,
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} partial:${diag.spatial?.partialRebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} deferred:${diag.spatial?.deferredReads ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
`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 ?? "?"} fenceCCD:${Boolean((scheduler.physics?.mechanical || scheduler.mechanicalStats)?.fenceSensitive)}`,
`effects update:${effects.updated ?? 0}/${effects.budget ?? effects.total ?? 0} skipped:${effects.skipped ?? 0} compact:${effects.compacted ?? 0} dropped:${effects.dropped ?? 0} render:${effectRender.drawn ?? 0}/${effectRender.budget ?? "?"} rSkip:${effectRender.skipped ?? 0} level:${effectRender.level || visual.effects || "?"}`,
`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)}`,

View file

@ -26,18 +26,16 @@
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); });
const deterministicFrame = (worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6);
const deterministicUnit = (worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, 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) + deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => 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 && 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
// Keep the historical bare high-level 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;

36
js/display_helpers.js Normal file
View file

@ -0,0 +1,36 @@
"use strict";
// Layer: display/helpers
// Shared canvas and display-label helpers used by render, item, creature, and weather modules.
(function (global) {
const WEATHER_LABELS = Object.freeze({ sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" });
function canvasEllipsizeText(ctx, text, maxW) {
let s = String(text || "");
const limit = Number(maxW);
if (!ctx || !Number.isFinite(limit) || limit <= 0) return s ? "\u2026" : "";
if (ctx.measureText(s).width <= limit) return s;
while (s.length > 1 && ctx.measureText(`${s}\u2026`).width > limit) s = s.slice(0, -1);
return `${s}\u2026`;
}
function roundedRect(ctx, x, y, w, h, r) {
if (!ctx) return;
const radius = Math.max(0, Math.min(Math.abs(Number(r) || 0), Math.abs(Number(w) || 0) * 0.5, Math.abs(Number(h) || 0) * 0.5));
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
function weatherLabel(weather = "sunny") {
return WEATHER_LABELS[String(weather || "sunny")] || WEATHER_LABELS.sunny;
}
global.canvasEllipsizeText = canvasEllipsizeText;
global.roundedRect = roundedRect;
global.weatherLabel = weatherLabel;
})(typeof window !== "undefined" ? window : globalThis);

70
js/domain_ids.js Normal file
View file

@ -0,0 +1,70 @@
"use strict";
// Layer: data/domain-ids
// Owns current domain identifier order shared by UI, simulation labels, and save schema.
// Old save compatibility is intentionally not preserved; current encode/decode only need
// to agree within the same app build.
(function (global) {
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]);
const MOOD_IDS = Object.freeze(["relaxed", "devastation", "crisis", "confusion", "fearful", "disease_spread", "weakened", "breeding", "happy", "overcrowded", "isolated"]);
const STATE_IDS = Object.freeze(["idle", "sleep"]);
const POWER_MODE_IDS = Object.freeze(["", "protein", "niteropu"]);
const SIZE_MODE_IDS = Object.freeze(["", "giant_drug", "dwarf_drug"]);
const LIFE_MODE_IDS = Object.freeze(["", "mercury"]);
const PIN_STATE_IDS = Object.freeze(["loose", "lodged", "falling", "stuck"]);
const STAGE_IDS = Object.freeze(["fresh", "aging", "old", "dry"]);
function indexMap(list) {
return Object.freeze(Object.fromEntries(list.map((id, index) => [id, index])));
}
const FIELD_INDEX = indexMap(FIELD_IDS);
const GROUND_INDEX = indexMap(GROUND_IDS);
const WEATHER_INDEX = indexMap(WEATHER_IDS);
const MOOD_INDEX = indexMap(MOOD_IDS);
function enumIndex(list, value, fallback = 0) {
const i = list.indexOf(String(value ?? ""));
return i >= 0 ? i : fallback;
}
function enumValue(list, index, fallback = "") {
const i = Number(index) | 0;
return list[i] ?? fallback ?? list[0] ?? "";
}
function canonicalFrom(list, value, fallback) {
const id = String(value || fallback || list[0] || "");
return list.includes(id) ? id : (fallback || list[0] || "");
}
function canonicalFieldId(value = "garden") { return canonicalFrom(FIELD_IDS, value, "garden"); }
function canonicalGroundId(value = "soil") { return canonicalFrom(GROUND_IDS, value, "soil"); }
function canonicalWeatherId(value = "sunny") { return canonicalFrom(WEATHER_IDS, value, "sunny"); }
function canonicalMoodId(value = "relaxed") { return canonicalFrom(MOOD_IDS, value, "relaxed"); }
global.TarinaiDomainIds = Object.freeze({
FIELD_IDS,
GROUND_IDS,
WEATHER_IDS,
MOOD_IDS,
STATE_IDS,
POWER_MODE_IDS,
SIZE_MODE_IDS,
LIFE_MODE_IDS,
PIN_STATE_IDS,
STAGE_IDS,
FIELD_INDEX,
GROUND_INDEX,
WEATHER_INDEX,
MOOD_INDEX,
enumIndex,
enumValue,
canonicalFieldId,
canonicalGroundId,
canonicalWeatherId,
canonicalMoodId,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,5 +1,7 @@
"use strict";
const TARINAI_PUBLIC_EVENT_TYPES = new Set(["audio:play", "selection:changed", "tool:selected", "world:phase", "log:entry", "tool:placed"]);
class TarinaiEventBus {
constructor() {
this.handlers = new Map();
@ -15,12 +17,28 @@ class TarinaiEventBus {
list.add(handler);
}
hasDirectListeners(type) {
if (!type) return false;
return Boolean(this.handlers.get(type)?.size || 0);
}
hasWildcardListeners(type) {
if (!type || !TARINAI_PUBLIC_EVENT_TYPES.has(String(type))) return false;
return Boolean(this.handlers.get("*")?.size || 0);
}
hasListeners(type) {
if (!type) return false;
return Boolean(this.hasDirectListeners(type) || this.hasWildcardListeners(type));
}
emit(type, detail = {}) {
if (!type) return null;
const event = { type, detail: detail || {}, at: performance?.now?.() ?? Date.now() };
const direct = this.handlers.get(type);
const wildcard = TARINAI_PUBLIC_EVENT_TYPES.has(String(type)) ? this.handlers.get("*") : null;
if (!(direct?.size || 0) && !(wildcard?.size || 0)) return null;
const event = { type, detail: detail || {}, at: performance?.now?.() ?? Date.now() };
if (direct) for (const handler of [...direct]) this.safeCall(handler, event);
const wildcard = this.handlers.get("*");
if (wildcard) for (const handler of [...wildcard]) this.safeCall(handler, event);
return event;
}

View file

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

View file

@ -18,11 +18,12 @@ function updateFirecracker(item, dt, worldRef) {
const FIRE_PANIC_INTERVAL = 1.20;
const FIRE_WATER_INTERVAL = 0.16;
const FIRE_CLUSTER_SCAN_INTERVAL = 0.45;
const FIRE_INTERACTION_BUDGET_DESKTOP = 18;
const FIRE_INTERACTION_BUDGET_MOBILE = 10;
const FIRE_MAX_ACTIVE_DESKTOP = 72;
const FIRE_MAX_ACTIVE_MOBILE = 44;
const GRASS_FIRE_EXTINGUISH_RATE = 0.56;
const NEST_BOX_FIRE_EVACUATE_AFTER = 10.0;
const NEST_BOX_FIRE_MIN_LIFE = 14.0;
const NEST_BOX_FIRE_WET_REIGNITE_DELAY = 1.65;
const NEST_BOX_FIRE_WATER_INTERVAL = 0.16;
const NEST_BOX_FIRE_EFFECT_INTERVAL = 0.28;
function fireTempMultiplier(worldRef, x, y, entity = null) {
const weather = String(worldRef?.weather || "");
@ -35,7 +36,8 @@ function updateFirecracker(item, dt, worldRef) {
function pushFireEffect(worldRef, x, y, size = 12, seed = null) {
if (!worldRef?.effects || typeof Effect !== "function") return;
if ((worldRef.effects.length || 0) > 180) return;
const effectCap = Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().effectCap || 220));
if ((worldRef.effects.length || 0) >= effectCap) return;
const a = global.deterministicAngle(worldRef, "fire-effect-angle", seed || x, y, worldRef.time || 0);
const speed = global.deterministicRange(worldRef, "fire-effect-speed", 3, 15, seed || x, y);
worldRef.effects.push(new Effect("flame", x, y, {
@ -50,10 +52,9 @@ function updateFirecracker(item, dt, worldRef) {
function fireInteractionBudget(worldRef) {
const frame = Math.floor((Number(worldRef?.time || 0) || 0) * 60);
if (!worldRef._fireInteractionBudget || worldRef._fireInteractionBudget.frame !== frame) {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
worldRef._fireInteractionBudget = {
frame,
remaining: mobile ? FIRE_INTERACTION_BUDGET_MOBILE : FIRE_INTERACTION_BUDGET_DESKTOP,
remaining: Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().fireInteractionBudget || 14)),
};
}
return worldRef._fireInteractionBudget;
@ -97,7 +98,6 @@ function updateFirecracker(item, dt, worldRef) {
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
t.sleeping = false;
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_BURNING_TEXT, wake: true });
worldRef?.emit?.("fire:ignite", { tarinai: t, source });
return true;
}
@ -123,7 +123,144 @@ function updateFirecracker(item, dt, worldRef) {
ant.state = "panic";
ant.targetId = "";
ant.targetRef = null;
worldRef?.emit?.("fire:ignite-ant", { ant, source });
return true;
}
function isIgnitableNestBox(item) {
return Boolean(item && !item.dead && item.type === "nest_box");
}
function nestBoxFireRadius(box) {
return Math.max(30, (Number(box?.r || 42) || 42) * 1.36);
}
function nestBoxFireActive(box) {
return Boolean(isIgnitableNestBox(box) && (box.burning || (box.burnTimer || 0) > 0.02 || (box.nestFireEvacuationTimer || 0) > 0.02));
}
function igniteNestBoxByFire(box, source = null, worldRef = null) {
if (!isIgnitableNestBox(box)) return false;
const now = Number(worldRef?.time || box.world?.time || 0) || 0;
if (now < Number(box.nestFireWetUntil || 0)) return false;
const already = nestBoxFireActive(box);
box.burning = true;
box.burnTimer = Math.max(Number(box.burnTimer || 0) || 0, NEST_BOX_FIRE_MIN_LIFE);
if (!already) {
box.nestFireStartedAt = now;
box.nestFireEvacuationTimer = NEST_BOX_FIRE_EVACUATE_AFTER;
box.nestFireEvacuated = false;
box.flameWaterCooldown = 0;
box.nestFireEffectCooldown = 0;
worldRef?.log?.("巣箱に火が燃え移った。", "danger");
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.35, life: 0.30, color: "rgba(255,122,48,0.56)" }));
}
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire");
worldRef?.markItemBucketsDirty?.("nest-box-fire");
worldRef?.markSpatialDirty?.("nest-box-fire");
if (worldRef) worldRef.drawListDirty = true;
return !already;
}
function extinguishNestBoxFire(box, worldRef = null, reason = "water") {
if (!isIgnitableNestBox(box) || !nestBoxFireActive(box)) return false;
box.burning = false;
box.burnTimer = 0;
box.nestFireEvacuationTimer = 0;
box.nestFireEvacuated = false;
box.nestFireStartedAt = 0;
box.nestFireWetUntil = reason === "water" ? ((Number(worldRef?.time || box.world?.time || 0) || 0) + NEST_BOX_FIRE_WET_REIGNITE_DELAY) : 0;
box.flameWaterCooldown = 0;
box.nestFireEffectCooldown = 0;
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.22, life: 0.24, color: "rgba(108,192,236,0.62)" }));
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire-extinguished");
worldRef?.markItemBucketsDirty?.("nest-box-fire-extinguished");
worldRef?.markSpatialDirty?.("nest-box-fire-extinguished");
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function evacuateBurningNestBox(box, worldRef = null, reason = "nest-box-fire") {
if (!isIgnitableNestBox(box) || !worldRef || box.nestFireEvacuated) return 0;
const occupants = global.TarinaiNestSleepSystem?.nestBoxOccupants
? global.TarinaiNestSleepSystem.nestBoxOccupants(worldRef, box, Infinity)
: (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id);
box.nestFireEvacuated = true;
let count = 0;
const now = Number(worldRef?.time || box.world?.time || 0) || 0;
for (const t of occupants || []) {
if (!t || t.dead) continue;
t._burningNestExitBoxId = box.id || t._burningNestExitBoxId || "";
t._burningNestExitUntil = Math.max(Number(t._burningNestExitUntil || 0) || 0, now + 1.8);
t.leaveNestBox?.(box);
igniteTarinaiByFire(t, box, worldRef, true);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 1.2);
t.fearTimer = Math.max(t.fearTimer || 0, 2.4);
count += 1;
}
if (count) {
worldRef.log?.(`炎上した巣箱から${count}匹のたりないが追い出された。`, "danger", { participants: occupants });
worldRef.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.55, life: 0.32, color: "rgba(255,92,38,0.58)" }));
worldRef.markSpatialDirty?.(reason);
worldRef.drawListDirty = true;
}
return count;
}
function burnOutNestBox(box, worldRef = null, reason = "burnout") {
if (!isIgnitableNestBox(box)) return false;
if (!box.nestFireEvacuated) evacuateBurningNestBox(box, worldRef, "nest-box-fire-burnout-evacuate");
box.burning = false;
box.burnTimer = 0;
box.nestFireEvacuationTimer = 0;
box.nestFireEvacuated = true;
box.nestFireStartedAt = 0;
box.nestFireWetUntil = 0;
box.flameWaterCooldown = 0;
box.nestFireEffectCooldown = 0;
box.amount = 0;
if (box.isStructure) {
box.hp = 0;
box.dead = true;
}
worldRef?.log?.("巣箱が焼失した。", "danger");
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.45, life: 0.34, color: "rgba(90,54,38,0.58)" }));
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-burnout");
worldRef?.markItemBucketsDirty?.("nest-box-burnout");
worldRef?.markSpatialDirty?.("nest-box-burnout");
worldRef?.compactItems?.();
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function updateNestBoxFire(box, dt, worldRef) {
if (!isIgnitableNestBox(box) || !nestBoxFireActive(box) || !worldRef) return false;
dt = Math.max(0, Math.min(0.24, Number(dt || 0) || 0));
box.burning = true;
box.burnTimer = Math.max(0, Number(box.burnTimer || 0) - dt);
box.nestFireEvacuationTimer = Math.max(0, Number(box.nestFireEvacuationTimer || 0) - dt);
box.flameWaterCooldown = Math.max(0, Number(box.flameWaterCooldown || 0) - dt);
box.nestFireEffectCooldown = Math.max(0, Number(box.nestFireEffectCooldown || 0) - dt);
if (box.flameWaterCooldown <= 0) {
box.flameWaterCooldown = NEST_BOX_FIRE_WATER_INTERVAL;
const water = contactWaterDrop(worldRef, box.x, box.y, nestBoxFireRadius(box));
if (water) {
consumeWaterForFire(worldRef, water, Math.max(12, (box.r || 42) * 0.34));
extinguishNestBoxFire(box, worldRef, "water");
return true;
}
}
if (!box.nestFireEvacuated && box.nestFireEvacuationTimer <= 0) {
evacuateBurningNestBox(box, worldRef, "nest-box-fire-evacuate");
}
if (box.nestFireEffectCooldown <= 0) {
box.nestFireEffectCooldown = NEST_BOX_FIRE_EFFECT_INTERVAL;
pushFireEffect(worldRef, box.x, box.y - (box.r || 42) * 0.35, Math.max(13, (box.r || 42) * 0.40), box);
}
if (box.burnTimer <= 0.01) {
burnOutNestBox(box, worldRef, "burnout");
return true;
}
worldRef.drawListDirty = true;
return true;
}
@ -183,7 +320,6 @@ function updateFirecracker(item, dt, worldRef) {
zunchi.stageTimer = Math.max(0, Number(zunchi.stageTimer || 0) + dt * 2.4);
if (!wasBurning) {
pushFireEffect(worldRef, zunchi.x, zunchi.y - Math.max(5, (zunchi.r || 14) * 0.2), Math.max(10, zunchi.r || 14), zunchi);
worldRef?.emit?.("fire:ignite-zunchi", { item: zunchi, source });
}
if ((zunchi.amount || 0) <= 0.5) zunchi.amount = 0;
worldRef?.markTerrainDirtyAt?.(zunchi.x, zunchi.y, Math.max(zunchi.r || 15, 28), "zunchi-fire");
@ -199,7 +335,7 @@ function updateFirecracker(item, dt, worldRef) {
const spreadRadius = r + 16;
let changed = false;
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, spreadRadius + 32, true) || []) {
if (!t || t.dead) continue;
if (!t || t.dead || t.insideNestBoxId || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(item.x, item.y, t.x, t.y);
if (d <= spreadRadius + Math.max(t.radius || 18, 12) * 0.72) changed = igniteTarinaiByFire(t, item, worldRef, false) || changed;
}
@ -217,6 +353,7 @@ function updateFirecracker(item, dt, worldRef) {
if (!extinguished && (item.amount || 0) > 0) changed = igniteGrassByFire(it, item, worldRef, dt) || changed;
}
if (it.type === "zunchi" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 15, 12)) changed = igniteZunchiByFire(it, item, worldRef, dt) || changed;
if (it.type === "nest_box" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 42, 28) * 1.15) changed = igniteNestBoxByFire(it, item, worldRef) || changed;
if (it.type === "water" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 10, 8)) {
consumeWaterForFire(worldRef, it, Math.max(6, r * 0.45));
item.amount = 0;
@ -230,7 +367,7 @@ function updateFirecracker(item, dt, worldRef) {
if (!item || !worldRef) return;
const r = Math.max(170, (item.r || 14) + 170);
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, r, true) || []) {
if (!t || t.dead) continue;
if (!t || t.dead || t.insideNestBoxId || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
panicTarinaiFromFire(t, item, worldRef);
}
}
@ -301,8 +438,7 @@ function updateFirecracker(item, dt, worldRef) {
}
function activeFireLimit() {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
return mobile ? FIRE_MAX_ACTIVE_MOBILE : FIRE_MAX_ACTIVE_DESKTOP;
return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().activeFireLimit || 60));
}
function activeFires(worldRef) {
@ -315,8 +451,11 @@ function updateFirecracker(item, dt, worldRef) {
let best = null;
let bestD = Infinity;
const exclude = opts.exclude || null;
for (const fire of fires) {
if (!fire || fire.dead) continue;
const searchRadius = Math.max(96, Number(opts.mergeRadius || opts.radius || 48) * 5.0);
const candidates = typeof worldRef?.nearbyItems === "function" ? (worldRef.nearbyItems(x, y, searchRadius, true) || []) : fires;
const scan = candidates.length ? candidates : fires;
for (const fire of scan) {
if (!fire || fire.dead || fire.type !== "fire") continue;
if (exclude && fire === exclude) continue;
const d = distXY(x, y, fire.x, fire.y);
if (d < bestD) { best = fire; bestD = d; }
@ -405,9 +544,10 @@ function updateFirecracker(item, dt, worldRef) {
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
fire.vx = 0;
fire.vy = 0;
worldRef.addItem?.(fire, "spawn-fire", { terrain: false });
const addedFire = worldRef.addItem?.(fire, "spawn-fire", { terrain: false }) || null;
if (!addedFire) return null;
worldRef.drawListDirty = true;
return fire;
return addedFire;
}
function scatterFireFrom(item, worldRef) {
@ -415,15 +555,17 @@ function updateFirecracker(item, dt, worldRef) {
const existing = activeFires(worldRef).length;
const limit = activeFireLimit();
const room = Math.max(0, limit - existing);
const desired = Math.max(5, Math.round(8 * Math.max(0.75, Math.min(1.4, (item.r || 15) / 15))));
const count = Math.max(3, Math.min(desired, room > 0 ? room : 3));
const sizeScale = clamp((Number(item.r || 15) || 15) / 15, 0.45, 3.0);
const flameMass = Math.pow(sizeScale, 1.28);
const desired = Math.max(2, Math.round(5 + 5.5 * flameMass));
const count = Math.max(2, Math.min(desired, room > 0 ? room : Math.max(2, Math.round(2 + 2.5 * Math.min(flameMass, 1.6)))));
for (let i = 0; i < count; i++) {
const a = (Math.PI * 2 * i / count) + global.deterministicRange(worldRef, "flame-cracker-angle", -0.20, 0.20, item, i);
const d = global.deterministicRange(worldRef, "flame-cracker-radius", 28, 118, item, i);
const d = global.deterministicRange(worldRef, "flame-cracker-radius", 18 + 10 * sizeScale, 68 + 56 * flameMass, item, i);
const fire = spawnFireAt(worldRef, item.x + Math.cos(a) * d, item.y + Math.sin(a) * d, {
radius: global.deterministicRange(worldRef, "flame-cracker-fire-radius", 10, 18, item, i),
amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 55, 120, item, i),
life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 4.0, 8.5, item, i),
radius: global.deterministicRange(worldRef, "flame-cracker-fire-radius", 6 + 3 * sizeScale, 10 + 6.5 * flameMass, item, i),
amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 22 + 34 * flameMass, 46 + 84 * flameMass, item, i),
life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 2.4 + 1.0 * sizeScale, 4.6 + 3.2 * flameMass, item, i),
source: item,
});
if (fire) {
@ -433,7 +575,7 @@ function updateFirecracker(item, dt, worldRef) {
}
pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item);
}
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * 3.0), life: 0.36, color: "rgba(255,126,54,0.58)" }));
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * (2.4 + sizeScale * 0.65)), life: 0.30 + sizeScale * 0.045, color: "rgba(255,126,54,0.58)" }));
item.amount = 0;
worldRef.markItemBucketsDirty?.("flame-firecracker");
worldRef.markSpatialDirty?.("flame-firecracker");
@ -458,10 +600,14 @@ function updateFirecracker(item, dt, worldRef) {
fireTempMultiplier,
contactWaterDrop,
consumeWaterForFire,
igniteTarinaiByFire,
panicTarinaiFromFire,
igniteAntByFire,
igniteGrassByFire,
igniteNestBoxByFire,
extinguishNestBoxFire,
updateNestBoxFire,
burnOutNestBox,
nestBoxFireActive,
spawnFireAt: (worldRef, x, y, opts = {}) => spawnFireAt(worldRef, x, y, opts),
scatterFireFrom,
compactWorldFires,

View file

@ -145,6 +145,20 @@
&& num(y) >= num(rect.top) - pad && num(y) <= num(rect.bottom) + pad;
}
function circleOverlapsRect(x, y, radius, rect, padding = 0) {
if (!rect) return false;
const rr = Math.max(0, num(radius)) + Math.max(0, num(padding));
if (rect.oriented) {
const local = rectLocalPoint(rect, x, y);
const clx = clampValue(local.x, -num(rect.halfW), num(rect.halfW));
const cly = clampValue(local.y, -num(rect.halfH), num(rect.halfH));
return Math.hypot(local.x - clx, local.y - cly) < rr;
}
const cx = clampValue(num(x), num(rect.left), num(rect.right));
const cy = clampValue(num(y), num(rect.top), num(rect.bottom));
return Math.hypot(num(x) - cx, num(y) - cy) < rr;
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
const dx = num(bx) - num(ax);
const dy = num(by) - num(ay);
@ -179,6 +193,26 @@
return segmentAabbHit(a.x, a.y, b.x, b.y, -(r.halfW || 0) - padding, -(r.halfH || 0) - padding, (r.halfW || 0) + padding, (r.halfH || 0) + padding);
}
function oneWayFenceAllowsMotion(rect, prevX, prevY, motionX, motionY) {
if (!rect?.oneWay) return false;
const nx = num(rect.oneWayNx);
const ny = num(rect.oneWayNy, -1);
const prevSide = (num(prevX) - num(rect.cx)) * nx + (num(prevY) - num(rect.cy)) * ny;
const motion = num(motionX) * nx + num(motionY) * ny;
const sourceMargin = Math.max(1, num(rect.halfH) * 0.2);
return motion > 0.01 || (prevSide < -sourceMargin && motion >= -0.01);
}
function oneWayFenceAllows(obj, rect, motionX = null, motionY = null) {
if (!rect?.oneWay || !obj) return false;
const hinted = Number.isFinite(Number(motionX)) || Number.isFinite(Number(motionY));
const vx = hinted ? num(motionX) : num(obj.vx) + num(obj.impulseVx);
const vy = hinted ? num(motionY) : num(obj.vy) + num(obj.impulseVy);
const prevX = Number.isFinite(Number(obj.prevX)) ? Number(obj.prevX) : num(obj.x) - vx * 0.016;
const prevY = Number.isFinite(Number(obj.prevY)) ? Number(obj.prevY) : num(obj.y) - vy * 0.016;
return oneWayFenceAllowsMotion(rect, prevX, prevY, vx, vy);
}
global.TarinaiGeometry = Object.freeze({
num,
clampValue,
@ -194,8 +228,11 @@
rectOverlapInfo,
rectsOverlap,
pointInRect,
circleOverlapsRect,
pointSegmentDistance,
segmentAabbHit,
segmentIntersectsOrientedRect,
oneWayFenceAllowsMotion,
oneWayFenceAllows,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,12 +1,12 @@
"use strict";
const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
const GROUND_TYPE_ORDER = window.TarinaiDomainIds.GROUND_IDS;
const GROUND_TYPES = Object.freeze({
// \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 }),
// \u30c7\u30d5\u30a9\u30eb\u30c8\u5730\u9762ID\u3002\u8868\u793a\u540d\u306f\u300c\u5730\u9762\u300d\u3002
soil: Object.freeze({ id: "soil", label: "砂", description: "普通の砂地。", 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 }),
blanket: Object.freeze({ id: "blanket", label: "\u3075\u308f\u3075\u308f\u6bdb\u5e03", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u306b\u304f\u3044\u3002\u8349\u306f\u751f\u3048\u306a\u3044", stressMultiplier: 0.5, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 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 }),
@ -21,7 +21,7 @@ function groundExists(id = "soil") {
}
function groundLabel(id = "soil") {
return groundDefinition(id).label || "\u7802";
return groundDefinition(id).label || "\u5730\u9762";
}
function normalizedGroundStressMultiplier(id = "soil") {
@ -64,7 +64,7 @@ function nextGroundTypeId(current = "soil") {
function groundTooltipText(id = "soil") {
const def = groundDefinition(id);
const desc = def.description || "\u30af\u30ea\u30c3\u30af\u3067\u5207\u308a\u66ff\u3048";
return `${def.label || "\u3058\u3081\u3093"}\n${desc}`;
return `${def.label || "\u5730\u9762"}\n${desc}`;
}
window.TarinaiGround = Object.freeze({

View file

@ -32,12 +32,14 @@ const HEALTH = (() => {
dwarfDrug: "\u77ee\u5c0f\u85ac",
freeze: "\u51CD\u7D50",
heatstroke: "\u71B1\u4E2D\u75C7",
cleaning: "掃除",
});
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
function causeLabel(reason = "") {
const text = String(reason || "");
if (!text) return "";
if (/掃除|ロボ掃除機|robot.?cleaner|cleaned/.test(text)) return CAUSES.cleaning;
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
if (/\u51CD\u7D50|\u4F4E\u6E29|\u5BD2\u3055|cold|freeze|freezing/i.test(text)) return CAUSES.freeze;
if (/\u71B1\u4E2D\u75C7|\u9AD8\u6E29|\u6691\u3055|heat|hot/i.test(text)) return CAUSES.heatstroke;
@ -128,6 +130,8 @@ const HEALTH = (() => {
}
if (direct === CAUSES.fire || recent === CAUSES.fire || t.lastMajorDamageCause === CAUSES.fire) return CAUSES.fire;
if (direct === CAUSES.heatstroke || recent === CAUSES.heatstroke || t.lastMajorDamageCause === CAUSES.heatstroke) return CAUSES.heatstroke;
if (direct === CAUSES.freeze || recent === CAUSES.freeze || t.lastMajorDamageCause === CAUSES.freeze) return CAUSES.freeze;
if (recentMajor && t.lastMajorDamageCause === CAUSES.collision && !(t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) return CAUSES.collision;
if (hasLingeringMajorDamage(t) && (!direct || direct === t.lastMajorDamageCause || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) {
return weakenedDeathReasonFor(t.lastMajorDamageCause);
@ -163,7 +167,7 @@ const HEALTH = (() => {
const cause = dominantDeathCause(t, text, opts) || CAUSES.accident;
if (cause.includes(WEAKENED_SUFFIX)) return cause;
if (opts.fromDamage) return cause;
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu && cause !== CAUSES.fire && cause !== CAUSES.weakness && cause !== CAUSES.freeze && cause !== CAUSES.heatstroke;
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu && cause !== CAUSES.fire && cause !== CAUSES.weakness && cause !== CAUSES.freeze && cause !== CAUSES.heatstroke && cause !== CAUSES.cleaning;
if (canWeaken && (opts.weakened || (hasLingeringMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause);
return cause;
}
@ -293,8 +297,7 @@ const HEALTH = (() => {
let cause = "";
if (actualLoss > 0.01) {
cause = rememberDamage(t, actualLoss, reason);
t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason });
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);
const plushie = (t.world?.ownedItemsFor?.(t.id) || 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();
const majorDamageReacted = maybeTriggerMajorDamageReaction(t, actualLoss, cause || reason);

View file

@ -19,13 +19,96 @@
function snapshotKey(snapshot) {
try {
const clone = cloneSnapshot(snapshot);
return JSON.stringify(clone);
// Snapshot payloads are produced as plain serializable data; stringify them
// directly for duplicate suppression instead of deep-cloning once just to
// compute the history key. The stored history entry is still cloned below.
return JSON.stringify(snapshot);
} catch (_) {
return "";
}
}
const DELETE_MARK = Object.freeze({ __delete: true });
const HISTORY_FULL_INTERVAL = 6;
const HISTORY_DELTA_MIN_SAVING = 0.72;
function isPlainObject(value) {
if (!value || typeof value !== "object") return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function diffValue(prev, next) {
if (Object.is(prev, next)) return undefined;
if (Array.isArray(prev) || Array.isArray(next)) {
const a = JSON.stringify(prev);
const b = JSON.stringify(next);
return a === b ? undefined : cloneSnapshot(next);
}
if (!isPlainObject(prev) || !isPlainObject(next)) return cloneSnapshot(next);
const patch = {};
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of keys) {
if (!(key in next)) {
patch[key] = DELETE_MARK;
continue;
}
const child = diffValue(prev[key], next[key]);
if (child !== undefined) patch[key] = child;
}
return Object.keys(patch).length ? patch : undefined;
}
function applyPatch(base, patch) {
if (patch === undefined) return cloneSnapshot(base);
if (!isPlainObject(patch) || Array.isArray(patch)) return cloneSnapshot(patch);
const out = isPlainObject(base) ? cloneSnapshot(base) : {};
for (const [key, value] of Object.entries(patch)) {
if (value && value.__delete === true && Object.keys(value).length === 1) {
delete out[key];
continue;
}
out[key] = isPlainObject(value) && isPlainObject(out[key]) ? applyPatch(out[key], value) : cloneSnapshot(value);
}
return out;
}
function materializeHistoryEntry(stack, entry) {
if (!entry) return null;
if (entry.snapshot) return cloneSnapshot(entry.snapshot);
const sequence = [...(stack || []), entry];
let start = -1;
for (let i = sequence.length - 1; i >= 0; i -= 1) {
if (sequence[i]?.snapshot) { start = i; break; }
}
if (start < 0) return null;
let snap = cloneSnapshot(sequence[start].snapshot);
for (let i = start + 1; i < sequence.length; i += 1) {
const patch = sequence[i]?.patch;
if (patch !== undefined) snap = applyPatch(snap, patch);
}
return snap;
}
function makeHistoryEntry(world, snap, key, label, stack) {
const fullEntry = { snapshot: cloneSnapshot(snap), key, label: String(label || "action"), time: world.time || 0 };
const stackLen = Array.isArray(stack) ? stack.length : 0;
if (stackLen <= 0 || stackLen % HISTORY_FULL_INTERVAL === 0) return fullEntry;
const prev = materializeHistoryEntry(stack.slice(0, -1), stack[stack.length - 1]);
if (!prev) return fullEntry;
const patch = diffValue(prev, snap);
if (patch === undefined) return { patch: {}, key, label: String(label || "action"), time: world.time || 0 };
try {
const fullSize = JSON.stringify(fullEntry.snapshot).length;
const patchSize = JSON.stringify(patch).length;
if (patchSize > fullSize * HISTORY_DELTA_MIN_SAVING) return fullEntry;
} catch (_) {
return fullEntry;
}
return { patch, key, label: String(label || "action"), time: world.time || 0 };
}
function capture(worldRef = global.world, label = "action") {
const world = ensure(worldRef);
if (!world || world._historyRestoring) return false;
@ -33,18 +116,19 @@
if (!snap) return false;
const key = snapshotKey(snap);
if (key && key === world._lastUndoSnapshotKey) return false;
world._undoStack.push({ snapshot: cloneSnapshot(snap), key, label: String(label || "action"), time: world.time || 0 });
world._undoStack.push(makeHistoryEntry(world, snap, key, label, world._undoStack));
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
world._redoStack.length = 0;
world._lastUndoSnapshotKey = key;
return true;
}
function restore(worldRef, entry) {
if (!worldRef || !entry?.snapshot) return false;
function restore(worldRef, entry, stack = []) {
const snapshot = materializeHistoryEntry(stack, entry);
if (!worldRef || !snapshot) return false;
worldRef._historyRestoring = true;
try {
global.TarinaiRestoreCoordinator.restoreSnapshot(cloneSnapshot(entry.snapshot), worldRef, { syncUi: true });
global.TarinaiRestoreCoordinator.restoreSnapshot(snapshot, worldRef, { syncUi: true });
worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot.createSnapshot(worldRef));
return true;
} finally {
@ -57,9 +141,9 @@
if (!world || !world._undoStack.length) return { ok: false, reason: "empty" };
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 (current) world._redoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "redo", world._redoStack));
if (world._redoStack.length > MAX_HISTORY) world._redoStack.splice(0, world._redoStack.length - MAX_HISTORY);
const ok = restore(world, prev);
const ok = restore(world, prev, world._undoStack);
return { ok, label: prev.label || "" };
}
@ -68,9 +152,9 @@
if (!world || !world._redoStack.length) return { ok: false, reason: "empty" };
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 (current) world._undoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "undo", world._undoStack));
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
const ok = restore(world, next);
const ok = restore(world, next, world._redoStack);
return { ok, label: next.label || "" };
}

View file

@ -27,11 +27,11 @@
const pb = global.TarinaiPhysicsBodySystem || null;
const links = [];
if (worldRef.itemsOfType) {
for (const linkType of ["rope", "rod"]) {
for (const linkType of ["rope", "rod", "spring"]) {
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 worldRef.items || []) if (link && !link.dead && ["rope", "rod", "spring"].includes(link.type)) links.push(link);
}
for (const link of links) {
const endpoints = [pb?.endpoint?.(link, 0), pb?.endpoint?.(link, 1)];

View file

@ -28,7 +28,6 @@
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);
@ -93,9 +92,11 @@
const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || [];
const seen = new Set(items);
// 大きな機構は中心が近傍セル外でも当たる可能性がある。
// 中心ではなく外接半径で追加走査して、長い棒・往復幅にも衝突させる。
if (Array.isArray(worldRef.items)) {
for (const it of worldRef.items) {
// 全item走査ではなく、機構type bucketだけを追加確認する。
const mechanicalTypes = ["rotator", "poison_block", "reciprocator"];
for (const type of mechanicalTypes) {
const bucket = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType(type) : (worldRef.items || []);
for (const it of bucket || []) {
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) {
@ -196,7 +197,7 @@
const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect;
if (typeof bounceCircleOffRect !== "function") return;
const hitRadius = (item.r || 18) + 2;
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : (item.type === "balloon" ? 0.54 : 0.78);
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : (item.type === "balloon" ? 0.82 : 1.25);
const velocityDamp = rect.bounce ? 1.0 : (item.type === "balloon" ? 0.82 : 0.94);
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, {
@ -220,20 +221,30 @@
}
function normalizeAngleLocal(angle = 0, fallback = 0) {
if (typeof normalizedItemAngle === "function") return normalizedItemAngle(angle, fallback);
const twoPi = Math.PI * 2;
const n = Number(angle);
const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0);
return ((base % twoPi) + twoPi) % twoPi;
return normalizedItemAngle(angle, fallback);
}
function fanWindAngleFor(item) {
return normalizeAngleLocal(item?.angle, item?.fanSwingCenter ?? 0);
}
function ensureFanBodyAngle(item) {
if (!item || item.type !== "fan") return 0;
const body = Number(item.fanBodyAngle);
if (Number.isFinite(body)) return normalizeAngleLocal(body, item.angle || 0);
item.fanBodyAngle = normalizeAngleLocal(item.angle || item.fanSwingCenter || 0, 0);
return item.fanBodyAngle;
}
function updateFanSwing(item, dt) {
if (!item || item.dead || item.type !== "fan") return false;
ensureFanBodyAngle(item);
if (!item.fanSwingOn) {
item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : itemAngleFor(item);
item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : fanWindAngleFor(item);
item.angle = normalizeAngleLocal(item.fanSwingCenter, item.angle || 0);
return false;
}
const center = normalizeAngleLocal(item.fanSwingCenter, itemAngleFor(item));
const center = normalizeAngleLocal(item.fanSwingCenter, fanWindAngleFor(item));
const range = clamp(Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0, 0, 150 * Math.PI / 180);
const speed = clamp(Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0, 0, 360 * Math.PI / 180);
item.fanSwingCenter = center;
@ -244,13 +255,37 @@
return true;
}
const FAN_WIND_ITEM_TYPES = Object.freeze(["balloon", "ball", "pushpin", "oshibyo", "water", "zunda_juice", "mercury", "zunchi", "fire"]);
function fanWindItemCandidates(fan, worldRef, cx, cy, radius) {
if (!worldRef) return [];
if (typeof worldRef.itemsOfType === "function") {
const out = [];
const seen = new Set();
const r2 = Math.max(1, Number(radius || 0) || 0) ** 2;
for (const type of FAN_WIND_ITEM_TYPES) {
for (const it of worldRef.itemsOfType(type) || []) {
if (!it || it.dead || it === fan || seen.has(it.id || it)) continue;
const dx = Number(it.x || 0) - cx;
const dy = Number(it.y || 0) - cy;
const reach = radius + Math.max(0, Number(it.r || it.radius || 0) || 0);
if (dx * dx + dy * dy > Math.max(r2, reach * reach)) continue;
seen.add(it.id || it);
out.push(it);
}
}
return out;
}
return worldRef.nearbyItems?.(cx, cy, radius) || worldRef.items || [];
}
function fanWindAt(fan, x, y, opts = {}) {
if (!fan || fan.dead || fan.type !== "fan") return null;
const range = Math.max(80, Number(opts.range || 300) || 300);
const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62);
const dx = x - fan.x;
const dy = y - fan.y;
const angle = itemAngleFor(fan);
const angle = fanWindAngleFor(fan);
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const along = dx * ax + dy * ay;
@ -271,16 +306,19 @@
const now = worldRef?.time || 0;
item.prevX = item.x;
item.prevY = item.y;
ensureFanBodyAngle(item);
item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt;
item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt;
updateFanSwing(item, frameDt);
const angle = itemAngleFor(item);
const angle = fanWindAngleFor(item);
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const range = 320;
const searchRadius = range + 40;
const nearbyItems = worldRef?.nearbyItems?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || [];
const queryX = item.x + ax * range * 0.45;
const queryY = item.y + ay * range * 0.45;
const nearbyItems = fanWindItemCandidates(item, worldRef, queryX, queryY, searchRadius);
for (const target of nearbyItems) {
if (!target || target.dead || target === item) continue;
const wind = fanWindAt(item, target.x, target.y, { range });
@ -288,7 +326,7 @@
applyFanToItem(item, target, wind, frameDt, worldRef);
}
const nearbyTarinai = worldRef?.nearbyTarinai?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || [];
const nearbyTarinai = worldRef?.nearbyTarinai?.(queryX, queryY, searchRadius) || [];
for (const t of nearbyTarinai) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const wind = fanWindAt(item, t.x, t.y, { range });
@ -306,6 +344,46 @@
return true;
}
function spreadFireByFan(fan, fire, wind, dt, worldRef) {
if (!fan || !fire || !worldRef || fire.dead || fire.type !== "fire") return false;
const now = worldRef.time || 0;
const falloff = Math.max(0, Number(wind?.falloff || 0) || 0);
if (falloff < 0.16 || (fire.amount || 0) < 18) return false;
const interval = Math.max(0.10, 0.34 - falloff * 0.18);
if ((fire.lastFanSpreadAt || -999) + interval > now) return false;
fire.lastFanSpreadAt = now;
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
const side = global.deterministicRange(worldRef, "fan-fire-side", -22, 22, fan, fire, Math.round(now * 10));
const dist = global.deterministicRange(worldRef, "fan-fire-distance", 58, 118, fan, fire, Math.round(now * 10)) * (0.82 + falloff * 0.58);
const x = clamp((fire.x || 0) + ax * dist - ay * side, CONFIG.worldPadding || 30, Math.max(CONFIG.worldPadding || 30, (worldRef.w || 0) - (CONFIG.worldPadding || 30)));
const y = clamp((fire.y || 0) + ay * dist + ax * side, CONFIG.worldPadding || 30, Math.max(CONFIG.worldPadding || 30, (worldRef.h || 0) - (CONFIG.worldPadding || 30)));
const spawned = global.TarinaiItemDynamicToolSystem?.spawnFireAt?.(worldRef, x, y, {
radius: Math.max(8, (fire.r || 14) * (0.55 + falloff * 0.22)),
amount: Math.max(18, Math.min(86, (fire.amount || 60) * (0.20 + falloff * 0.22))),
life: 2.2 + falloff * 3.4,
source: fan,
mergeRadius: 24,
});
if (spawned) {
// A fan should carry embers forward instead of merely dimming the source flame.
// The source loses a little fuel, while the downwind flame handles normal fire contacts.
fire.amount = Math.max(4, (fire.amount || 0) - (2.2 + falloff * 3.8));
worldRef.effects?.push(new Effect("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), {
size: Math.max(7, (fire.r || 14) * 0.72),
life: 0.22,
color: "rgba(255,126,54,0.58)",
vx: ax * (80 + falloff * 120),
vy: ay * (80 + falloff * 120),
}));
worldRef.markItemBucketsDirty?.("fan-fire-spread");
worldRef.markSpatialDirty?.("fan-fire-spread");
worldRef.drawListDirty = true;
return true;
}
return false;
}
function applyFanToItem(fan, target, wind, dt, worldRef) {
const type = target.type || "";
let massFactor = 0;
@ -314,7 +392,10 @@
else if (type === "pushpin" || type === "oshibyo") massFactor = 0.90;
else if (type === "water" || type === "zunda_juice" || type === "mercury") massFactor = 0.42;
else if (type === "zunchi") massFactor = 0.30;
else if (type === "fire") massFactor = 0.18;
else if (type === "fire") {
spreadFireByFan(fan, target, wind, dt, worldRef);
return;
}
else return;
const force = 236 * wind.falloff * massFactor;
@ -326,7 +407,10 @@
target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820);
if (type === "balloon") target.spinVelocity = clamp((target.spinVelocity || 0) + 3.2 * wind.falloff * dt, -12, 12);
else if (type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + 5.4 * wind.falloff * dt, -38, 38);
if (type === "fire") target.amount = Math.max(0, (target.amount ?? 1) - wind.falloff * dt * 1.0);
// Promote wind-driven objects out of the sleeping heap immediately. Without
// this, velocity accumulates while the target waits for its next scheduled
// update and the object jumps in periodic bursts.
global.TarinaiItemUpdateScheduler?.wakeItem?.(worldRef, target, 0.24);
worldRef?.markSpatialDirty?.("fan-wind-item");
}
@ -353,8 +437,9 @@
const radius = Math.max(26, (balloon.r || 20) + 22);
for (const it of worldRef.nearbyItems(balloon.x, balloon.y, radius) || []) {
if (!it || it.dead || it === balloon) continue;
if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker") {
const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + (it.type === "fire" ? 16 : 4);
if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker" || it.type === "firecracker") {
const extra = it.type === "fire" ? 16 : (it.type === "firecracker" || it.type === "flame_firecracker" ? 8 : 4);
const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + extra;
if (hit) {
popBalloon(balloon, worldRef, it);
return;
@ -410,24 +495,29 @@
function spawnWaterFromBalloon(balloon, worldRef) {
if (!balloon || !worldRef) return 0;
const baseR = Math.max(14, Number(balloon.r || 20) || 20);
const count = Math.max(7, Math.min(13, Math.round(baseR * 0.42)));
const count = Math.max(12, Math.min(22, Math.round(baseR * 0.68)));
let spawned = 0;
for (let i = 0; i < count; i++) {
if (typeof global.Item !== "function") break;
const a = deterministicAngle(worldRef || balloon, "water-balloon-splash-angle", balloon, i);
const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.10, baseR * 0.58, balloon, i);
const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 150, 420, balloon, i);
const drop = new global.Item("water", balloon.x + Math.cos(a) * d, balloon.y + Math.sin(a) * d);
drop.r = deterministicRange(worldRef || balloon, "water-balloon-drop-radius", 5.5, 10.5, balloon, i);
drop.amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 10, 28, balloon, i);
drop.prevX = drop.x;
drop.prevY = drop.y;
drop.vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.18;
drop.vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.18;
drop.splashFlightTimer = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.30, 0.62, balloon, i);
drop.splashSourceId = balloon.id || "";
if (worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false })) spawned += 1;
else { worldRef.items?.push?.(drop); spawned += 1; }
const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.12, baseR * 0.95, balloon, i);
const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 280, 760, balloon, i);
const x = balloon.x + Math.cos(a) * d;
const y = balloon.y + Math.sin(a) * d;
const vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.28;
const vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.28;
const flight = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.42, 0.88, balloon, i);
const amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 24, 58, balloon, i);
const drop = global.TarinaiWeatherSystem?.createRainWaterItem
? global.TarinaiWeatherSystem.createRainWaterItem(worldRef, { x, y, amount, vx, vy, splashFlightTimer: flight })
: new global.Item("water", x, y);
if (!Number.isFinite(Number(drop.amount))) drop.amount = amount;
if (!Number.isFinite(Number(drop.vx))) drop.vx = vx;
if (!Number.isFinite(Number(drop.vy))) drop.vy = vy;
if (!Number.isFinite(Number(drop.splashFlightTimer))) drop.splashFlightTimer = flight;
const addedDrop = worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false }) || null;
if (!addedDrop) continue;
spawned += 1;
if (i < 8) {
worldRef.effects?.push(new Effect("fight", drop.x, drop.y, {
size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i),
@ -538,7 +628,11 @@
worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s }));
}
spawnWaterFromBalloon(balloon, worldRef);
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火" : (cause?.type === "genkotsu" ? "げんこつ" : (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "何か"));
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火"
: (cause?.type === "firecracker" ? "爆竹"
: (cause?.type === "shoot" ? "射撃"
: (cause?.type === "genkotsu" ? "げんこつ"
: (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "強い衝撃"))));
if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) {
worldRef.log?.(`水風船が${sourceLabel}に触れて割れた。`, "accident", { participants: [] });
}
@ -550,7 +644,6 @@
update,
updateFan,
updateWaterDropMotion,
fanWindAt,
resolveObstacleCollisions,
popBalloon,
});

View file

@ -9,15 +9,17 @@
switch (item.type) {
case "firecracker":
case "flame_firecracker":
case "sticky_bomb":
case "fire":
case "genkotsu":
case "nest_box":
case "rotator":
case "poison_block":
case "reciprocator":
case "gate_fence":
case "robot_cleaner":
case "rope":
case "rod":
case "spring":
return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false;
case "ball":
case "balloon":
@ -34,6 +36,10 @@
if (global.TarinaiItemDynamicPinSystem.isPin(item)) {
return global.TarinaiItemDynamicPinSystem.update(item, dt, worldRef) !== false;
}
if (item.isStructure && typeof item.update === "function") {
item.update(dt, worldRef);
return true;
}
return false;
}
}

View file

@ -2,7 +2,7 @@
// Layer: entity-runtime/items/dynamic-system
// Owns dynamic tool behavior for rotator, reciprocator, firecracker, genkotsu,
// links, and static gate-fence ticking. Item.prototype methods delegate here for
// links, and active tool ticking. Item.prototype methods delegate here for
// direct Item.prototype calls; lifecycle dispatch also calls this boundary directly.
(function (global) {
function fireApi() {
@ -40,35 +40,159 @@
}
const STICKY_BOMB_PANIC_TEXT = "粘着ボムが付いてパニックになっている。";
const STICKY_BOMB_FUSE_SECONDS = 12.0;
const STICKY_BOMB_BLAST_SCALE = 0.68; // identical to TOOL_SIZE_SCALES.small
function tarinaiById(worldRef, id = "") {
return (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === id) || null;
}
function panicStickyBombCarrier(t, item, worldRef, force = false) {
if (!t || t.dead) return false;
const now = Number(worldRef?.time || t.world?.time || 0) || 0;
if (!force && now < Number(item.stickyBombPanicCooldown || 0)) return false;
item.stickyBombPanicCooldown = now + 0.55;
t.thought = STICKY_BOMB_PANIC_TEXT;
t.lastPanicCause = "sticky_bomb";
t.lastPanicDetail = STICKY_BOMB_PANIC_TEXT;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.38);
t.fearTimer = Math.max(t.fearTimer || 0, 1.15);
if (typeof t.enterPanic === "function") {
t.enterPanic({ threat: item, target: item, reason: STICKY_BOMB_PANIC_TEXT, fear: 1.45, wake: true, cause: "sticky_bomb", surpriseTimer: 0.45, awakeLockTimer: 0.28, bubble: "!!", bubbleCooldown: 0.55 });
} else {
t.setActionState?.("panic", { target: item, reason: STICKY_BOMB_PANIC_TEXT, wake: true, sleeping: false });
}
if (t.addStress) t.addStress(1.8, { threshold: 5 });
return true;
}
function attachStickyBomb(item, carrier, worldRef, reason = "attach") {
if (!item || !carrier || carrier.dead) return false;
if (item.stickyBombCarrierId === carrier.id) return false;
const old = tarinaiById(worldRef, item.stickyBombCarrierId);
item.stickyBombCarrierId = carrier.id;
item.stickyBombTransferCooldown = 0.42;
item.vx = 0;
item.vy = 0;
item.x = carrier.x;
item.y = carrier.y - Math.max(10, (carrier.radius || 20) * 0.55);
item.prevX = item.x;
item.prevY = item.y;
item.noStickUntil = 0;
panicStickyBombCarrier(carrier, item, worldRef, true);
if (old && old !== carrier) {
old.surpriseTimer = Math.max(old.surpriseTimer || 0, 0.18);
old.thought = "粘着ボムが移った";
}
if (worldRef?.effects && typeof Effect !== "undefined") {
worldRef.effects.push(new Effect("ring", item.x, item.y, { size: Math.max(16, (item.r || 16) * 1.45), life: 0.18, color: reason === "transfer" ? "rgba(255,210,72,0.52)" : "rgba(255,128,78,0.48)" }));
}
worldRef?.markItemBucketsDirty?.("sticky-bomb-attach");
worldRef?.markSpatialDirty?.("sticky-bomb-attach");
return true;
}
function explodeStickyBomb(item, worldRef) {
if (!item || item.dead) return false;
item.stickyBombCarrierId = "";
item.stickyBombTransferCooldown = 0;
item.blastScale = STICKY_BOMB_BLAST_SCALE;
if (worldRef?.explodeFirecracker) {
return Boolean(worldRef.explodeFirecracker(item));
}
item.amount = 0;
item.hp = 0;
audio.explode?.();
worldRef?.log?.("粘着ボムが爆発した。", "accident");
return true;
}
function updateStickyBomb(item, dt, worldRef) {
if (!item || item.dead || item.type !== "sticky_bomb") return false;
const step = Math.max(0.016, Number(dt || 0.016) || 0.016);
item.fuseTimer = Math.max(0, Number(item.fuseTimer ?? STICKY_BOMB_FUSE_SECONDS) - step);
item.stickyBombTransferCooldown = Math.max(0, Number(item.stickyBombTransferCooldown || 0) - step);
item.spin = (Number(item.spin || 0) || 0) + (Number(item.spinVelocity || 0) || 0) * step;
if (item.fuseTimer <= 0) return explodeStickyBomb(item, worldRef);
const carrier = tarinaiById(worldRef, item.stickyBombCarrierId);
if (carrier) {
const side = Number(carrier.facing || carrier.faceDir || 1) || 1;
item.x = carrier.x + side * Math.max(6, (carrier.radius || 20) * 0.32);
item.y = carrier.y - Math.max(10, (carrier.radius || 20) * 0.62);
item.prevX = item.x;
item.prevY = item.y;
item.vx = 0;
item.vy = 0;
panicStickyBombCarrier(carrier, item, worldRef, false);
if (item.stickyBombTransferCooldown <= 0) {
let best = null;
let bestD = Infinity;
for (const other of worldRef?.nearbyTarinai?.(carrier.x, carrier.y, Math.max(72, (carrier.radius || 20) * 3.0), true) || []) {
if (!other || other.dead || other === carrier || other.id === carrier.id) continue;
const d = distXY(carrier.x, carrier.y, other.x, other.y);
if (d <= Math.max(30, (carrier.radius || 18) + (other.radius || 18) + 10) && d < bestD) { best = other; bestD = d; }
}
if (best) attachStickyBomb(item, best, worldRef, "transfer");
}
} else {
item.stickyBombCarrierId = "";
item.prevX = item.x;
item.prevY = item.y;
item.x += (Number(item.vx || 0) || 0) * step;
item.y += (Number(item.vy || 0) || 0) * step;
item.vx *= Math.pow(0.65, step);
item.vy *= Math.pow(0.65, step);
const p = Math.max(28, CONFIG.worldPadding || 30);
item.x = clamp(item.x, p, (worldRef?.w || 1) - p);
item.y = clamp(item.y, p, (worldRef?.h || 1) - p);
let best = null;
let bestD = Infinity;
for (const t of worldRef?.nearbyTarinai?.(item.x, item.y, Math.max(72, (item.r || 16) + 56), true) || []) {
if (!t || t.dead) continue;
const d = distXY(item.x, item.y, t.x, t.y);
if (d <= Math.max(34, (item.r || 16) + (t.radius || 18) + 8) && d < bestD) { best = t; bestD = d; }
}
if (best) attachStickyBomb(item, best, worldRef, "attach");
}
if (worldRef?.effects && typeof Effect !== "undefined" && item.fuseTimer < Math.max(1.2, (item.fuseMax || STICKY_BOMB_FUSE_SECONDS) * 0.35) && deterministicChance(worldRef, "sticky-bomb-spark", step * 5.8, item, Math.floor((worldRef.time || 0) * 20))) {
worldRef.effects.push(new Effect("fight", item.x, item.y - (item.r || 16) * 0.55, { size: 4 + Math.random() * 4, life: 0.16, color: "rgba(255,224,92,0.78)", vy: -20 }));
}
worldRef && (worldRef.drawListDirty = true);
return true;
}
function updateFlexibleLink(item, dt, worldRef) {
global.TarinaiPhysicsBodySystem?.applyConstraintState?.(item, item?.physicsConstraint);
return Boolean(global.TarinaiConstraintSystem.updateFlexibleLink(item, dt, worldRef));
}
function updateRigidLink(item, dt, worldRef) {
global.TarinaiPhysicsBodySystem?.applyConstraintState?.(item, item?.physicsConstraint);
return Boolean(global.TarinaiConstraintSystem.updateRigidLink(item, dt, worldRef));
}
function updateGateFence(item) {
if (item) item.amount = 999;
return Boolean(item);
}
function update(item, dt, worldRef) {
if (!item || item.dead) return false;
if (item.type === "firecracker") return fireApi().updateFirecracker(item, dt, worldRef);
if (item.type === "flame_firecracker") return fireApi().updateFlameFirecracker(item, dt, worldRef);
if (item.type === "sticky_bomb") return updateStickyBomb(item, dt, worldRef);
if (item.type === "fire") return fireApi().updateFire(item, dt, worldRef);
if (item.type === "nest_box") return fireApi().updateNestBoxFire(item, dt, worldRef);
if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef);
if (item.type === "rotator") return updateRotator(item, dt, worldRef);
if (item.type === "poison_block") return updatePoisonBlock(item, dt, worldRef);
if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef);
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
if (item.type === "robot_cleaner") return robotApi().updateRobotCleaner(item, dt, worldRef);
if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef);
if (item.type === "rod") return updateRigidLink(item, dt, worldRef);
if (item.type === "spring") {
global.TarinaiPhysicsBodySystem?.applyConstraintState?.(item, item?.physicsConstraint);
return global.TarinaiConstraintSystem?.updateSpringLink?.(item, dt, worldRef) ?? false;
}
return false;
}
@ -94,6 +218,10 @@
panicTarinaiFromFire: (...args) => fireApi().panicTarinaiFromFire(...args),
igniteAntByFire: (...args) => fireApi().igniteAntByFire(...args),
igniteGrassByFire: (...args) => fireApi().igniteGrassByFire(...args),
igniteNestBoxByFire: (...args) => fireApi().igniteNestBoxByFire(...args),
extinguishNestBoxFire: (...args) => fireApi().extinguishNestBoxFire(...args),
updateNestBoxFire: (...args) => fireApi().updateNestBoxFire(...args),
nestBoxFireActive: (...args) => fireApi().nestBoxFireActive(...args),
spawnFireAt: (...args) => fireApi().spawnFireAt(...args),
scatterFireFrom: (...args) => fireApi().scatterFireFrom(...args),
compactWorldFires: (...args) => fireApi().compactWorldFires(...args),

View file

@ -93,9 +93,15 @@
color: "rgba(236,46,24,0.72)",
}));
}
if (item.burnTimer <= 0.01) item.burning = false;
if ((item.amount || 0) <= 0.5) item.amount = 0;
if (item.burnTimer <= 0.01 || (item.amount || 0) <= 0.5) {
item.burning = false;
item.burnTimer = 0;
item.amount = 0;
worldRef?.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: Math.max(13, (item.r || 15) * 1.25), life: 0.42, color: "rgba(64,44,34,0.44)" }));
}
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-burning");
worldRef?.markItemBucketsDirty?.("zunchi-burning");
worldRef?.markSpatialDirty?.("zunchi-burning");
worldRef.drawListDirty = true;
return true;
}
@ -133,8 +139,16 @@
item.fertility = clamp(1 - item.stageTimer / 360, 0, 1);
item.amount -= decayDt * 0.090 * decayBoost;
}
if ((item.amount || 0) <= 0.5) {
item.amount = 0;
worldRef?.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: Math.max(12, (item.r || 15) * 1.15), life: 0.34, color: "rgba(64,44,34,0.34)" }));
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-decay-out");
worldRef?.markItemBucketsDirty?.("zunchi-decay-out");
worldRef?.markSpatialDirty?.("zunchi-decay-out");
if (worldRef) worldRef.drawListDirty = true;
}
return true;
}
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, updateLifecycle, updateBurningLifecycle });
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, updateLifecycle });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -33,7 +33,6 @@
if (worldRef) worldRef.drawListDirty = true;
}
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true });
if (item.foodServingsRemaining <= 0.015) item.amount = 0;
}
} else if (MEDICINE_LIKE_FOOD_TYPES.includes(item.type)) {
@ -43,7 +42,6 @@
if (lost > 0) {
changed = true;
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item, type: item.type, amount: lost, passive: true });
}
}
}

View file

@ -9,8 +9,11 @@
item.lifecycleTimer += dt;
const interval = item.lifecycleInterval || (0.95 + stableUnit(item.id, "zunchi-life") * 0.45);
if (item.lifecycleTimer < interval) return false;
const lifecycleDt = Math.min(item.lifecycleTimer, 2.4);
item.lifecycleTimer = 0;
const groundId = worldRef?.groundType || "soil";
const groundDecayMul = Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1;
const maxLifecycleDt = groundDecayMul >= 100 ? 0.35 : 2.4;
const lifecycleDt = Math.min(item.lifecycleTimer, maxLifecycleDt);
item.lifecycleTimer = Math.max(0, item.lifecycleTimer - lifecycleDt);
global.TarinaiItemDynamicZunchiSystem.updateLifecycle(item, lifecycleDt, worldRef);
return true;
}

View file

@ -69,7 +69,6 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o
worldRef?.markItemBucketsDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
worldRef?.markSpatialDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
worldRef?.markTerrainDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded");
if (options.emit !== false) worldRef?.emit?.("duplicator:loaded", { duplicator, foodType: loadType, replaced: changed, direct: Boolean(options.direct) });
if (options.log !== false) worldRef?.log?.(`\u8907\u88fd\u6a5f\u306b${duplicator.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, isPinType(loadType) ? "observe" : "food");
return true;
}
@ -93,12 +92,7 @@ function duplicatorApplyStoredPin(duplicator, worldRef) {
pin.noStickTargetId = "";
pin.noStickTargetUntil = 0;
const added = worldRef?.addItem?.(pin, "duplicator-pin", { terrain: false }) || null;
if (!added && worldRef?.items) {
pin.world = worldRef;
worldRef.items.push(pin);
worldRef.markItemBucketsDirty?.("duplicator-pin");
worldRef.markSpatialDirty?.("duplicator-pin");
}
if (!added) return false;
const ok = global.TarinaiItemDynamicPinSystem.attach(pin, t, worldRef, distXY(dish.x, dish.y, t.x, t.y));
if (!ok) {
pin.amount = 0;

View file

@ -1,5 +1,35 @@
"use strict";
function drawZunchiFallbackSprite(ctx, r = 15, alpha = 1) {
ctx.save();
ctx.globalAlpha *= alpha;
ctx.fillStyle = "rgba(102, 76, 42, 0.92)";
ctx.strokeStyle = "rgba(54, 39, 23, 0.42)";
ctx.lineWidth = Math.max(1.1, r * 0.08);
const lumps = [
[-0.58, 0.12, 0.42, 0.26],
[-0.24, -0.05, 0.48, 0.32],
[0.20, 0.07, 0.50, 0.30],
[0.54, 0.16, 0.34, 0.22],
[0.02, -0.31, 0.34, 0.22],
];
for (const [x, y, w, h] of lumps) {
ctx.beginPath();
ctx.ellipse(r * x, r * y, r * w, r * h, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.globalAlpha *= 0.48;
ctx.strokeStyle = "rgba(255, 245, 210, 0.86)";
ctx.lineWidth = Math.max(1.0, r * 0.06);
ctx.beginPath();
ctx.moveTo(-r * 0.78, -r * 0.20);
ctx.quadraticCurveTo(-r * 0.20, -r * 0.50, r * 0.58, -r * 0.16);
ctx.stroke();
ctx.restore();
}
// Item field rendering. Kept separate from construction and lifecycle logic.
Object.assign(Item.prototype, {
draw(ctx, t, lighting = null) {
@ -279,85 +309,94 @@ draw(ctx, t, lighting = null) {
ctx.beginPath();
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
ctx.fill();
if (this.burning || (this.burnTimer || 0) > 0.02 || (this.nestFireEvacuationTimer || 0) > 0.02) {
const phase = t * 7.4 + this.seed;
ctx.save();
ctx.globalAlpha *= 0.78;
if (typeof drawSimpleFireShape === "function") {
drawSimpleFireShape(ctx, -this.r * 0.42, -this.r * 0.64, this.r * 0.38, { phase, shadow: itemShadowsEnabled() });
drawSimpleFireShape(ctx, this.r * 0.38, -this.r * 0.56, this.r * 0.32, { phase: phase + 1.7, shadow: false });
} else {
ctx.fillStyle = "rgba(236,46,24,0.78)";
ctx.beginPath();
ctx.ellipse(-this.r * 0.35, -this.r * 0.58, this.r * 0.18, this.r * 0.36, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
} else if (this.type === "pipe") {
const r = this.r || 36;
ctx.save();
const body = styleNight ? "#506d5f" : (styleWarm ? "#7f9a72" : "#6f8d75");
const side = styleNight ? "#3e574d" : (styleWarm ? "#637f5d" : "#587260");
const lip = styleNight ? "#779083" : (styleWarm ? "#adc49b" : "#94b199");
const lipDark = styleNight ? "#2f4640" : "#486858";
const inner = styleNight ? "#0d1514" : "#152420";
const outline = styleNight ? "rgba(9,15,15,0.86)" : "rgba(30,50,42,0.78)";
const topY = -r * 0.42;
const bottomY = r * 0.54;
const topRx = r * 1.04;
const topRy = r * 0.43;
const bottomRx = r * 0.94;
const bottomRy = r * 0.34;
const side = styleNight ? "#3b544c" : (styleWarm ? "#648166" : "#577064");
const lip = styleNight ? "#80998d" : (styleWarm ? "#b4c6af" : "#9fb7aa");
const rimShadow = styleNight ? "#26413b" : "#4b6a60";
const inner = styleNight ? "#0b1212" : "#14211f";
const outline = styleNight ? "rgba(8,14,14,0.88)" : "rgba(26,46,40,0.80)";
const mouthY = -r * 0.16;
const mouthRx = r * 1.05;
const mouthRy = r * 0.37;
const innerRx = r * 0.70;
const innerRy = r * 0.24;
const frontY = r * 0.34;
const frontRx = r * 0.97;
const frontRy = r * 0.30;
// Shadow under the visible front edge.
ctx.fillStyle = "rgba(18,24,22,0.20)";
ctx.fillStyle = "rgba(18,24,22,0.21)";
ctx.beginPath();
ctx.ellipse(0, r * 0.73, r * 1.12, r * 0.30, 0, 0, Math.PI * 2);
ctx.ellipse(0, frontY + r * 0.10, r * 1.36, r * 0.34, 0, 0, Math.PI * 2);
ctx.fill();
// Cylindrical side wall: the lower edge is visible because the pipe is viewed from above-front.
// Exposed cylindrical wall: only the front half is visible so the pipe feels buried,
// not like a free-standing jar.
ctx.fillStyle = side;
ctx.strokeStyle = outline;
ctx.lineWidth = Math.max(2.2, r * 0.07);
ctx.beginPath();
ctx.moveTo(-topRx, topY);
ctx.bezierCurveTo(-topRx * 0.98, topY + r * 0.30, -bottomRx * 1.02, bottomY - r * 0.16, -bottomRx, bottomY);
ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, Math.PI, 0, true);
ctx.bezierCurveTo(topRx * 1.02, bottomY - r * 0.16, topRx * 0.98, topY + r * 0.30, topRx, topY);
ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI, true);
ctx.moveTo(-mouthRx * 0.92, mouthY + r * 0.03);
ctx.bezierCurveTo(-mouthRx * 0.98, mouthY + r * 0.22, -frontRx * 1.00, frontY - r * 0.06, -frontRx, frontY);
ctx.ellipse(0, frontY, frontRx, frontRy, 0, Math.PI, 0, true);
ctx.bezierCurveTo(frontRx * 1.00, frontY - r * 0.06, mouthRx * 0.98, mouthY + r * 0.22, mouthRx * 0.92, mouthY + r * 0.03);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Front lower rim shade.
ctx.fillStyle = body;
// Ground collar around the mouth to make it read as a pipe opening in the ground.
ctx.fillStyle = "rgba(92,108,104,0.22)";
ctx.beginPath();
ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, 0, Math.PI);
ctx.lineTo(bottomRx * 0.88, bottomY + r * 0.08);
ctx.ellipse(0, bottomY + r * 0.08, bottomRx * 0.88, bottomRy * 0.58, 0, 0, Math.PI, true);
ctx.closePath();
ctx.ellipse(0, mouthY + r * 0.03, mouthRx * 1.08, mouthRy * 1.15, 0, 0, Math.PI * 2);
ctx.fill();
// Top lip and visible round opening.
ctx.fillStyle = lip;
ctx.strokeStyle = outline;
ctx.lineWidth = Math.max(2.6, r * 0.08);
ctx.beginPath();
ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI * 2);
ctx.ellipse(0, mouthY, mouthRx, mouthRy, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = inner;
ctx.strokeStyle = styleNight ? "rgba(2,7,7,0.88)" : "rgba(14,26,23,0.90)";
ctx.lineWidth = Math.max(2.0, r * 0.065);
ctx.beginPath();
ctx.ellipse(0, topY + r * 0.02, r * 0.64, r * 0.24, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = lipDark;
ctx.lineWidth = Math.max(1.4, r * 0.045);
ctx.beginPath();
ctx.ellipse(0, topY + r * 0.02, r * 0.78, r * 0.32, 0, Math.PI * 0.04, Math.PI * 0.96);
ctx.stroke();
// Upper-left highlight for the rim and side.
ctx.globalAlpha = 0.34;
ctx.fillStyle = "rgba(255,255,255,0.86)";
ctx.strokeStyle = rimShadow;
ctx.lineWidth = Math.max(1.6, r * 0.05);
ctx.beginPath();
ctx.ellipse(-r * 0.42, topY - r * 0.17, r * 0.24, r * 0.055, -0.10, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.22;
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.lineWidth = Math.max(1.1, r * 0.026);
ctx.beginPath();
ctx.moveTo(-r * 0.72, -r * 0.10);
ctx.bezierCurveTo(-r * 0.66, r * 0.18, -r * 0.62, r * 0.32, -r * 0.48, r * 0.50);
ctx.ellipse(0, mouthY + r * 0.01, mouthRx * 0.86, mouthRy * 0.70, 0, Math.PI * 1.02, Math.PI * 1.98);
ctx.stroke();
ctx.fillStyle = inner;
ctx.strokeStyle = styleNight ? "rgba(2,7,7,0.90)" : "rgba(14,24,22,0.92)";
ctx.lineWidth = Math.max(2.0, r * 0.065);
ctx.beginPath();
ctx.ellipse(0, mouthY + r * 0.01, innerRx, innerRy, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.save();
ctx.globalAlpha = 0.24;
ctx.strokeStyle = "rgba(255,255,255,0.46)";
ctx.lineWidth = Math.max(1.2, r * 0.035);
ctx.beginPath();
ctx.moveTo(-mouthRx * 0.60, mouthY + r * 0.12);
ctx.quadraticCurveTo(-mouthRx * 0.68, frontY - r * 0.12, -mouthRx * 0.48, frontY + r * 0.12);
ctx.stroke();
ctx.restore();
ctx.restore();
} else if (this.type === "robot_cleaner") {
ctx.save();
@ -737,10 +776,11 @@ draw(ctx, t, lighting = null) {
ctx.restore();
} else if (this.type === "fan") {
const r = this.r || 26;
const angle = itemAngleFor(this);
const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this);
const windAngle = itemAngleFor(this);
const spin = (this.fanSpin || 0) + t * 10.5;
ctx.save();
ctx.rotate(angle);
ctx.rotate(windAngle);
const windRange = 320;
const windStart = r * 1.02;
const windSpread = 0.62;
@ -801,7 +841,7 @@ draw(ctx, t, lighting = null) {
ctx.globalAlpha = fanAlpha;
ctx.restore();
ctx.save();
ctx.rotate(angle);
ctx.rotate(bodyAngle);
if (itemShadowsEnabled()) {
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(52,40,26,0.16)";
ctx.shadowBlur = 3;
@ -902,6 +942,49 @@ draw(ctx, t, lighting = null) {
ctx.fill();
}
ctx.restore();
} else if (this.type === "sticky_bomb") {
const r = this.r || 16;
const fuse = clamp((this.fuseTimer ?? 12) / Math.max(this.fuseMax || 12, 0.1), 0, 1);
const flash = fuse < 0.34 ? (0.5 + Math.sin(t * 24 + this.seed) * 0.5) : 0;
ctx.save();
ctx.rotate((this.spin || 0) * 0.18 + Math.sin(t * 2.0 + this.seed) * 0.05);
ctx.fillStyle = flash > 0.62 ? "#ffdf4e" : "#26272d";
ctx.strokeStyle = "rgba(12,13,18,0.86)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(0, 0, r * 0.78, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.20)";
ctx.beginPath();
ctx.ellipse(-r * 0.28, -r * 0.32, r * 0.20, r * 0.12, -0.55, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#d7d7d2";
ctx.strokeStyle = "rgba(38,38,36,0.82)";
ctx.lineWidth = 1.5;
roundedRect(ctx, -r * 0.28, -r * 0.98, r * 0.56, r * 0.28, r * 0.08);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = flash > 0.1 ? "rgba(255,238,90,0.95)" : "rgba(214,102,44,0.82)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(r * 0.08, -r * 1.02);
ctx.quadraticCurveTo(r * 0.42, -r * 1.30, r * 0.88, -r * 1.08);
ctx.stroke();
ctx.fillStyle = flash > 0.1 ? "rgba(255,238,92,0.96)" : "rgba(255,92,42,0.80)";
ctx.beginPath();
ctx.arc(r * 0.92, -r * 1.06, 2.2 + flash * 2.0, 0, Math.PI * 2);
ctx.fill();
if (this.stickyBombCarrierId) {
ctx.fillStyle = "rgba(255,86,70,0.92)";
ctx.beginPath();
ctx.moveTo(0, r * 0.72);
ctx.lineTo(-r * 0.22, r * 1.02);
ctx.lineTo(r * 0.22, r * 1.02);
ctx.closePath();
ctx.fill();
}
ctx.restore();
} else if (this.type === "firecracker" || this.type === "flame_firecracker") {
const flameMode = this.type === "flame_firecracker";
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
@ -935,9 +1018,10 @@ draw(ctx, t, lighting = null) {
ctx.textBaseline = "middle";
ctx.strokeText(timerText, 0, this.r * 0.02);
ctx.fillText(timerText, 0, this.r * 0.02);
} 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") {
} 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" || this.type === "one_way_fence") {
const bounce = this.type === "bounce_fence" || this.type === "bounce_fence_v";
const gate = this.type === "gate_fence";
const oneWay = this.type === "one_way_fence";
const glassWall = this.type === "glass_wall";
const angle = itemAngleFor(this);
const len = Math.max(112, this.r * 3.55);
@ -945,9 +1029,9 @@ draw(ctx, t, lighting = null) {
const open = gate && this.gateOpen;
ctx.save();
ctx.rotate(angle);
const wood = bounce ? (styleNight ? "#4e6f9f" : "#5aa2e0") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"));
const edge = bounce ? (styleNight ? "#243755" : "#286093") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25");
const hi = bounce ? (styleNight ? "rgba(190,230,255,0.24)" : "rgba(235,252,255,0.54)") : gate ? (styleNight ? "rgba(214,240,210,0.18)" : "rgba(220,255,210,0.34)") : (styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)");
const wood = bounce ? (styleNight ? "#4e6f9f" : "#5aa2e0") : oneWay ? (styleNight ? "#6b5a86" : "#9b78c8") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"));
const edge = bounce ? (styleNight ? "#243755" : "#286093") : oneWay ? (styleNight ? "#352943" : "#593a80") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25");
const hi = bounce ? (styleNight ? "rgba(190,230,255,0.24)" : "rgba(235,252,255,0.54)") : oneWay ? (styleNight ? "rgba(232,220,255,0.25)" : "rgba(255,245,255,0.55)") : gate ? (styleNight ? "rgba(214,240,210,0.18)" : "rgba(220,255,210,0.34)") : (styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)");
const drawRail = (x0, y0, w, h, alpha = 1) => {
ctx.save();
ctx.globalAlpha *= alpha;
@ -1035,12 +1119,32 @@ draw(ctx, t, lighting = null) {
ctx.fill();
}
}
if (oneWay) {
ctx.fillStyle = styleNight ? "rgba(246,235,255,0.92)" : "rgba(255,255,255,0.94)";
for (const xx of [-0.28, 0, 0.28]) {
ctx.beginPath();
ctx.moveTo(len * xx, -thick * 0.72);
ctx.lineTo(len * xx - thick * 0.34, -thick * 0.12);
ctx.lineTo(len * xx + thick * 0.34, -thick * 0.12);
ctx.closePath();
ctx.fill();
}
}
}
ctx.restore();
} else if (this.type === "pressure_switch") {
const active = Boolean(this.signalActive);
ctx.save(); ctx.globalAlpha *= active ? 1 : 0.78;
ctx.fillStyle = active ? (styleNight ? "#ffcf5a" : "#ffd74c") : (styleNight ? "#505866" : "#c7ced8");
ctx.strokeStyle = active ? "#9b6512" : "#69717a"; ctx.lineWidth=3;
ctx.beginPath(); ctx.roundRect?.(-27,-18,54,36,8); if (!ctx.roundRect) { ctx.rect(-27,-18,54,36); } ctx.fill(); ctx.stroke();
ctx.fillStyle = active ? "#fff6c9" : "#7b838d"; ctx.beginPath(); ctx.arc(0,-3,9,0,Math.PI*2); ctx.fill();
ctx.fillStyle = styleNight ? "#eef2f6" : "#26323e"; ctx.font="bold 10px ui-rounded, sans-serif"; ctx.textAlign="center"; ctx.fillText(`${this.pressureTarget === "item" ? "物" : "た"}${this.pressureThreshold || 1}`,0,14);
ctx.restore();
} else if (this.type === "rotator") {
const angle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem;
const powered = pb?.scalar?.(this, "motorOn", true) !== false;
const powered = window.TarinaiMechanicalSystem?.isPowered?.(this) ?? (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));
@ -1069,8 +1173,8 @@ draw(ctx, t, lighting = null) {
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 segments = (pb?.segments?.(this) || [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]]);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 11) || 11) || 11));
ctx.save();
ctx.rotate(angle);
ctx.lineCap = "round";
@ -1088,7 +1192,7 @@ draw(ctx, t, lighting = null) {
const bodyAngle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem;
const axisAngle = window.TarinaiMechanicalSystem.railAxisAngle(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle;
const powered = pb?.scalar?.(this, "railOn", true) !== false;
const powered = window.TarinaiMechanicalSystem?.isPowered?.(this) ?? (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();
const travel = Math.max(24, Number(pb?.scalar?.(this, "railTravel", 150) || 150) || 150);
@ -1155,7 +1259,7 @@ draw(ctx, t, lighting = null) {
} else {
ctx.fillText("\u25cb", 0, 0);
}
} else if (this.type === "rope" || this.type === "rod") {
} else if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(this.type)) {
const runtime = window.TarinaiLinkRuntime;
const pb = window.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(this, 0), world);
@ -1166,7 +1270,16 @@ draw(ctx, t, lighting = null) {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (this.type === "rod") {
if (this.type === "wire" || this.type === "insulated_wire") {
const active = Boolean(this.signalActive);
ctx.strokeStyle = this.type === "insulated_wire" ? (active ? "#ffd447" : "#506e92") : (active ? "#fff06a" : "#6f6254");
ctx.lineWidth = this.type === "insulated_wire" ? 5.2 : 3.2;
ctx.setLineDash(active ? [9,5] : []); ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); ctx.setLineDash([]);
} else if (this.type === "spring") {
const dx=bx-ax, dy=by-ay, len=Math.max(1,Math.hypot(dx,dy)), nx=-dy/len, ny=dx/len;
ctx.strokeStyle=styleNight?"#d6d9df":"#6e737b"; ctx.lineWidth=4; ctx.beginPath(); ctx.moveTo(ax,ay);
const coils=16; for(let i=1;i<coils;i++){ const t=i/coils; const amp=(i%2?1:-1)*11; ctx.lineTo(ax+dx*t+nx*amp,ay+dy*t+ny*amp); } ctx.lineTo(bx,by); ctx.stroke();
} else if (this.type === "rod") {
ctx.strokeStyle = styleNight ? "rgba(176,166,150,0.96)" : "rgba(92,82,68,0.96)";
ctx.lineWidth = 7.2;
ctx.beginPath();
@ -1192,21 +1305,23 @@ draw(ctx, t, lighting = null) {
drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by);
ctx.stroke();
}
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)");
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (this.type === "spring" ? "rgba(105,112,124,0.96)" : (this.type === "wire" || this.type === "insulated_wire" ? "rgba(70,80,92,0.96)" : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)")));
for (const [kx, ky] of [[ax, ay], [bx, by]]) {
ctx.beginPath(); ctx.arc(kx, ky, this.type === "rod" ? 4.8 : 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(kx, ky, (this.type === "rod" || this.type === "spring") ? 4.8 : 4.2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
} else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "zunchi";
const img = images.get(zunchiId) || images.get("zunchi");
const img = getRenderableImage(zunchiId, "zunchi");
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
ctx.globalAlpha *= stageAlpha;
if (img) {
const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi");
const ratio = metrics?.ratio || 178 / 236;
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
ctx.globalAlpha *= stageAlpha;
ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio);
} else {
drawZunchiFallbackSprite(ctx, this.r, 1);
}
} else if (this.type === "trace") {
ctx.fillStyle = "rgba(122, 100, 76, 0.12)";

View file

@ -6,7 +6,7 @@ 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" },
area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, iconText: "▦", 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" },
@ -16,6 +16,9 @@ const TOOL_DEFINITIONS = Object.freeze({
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3084\u706b\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
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" },
spring: { id: "spring", label: "バネ", placeable: false, scalable: false, iconText: "≋", tooltip: "2つの対象を順にクリックして、伸縮するバネでつなぐ。" },
wire: { id: "wire", label: "電線", placeable: false, scalable: false, iconText: "⌁", tooltip: "検知器の信号を電気で伝える。触ると感電。" },
insulated_wire: { id: "insulated_wire", label: "絶縁電線", placeable: false, scalable: false, iconText: "⌁", tooltip: "検知器の信号を電気で伝える。感電しない。" },
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" },
@ -40,29 +43,32 @@ const TOOL_DEFINITIONS = Object.freeze({
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u4e2d\u306b\u5165\u3063\u3066\u7720\u308c\u308b\u4e38\u3044\u571f\u7ba1\u3002\u51fa\u308b\u6642\u306f\u30e9\u30f3\u30c0\u30e0\u306a\u571f\u7ba1\u304b\u3089\u51fa\u308b\u3002\u5bdd\u5fc3\u5730\u306f\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u6bb5\u4e0b\u3002" },
pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "移動できる地下空間。たりないが眠ることもある。" },
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "水の入った風船。軽くて風に流される。画鋲・火・げんこつで割れ、水が飛び散って近くのたりないが驚く。" },
fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。水風船やボールを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "たりないのおもちゃ。割れると水滴が飛び散る。" },
fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。軽いものを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: false, scalable: false, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", simulationOnly: true },
flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u30bf\u30a4\u30de\u30fc\u5f8c\u306b\u7206\u767a\u305b\u305a\u3001\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\u3059\u3002" },
flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\u3059\u3002" },
sticky_bomb: { id: "sticky_bomb", itemType: "sticky_bomb", label: "粘着ボム", placeable: true, scalable: false, radius: 16, amount: 999, iconText: "\uD83D\uDCA3", collisionShape: "circle", tooltip: "\u4e00\u5b9a\u6642\u9593\u3067\u7206\u767a\u3059\u308b\u7206\u5f3e\u3002\u4ed8\u7740\u3057\u305f\u500b\u4f53\u306f\u30d1\u30cb\u30c3\u30af\u306b\u306a\u308b\u3002\u4ed6\u500b\u4f53\u306b\u89e6\u308c\u308b\u3068\u30dc\u30e0\u304c\u305d\u3063\u3061\u306b\u79fb\u52d5\u3059\u308b\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" },
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\u5f37\u70c8\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\u5f37\u70c8\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" },
one_way_fence: { id: "one_way_fence", itemType: "one_way_fence", label: "一方通行柵", placeable: true, scalable: true, radius: 42, amount: 999, iconText: "⇧", collisionShape: "oriented_rect", tooltip: "矢印方向には通過でき、逆方向からは通れない柵。Q/Eで45度、Shift+Q/Eで5度ずつ向きを変える。" },
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" },
poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 44, 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" },
pressure_switch: { id: "pressure_switch", itemType: "pressure_switch", label: "検知器", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "▣", collisionShape: "circle", tooltip: "表示された矩形範囲内にいるたりない、またはアイテム数を検知して電線へ信号を送る。クリックで対象・必要数・範囲を設定。" },
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 },
@ -90,7 +96,7 @@ 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;
if (t === "fence_v" || t === "bounce_fence_v" || t === "reciprocator") return Math.PI / 2;
return 0;
}
function normalizedItemAngle(angle = 0, fallback = 0) {
@ -111,6 +117,12 @@ function itemHasTrait(type = "", trait = "") {
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isNestContainerType(type = "") { const id = String(type || ""); return id === "nest_box" || id === "pipe"; }
function isNestContainerItem(item, options = {}) {
if (!item) return false;
const requireAlive = options?.alive !== false;
if (requireAlive && item.dead) return false;
return isNestContainerType(item.type);
}
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;
@ -174,6 +186,42 @@ function toolTipsFromDefinitions() {
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
}
function toolItemTypeIds() {
return Object.values(TOOL_DEFINITIONS)
.map(def => def?.itemType || (def?.placeable ? def.id : ""))
.filter(Boolean);
}
const ToolRuntime = Object.freeze({
TOOL_DEFINITIONS,
isFenceItemType,
isRotatableItemType,
defaultItemAngle,
normalizedItemAngle,
itemAngleFor,
itemHasTrait,
isPinType,
isSleepFurnitureType,
isNestContainerType,
isNestContainerItem,
lodgedPinFor,
hasLodgedPinEffect,
pinBehaviorFor,
toolCategories,
toolDefinition,
toolLabel,
toolItemType,
itemDefinition,
itemRadiusFor,
itemAmountFor,
normalizedToolSizeFor,
toolSizeScaleFor,
scalableToolIds,
toolTipsFromDefinitions,
toolItemTypeIds,
});
global.TarinaiToolRuntime = ToolRuntime;
Object.assign(global, {
isFenceItemType,
@ -195,12 +243,11 @@ function toolTipsFromDefinitions() {
itemDefinition,
itemRadiusFor,
itemAmountFor,
TOOL_SIZE_ORDER,
TOOL_SIZE_LABELS,
normalizedToolSizeFor,
toolSizeScaleFor,
scalableToolIds,
toolTipsFromDefinitions,
toolItemTypeIds,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -3,19 +3,19 @@
// Static item/tool metadata shared by tool definitions and UI helpers.
(function (global) {
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]),
hazard: new Set(["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
hazard: new Set(["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]),
draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch"]),
});
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence"]));
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_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 PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h"]);
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"]));
const PIN_BEHAVIORS = Object.freeze({
@ -29,12 +29,24 @@
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "signboard"] },
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] },
]);
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" });
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
const TOOL_SHORTCUTS = Object.freeze({
new: "1",
poke: "2",
pinch: "3",
copy: "4",
undo: "5",
redo: "6",
delete: "7",
area_delete: "8",
water_hose: "9",
clear_tool: "0",
});
global.TarinaiItemToolMetadata = Object.freeze({
ITEM_TRAITS,
@ -47,10 +59,6 @@
TOOL_SIZE_ORDER,
TOOL_SIZE_LABELS,
TOOL_SIZE_SCALES,
});
Object.assign(global, {
TOOL_SIZE_ORDER,
TOOL_SIZE_LABELS,
TOOL_SHORTCUTS,
});
})(typeof window !== "undefined" ? window : globalThis);

49
js/item_type_catalog.js Normal file
View file

@ -0,0 +1,49 @@
"use strict";
// Layer: data/item-type-catalog
// Builds the current save item-type table from live tool, structure, and link definitions.
// The item-type table remains build-defined. Schema 36 includes the current mechanical and signal tool ordering.
(function (global) {
const DomainIds = global.TarinaiDomainIds;
if (!DomainIds) throw new Error("TarinaiDomainIds must be loaded before item_type_catalog.js");
function addOrdered(out, seen, values = []) {
for (const value of values || []) {
const id = String(value || "").trim();
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
}
function buildItemTypeIds() {
const out = [];
const seen = new Set();
const structureTypes = Object.keys(global.StructureRegistry?.defs || {});
const toolTypes = typeof global.toolItemTypeIds === "function" ? global.toolItemTypeIds() : [];
const linkToolTypes = global.TarinaiItemToolMetadata?.PHYSICS_TOOL_IDS || [];
addOrdered(out, seen, structureTypes);
addOrdered(out, seen, toolTypes);
addOrdered(out, seen, linkToolTypes);
return Object.freeze(out);
}
const ITEM_TYPE_IDS = buildItemTypeIds();
const ITEM_TYPE_INDEX = Object.freeze(Object.fromEntries(ITEM_TYPE_IDS.map((id, index) => [id, index])));
function itemTypeIndex(value = "", fallback = -1) {
const index = ITEM_TYPE_INDEX[String(value ?? "")];
return Number.isInteger(index) ? index : fallback;
}
function itemTypeValue(index = 0, fallback = "") {
return DomainIds.enumValue(ITEM_TYPE_IDS, index, fallback);
}
global.TarinaiItemTypeCatalog = Object.freeze({
ITEM_TYPE_IDS,
ITEM_TYPE_INDEX,
itemTypeIndex,
itemTypeValue,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -8,7 +8,7 @@ function initializeItemTypeState(item, type, x, y) {
if (type === "water" || type === "zunda_juice") item.roles.drink = true;
if (["sweet", "water", "zunda_juice", "slave_chain", "golden_crown"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true;
if (type === "bed" || type === "nest_box" || type === "pipe") item.roles.sleepPlace = true;
if (["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true;
if (["firecracker", "flame_firecracker", "sticky_bomb", "fire", "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));
@ -76,6 +76,7 @@ function initializeItemTypeState(item, type, x, y) {
if (type === "fan") {
item.amount = 999;
item.angle = defaultItemAngle(type);
item.fanBodyAngle = item.angle;
item.fanSpin = rand(0, Math.PI * 2);
item.fanPulse = rand(0, Math.PI * 2);
item.lastFanEffectAt = -999;
@ -111,6 +112,21 @@ function initializeItemTypeState(item, type, x, y) {
item.fuseTimer = 5;
item.fuseMax = 5;
}
if (type === "sticky_bomb") {
item.fuseTimer = 12.0;
item.blastScale = 0.68;
item.fuseMax = 12.0;
item.vx = rand(-8, 8);
item.vy = rand(-8, 8);
item.prevX = x;
item.prevY = y;
item.spin = rand(0, Math.PI * 2);
item.spinVelocity = rand(-2.8, 2.8);
item.stickyBombCarrierId = "";
item.stickyBombTransferCooldown = 0;
item.stickyBombPanicCooldown = 0;
item.stickyBombArmedAt = 0;
}
if (type === "fire") {
item.amount = Math.max(Number(item.amount || 0) || 0, 120);
item.r = Math.max(Number(item.r || 0) || 0, 14);
@ -155,7 +171,7 @@ function initializeItemTypeState(item, type, x, y) {
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: false, speed: 0, direction: 1 },
rail: null,
shape: { model: "segments", thickness: 14, segments: [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]], version: 0 },
shape: { model: "segments", thickness: 11, segments: [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]], version: 0 },
collision: { solid: true, hazard: true },
hazard: { kind: "poison", damage: 7 },
sleep: { awakeUntil: 0 },
@ -185,13 +201,13 @@ function initializeItemTypeState(item, type, x, y) {
};
item.amount = 999;
}
if (type === "rope" || type === "rod") {
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(type)) {
item.amount = 999;
item.r = 48;
item.deletable = true;
item.physicsConstraint = {
type,
kind: type === "rope" ? "flexible-distance" : "rigid-distance",
kind: type === "rope" ? "flexible-distance" : (type === "rod" ? "rigid-distance" : (type === "spring" ? "spring-distance" : "signal-wire")),
endpoints: [null, null],
length: 80,
mid: { x, y: y + 10, vx: 0, vy: 0 },
@ -199,6 +215,16 @@ function initializeItemTypeState(item, type, x, y) {
};
}
if (type === "pressure_switch") {
item.pressureTarget = "tarinai";
item.pressureThreshold = 1;
item.pressureWidth = 220;
item.pressureHeight = 160;
item.signalActive = false;
item.deletable = true;
item.amount = 999;
}
if (type === "reciprocator") {
item.physicsBody = {
type: "reciprocator",
@ -216,7 +242,6 @@ function initializeItemTypeState(item, type, x, y) {
}
if (globalThis.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
globalThis.TarinaiPhysicsBodySystem.purgeLegacyPhysicsStorage(item);
globalThis.TarinaiPhysicsBodySystem.invalidateItem(item, "item-initialized");
}

View file

@ -5,45 +5,94 @@
// helpers and the item scheduler both read this single policy instead of
// duplicating item-type timing rules.
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]);
const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "robot_cleaner"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = Object.freeze([
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo",
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod",
"stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch",
]);
function mobilePerfHint() {
return Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
function itemSpeed(item) {
if (!item) return 0;
return Math.hypot(item.vx || 0, item.vy || 0, item.impulseVx || 0, item.impulseVy || 0);
}
function itemRealtimeActive(item) {
if (!item || item.dead) return false;
if (!Number.isFinite(item._schedulerLastAt)) return true;
const now = item.world?.time || 0;
if (item.playerHeld || item._heldByPlayer || item.dragging || item.dropTimer > 0) return true;
if (item.burning || (item.burnTimer || 0) > 0.02) return true;
if (itemSpeed(item) > 0.08) return true;
const activeUntil = Math.max(
Number(item._activeUntil || -Infinity) || -Infinity,
Number(item.awakeUntil || -Infinity) || -Infinity,
Number(item.eatProtectedUntil || -Infinity) || -Infinity
);
if (activeUntil > now) return true;
const lastActiveAt = Math.max(
Number(item.lastKickedAt || -999) || -999,
Number(item.lastShotAt || -999) || -999
);
if (now - lastActiveAt < 0.35) return true;
// Fan wind is a continuous force. Scheduling it as an idle item turns the
// accumulated dt into periodic gusts, so fans must remain in the realtime lane.
if (item.type === "fan") return true;
if (item.type === "firecracker" || item.type === "flame_firecracker" || item.type === "sticky_bomb") return Boolean(item.fuseTimer > 0 || item.armed || item.active);
if (item.type === "robot_cleaner") return Boolean(item.active || item.cleanerActive || item.targetId || item.targetRef || item.state === "clean" || item.state === "return");
if (PIN_ITEM_TYPES.has(item.type)) return Boolean(item.pinState && item.pinState !== "idle" && item.pinState !== "ground");
if (item.type === "plushie") return Boolean(item.carriedById || item.onHead);
return false;
}
function idleRealtimeInterval(item) {
if (!item) return Infinity;
if (item.type === "ball" || item.type === "balloon") return 0.24;
if (item.type === "fan") return 0.60;
if (item.type === "genkotsu") return 0.45;
if (item.type === "firecracker" || item.type === "flame_firecracker" || item.type === "sticky_bomb") return 0.35;
if (item.type === "robot_cleaner") return 0.75;
if (PIN_ITEM_TYPES.has(item.type)) return 0.80;
if (item.isStructure && item.type === "plushie") return 0.95;
return 0.80;
}
function adjustedInterval(baseInterval) {
if (!Number.isFinite(baseInterval) || baseInterval <= 0) return baseInterval;
const profile = global.TarinaiPerf?.performanceProfile?.();
if (profile?.simulationThrottled === false) return 0;
return baseInterval * Math.max(1, Number(profile?.itemCadenceScale || 1));
}
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.
if (item.type === "rotator" || item.type === "reciprocator" || item.type === "poison_block" || item.type === "rope" || item.type === "rod") return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return 0;
if (PIN_ITEM_TYPES.has(item.type)) return 0;
if (item.isStructure && item.type === "plushie") return 0;
if (item.isStructure && item.type === "grass_bed") return 3.0;
if (item.type === "fire") {
const fireCount = item.world?.itemsOfType?.("fire")?.length || 0;
if (fireCount > 64) return mobile ? 0.55 : 0.35;
if (fireCount > 36) return mobile ? 0.42 : 0.25;
return mobile ? 0.30 : 0.18;
}
if (item.type === "rotator" || item.type === "reciprocator" || item.type === "poison_block" || item.type === "rope" || item.type === "rod" || item.type === "spring" || item.type === "wire" || item.type === "insulated_wire" || item.type === "pressure_switch") return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (PIN_ITEM_TYPES.has(item.type)) return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (item.isStructure && item.type === "plushie") return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (item.isStructure && item.type === "grass_bed") return adjustedInterval(3.0);
if (item.type === "fire") return adjustedInterval(0.18);
if (item.type === "nest_box" && global.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(item)) return adjustedInterval(0.18);
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi") return 2.4;
if (item.type === "zunchi" && (item.burning || (item.burnTimer || 0) > 0.02)) return adjustedInterval(0.18);
if (item.type === "zunchi") {
const groundId = item.world?.groundType || (typeof world !== "undefined" ? world?.groundType : "") || "soil";
const decayMul = Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1;
if (decayMul >= 100) return adjustedInterval(0.12);
if (decayMul > 1.01) return adjustedInterval(0.28);
return adjustedInterval(2.4);
}
if (item.type === "grass") return Infinity;
if (item.type === "duplicator") return mobile ? 0.8 : 0.5;
if (item.type === "water") return mobile ? 2.4 : 1.8;
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 (isServingFoodType(item.type)) return 5.0;
if (item.type === "duplicator") return adjustedInterval(0.5);
if (item.type === "water") return adjustedInterval(1.8);
if (item.type === "trace" || item.type === "splat") return adjustedInterval(1.2);
if (item.type === "ant_corpse") return adjustedInterval(3.5);
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return adjustedInterval(5.0);
if (isServingFoodType(item.type)) return adjustedInterval(5.0);
return Infinity;
}
@ -83,6 +132,7 @@
global.TarinaiItemUpdatePolicy = Object.freeze({
itemUpdateInterval,
itemRealtimeActive,
forScheduledItems,
ensureBuckets,
candidateItems,

View file

@ -52,6 +52,7 @@
(worldRef.items || []).length,
worldRef.itemBucketRebuildsTotal || 0,
worldRef.itemBucketsDirty ? 1 : 0,
global.TarinaiPerf?.simulationOptimizationTier?.() || "balanced",
].join(":");
}
@ -88,8 +89,9 @@
reason,
};
for (const item of candidateItems(worldRef)) {
item._schedulerLastAt = Number.isFinite(item._schedulerLastAt) ? item._schedulerLastAt : now;
scheduleItem(state, item, now, itemUpdateInterval(item));
const interval = itemUpdateInterval(item);
if (interval > 0 && !Number.isFinite(item._schedulerLastAt)) item._schedulerLastAt = now;
scheduleItem(state, item, now, interval);
}
worldRef._itemUpdateScheduler = state;
worldRef._itemUpdateRealtime = state.realtime;
@ -102,6 +104,10 @@
return worldRef._itemUpdateScheduler;
}
function scheduledBudgetFor() {
return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().scheduledItemBudget || 30));
}
function runRealtime(worldRef, state, dt) {
let ran = 0;
const now = worldRef.time || 0;
@ -116,8 +122,9 @@
global.TarinaiItemRuntime.updateOne(item, dt, worldRef);
item._schedulerLastAt = now;
ran += 1;
if (Number.isFinite(itemUpdateInterval(item)) && itemUpdateInterval(item) <= 0) nextRealtime.push(item);
else scheduleItem(state, item, now, itemUpdateInterval(item));
const nextInterval = itemUpdateInterval(item);
if (Number.isFinite(nextInterval) && nextInterval <= 0) nextRealtime.push(item);
else scheduleItem(state, item, now, nextInterval);
} else {
scheduleItem(state, item, now, interval);
}
@ -127,10 +134,10 @@
return ran;
}
function runDue(worldRef, state) {
function runDue(worldRef, state, budget = scheduledBudgetFor(worldRef, state)) {
let ran = 0;
const now = worldRef.time || 0;
const maxDue = Math.max(24, Math.ceil((state.heap.length || 0) * 0.08));
const maxDue = Math.max(1, Number(budget || 0) || 1);
let dueRuns = 0;
while (state.heap.length && state.heap[0].time <= now + 0.0001 && dueRuns < maxDue) {
const entry = heapPop(state.heap);
@ -149,16 +156,37 @@
dueRuns += 1;
scheduleItem(state, item, now, itemUpdateInterval(item));
}
state.lastBudgetDue = maxDue;
state.lastDueSkipped = (state.heap.length && state.heap[0].time <= now + 0.0001) ? 1 : 0;
return ran;
}
function physicsBudgetFor(worldRef) {
const itemCount = (worldRef?.items || []).length || 0;
const mobile = Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
if (mobile) return { maxPairs: 150, maxLinks: 96, postMaxPairs: 40, maxSubsteps: 2 };
return itemCount > 80
? { maxPairs: 240, maxLinks: 170, postMaxPairs: 78, maxSubsteps: 4 }
: { maxPairs: 300, maxLinks: 210, postMaxPairs: 100, maxSubsteps: 5 };
function physicsBudgetFor() {
return { ...(global.TarinaiPerf?.performanceProfile?.().physicsBudget || {}) };
}
function poisonContactBudgetFor(worldRef) {
const possibleTargets =
(worldRef?.itemCounts?.grass_bed || 0) +
(worldRef?.itemCounts?.zunchi || 0) +
(worldRef?.itemCounts?.water || 0) +
(worldRef?.itemCounts?.grass || 0);
if ((worldRef?.itemCounts?.poison_block || 0) <= 0 || possibleTargets <= 0) return { maxPoison: 0, maxTargets: 0, skip: true };
return { ...(global.TarinaiPerf?.performanceProfile?.().poisonContactBudget || { maxPoison: 12, maxTargets: 42, skip: false }) };
}
function wakeItem(worldRef, item, holdSeconds = 0.22) {
if (!worldRef || !item || item.dead) return false;
const state = ensure(worldRef);
const now = worldRef.time || 0;
item._activeUntil = Math.max(Number(item._activeUntil || -Infinity) || -Infinity, now + Math.max(0.05, Number(holdSeconds || 0.22) || 0.22));
// Invalidate a pending heap entry and promote the item immediately. This is
// used by continuous external forces such as fan wind.
item._schedulerToken = (item._schedulerToken || 0) + 1;
item._schedulerLastAt = now;
if (!state.realtime.includes(item)) state.realtime.push(item);
worldRef._itemUpdateRealtime = state.realtime;
return true;
}
function run(worldRef, dt) {
@ -170,16 +198,19 @@
let ran = realtimeRan;
if (realtimeEnd) realtimeEnd();
const dueEnd = global.TarinaiPerf.begin("update.items.scheduled");
const dueRan = runDue(worldRef, state);
const budgetDue = scheduledBudgetFor(worldRef, state);
const dueRan = runDue(worldRef, state, budgetDue);
ran += dueRan;
if (dueEnd) dueEnd();
global.TarinaiSignalSystem?.updateWorld?.(worldRef, dt);
const physicsEnd = global.TarinaiPerf.begin("update.physicsWorld");
const physicsBudget = physicsBudgetFor(worldRef);
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;
const poisonContactBudget = poisonContactBudgetFor(worldRef);
const poisonContactRemoved = poisonContactBudget.skip ? 0 : (worldRef.resolvePoisonBlockItemContacts?.(poisonContactBudget) || 0);
if (physicsEnd) physicsEnd();
state.signature = schedulerSignature(worldRef);
worldRef._itemUpdateSchedulerStats = {
@ -190,16 +221,20 @@
lastRan: ran,
realtimeRan,
dueRan,
budgetDue,
dueSkipped: state.lastDueSkipped || 0,
sleeping: state.heap.length,
activeRealtime: state.realtime.length,
physicsBudget,
poisonContactBudget,
poisonContactRemoved,
physics: physicsWorld || mechanicalWorld,
constraints: constraintWorld,
postConstraintPairs,
mechanicalStats: worldRef._mechanicalWorldStats || null,
constraintStats: worldRef._constraintWorldStats || null,
};
return ran + (physicsWorld.ran || mechanicalWorld?.ran || 0) + constraintWorld + poisonContactRemoved;
return ran + (physicsWorld.ran || mechanicalWorld?.ran || 0) + poisonContactRemoved;
} finally {
if (end) end();
}
@ -209,5 +244,5 @@
return worldRef?._itemUpdateRealtime || [];
}
global.TarinaiItemUpdateScheduler = Object.freeze({ run, trackedDynamicItems });
global.TarinaiItemUpdateScheduler = Object.freeze({ run, trackedDynamicItems, wakeItem });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -546,6 +546,9 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
pinState: "loose",
spin: type === "pushpin" || type === "oshibyo" ? -0.55 : 0,
zunchiVariant: "zunchi",
stage: type === "zunchi" ? "fresh" : undefined,
freshness: type === "zunchi" ? 1 : undefined,
fertility: type === "zunchi" ? 0 : undefined,
antCount: typeof ANT_NEST_START_COUNT !== "undefined" ? ANT_NEST_START_COUNT : 6,
fuseTimer: 5,
fuseMax: 5,
@ -599,6 +602,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
oshibyo: 0.62,
firecracker: 0.90,
flame_firecracker: 0.90,
sticky_bomb: 0.92,
fire: 1.05,
giant_drug: 1.22,
dwarf_drug: 0.56,
@ -607,7 +611,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
if (isConsumableSpriteType(type)) scale *= 1.95;
else if (["grass", "water", "zunda_juice", "mercury", "fire"].includes(type)) scale *= 1.85;
else if (type === "duplicator") scale *= 1.55;
else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "fire", "genkotsu"].includes(type)) scale *= 1.08;
else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu"].includes(type)) scale *= 1.08;
else if (isParamEffectItemType(type)) scale *= 1.35;
if (watermark) scale *= 1.42;
ctx.scale(scale, scale);
@ -627,8 +631,6 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
if (typeof window !== "undefined") {
window.isConsumableSpriteType = isConsumableSpriteType;
window.foodServingSizeForRemaining = foodServingSizeForRemaining;
window.foodServingVisualScaleFor = foodServingVisualScaleFor;
window.updateServingFoodVisualSize = updateServingFoodVisualSize;
window.drawConsumableFieldSprite = drawConsumableFieldSprite;
window.drawStoredConsumableSprite = drawStoredConsumableSprite;

View file

@ -16,8 +16,6 @@ function ensureWorldBootstrap() {
let world = ensureWorldBootstrap();
let last = nowSec();
let statsTimer = 0;
let fpsTimer = 0;
let fpsFrames = 0;
window.__tarinaiFps = 0;
const LOADING_SPRITES = SPRITES
@ -74,6 +72,7 @@ function setLoadingProgress(text = "", ratio = 0) {
function hideLoadingScreen() {
if (loadingSpriteTimer) { clearInterval(loadingSpriteTimer); loadingSpriteTimer = 0; }
document.getElementById("loadingSpriteStrip")?.replaceChildren();
setLoadingProgress("\u8d77\u52d5\u5b8c\u4e86", 1);
const screen = document.getElementById("loadingScreen");
if (!screen) return;
@ -84,14 +83,8 @@ function loop() {
const rawDt = t - last;
const dt = clamp(rawDt, 0, 0.05);
last = t;
window.TarinaiPerf.observeFrameDelta(rawDt);
window.TarinaiPerf.beginFrame();
fpsTimer += rawDt;
fpsFrames += 1;
if (fpsTimer >= 0.5) {
window.__tarinaiFps = Math.round(fpsFrames / Math.max(fpsTimer, 0.001));
fpsTimer = 0;
fpsFrames = 0;
}
const endUpdate = window.TarinaiPerf.begin("update.total");
world.update(dt);
if (endUpdate) endUpdate();
@ -120,7 +113,7 @@ loadImages((done, total) => {
setLoadingProgress("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u69cb\u7bc9\u4e2d", 0.72);
applyFieldLayout("garden");
world.reset(null, "garden");
window.bindGroundUI?.(world);
window.TarinaiGroundUI?.bind?.(world);
selectTool("observe");
setLoadingProgress("\u8868\u793a\u3092\u521d\u671f\u5316\u4e2d", 0.84);
renderLog(world.logs);

View file

@ -7,17 +7,13 @@
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,
});

View file

@ -40,14 +40,14 @@
}
function bodyOf(item) { return ensureBody(item, { syncFromLegacy: false }); }
function usingStepScratch(item) { return item?._physicsStepScratch === true; }
function runMechanicalStep(item, commitReason, fn) {
function runMechanicalStep(item, fn) {
const prevScratch = item._physicsStepScratch === true;
item._physicsStepScratch = true;
try {
return fn();
} finally {
item._physicsStepScratch = prevScratch;
commitBody(item, commitReason);
commitBody(item);
}
}
function bodyPose(item) { return usingStepScratch(item) ? null : (bodyOf(item)?.pose || null); }
@ -56,11 +56,9 @@
function bodyRail(item) { return bodyOf(item)?.rail || null; }
function bodyShape(item) { return bodyOf(item)?.shape || null; }
function applyBodyState(item, opts = {}) { return bodySystem()?.applyBodyState?.(item, item?.physicsBody, opts) || false; }
function normalizeBodyState(item) { return bodySystem()?.normalizeBodyState?.(item, item?.physicsBody) || item?.physicsBody || null; }
function commitBody(item, reason = "mechanical-body-mutated") {
function commitBody(item) {
if (!item || !isMechanicalType(item)) return null;
// Capture before ensureBody()/normalizeBodyState() can restore the old body
// pose to item.x/y/angle.
// Capture the scratch pose before writing it back to the authoritative body.
const sx = num(item.x);
const sy = num(item.y);
const sa = num(item.angle);
@ -74,11 +72,11 @@
item.x = sx;
item.y = sy;
item.angle = sa;
normalizeBodyState(item);
bodySystem()?.markBodyChanged?.(item, reason);
return body;
}
function isPowered(item) {
const signalOverride = global.TarinaiSignalSystem?.powerOverride?.(item);
if (signalOverride != null) return Boolean(signalOverride);
const motor = bodyMotor(item);
if (motor) return item?.type === "poison_block" ? false : motor.powered !== false;
return item?.type === "rotator" ? ps(item, "motorOn", true) !== false : (item?.type === "reciprocator" ? ps(item, "railOn", true) !== false : false);
@ -106,7 +104,6 @@
if (body) {
body.shape = body.shape || {};
body.shape.version = nextVersion;
bodySystem()?.markBodyChanged?.(item, "mechanical-geometry-edited");
}
item._mechanicalShapeVersion = nextVersion;
item._mechanicalGeomCache = null;
@ -124,7 +121,6 @@
if (body) {
body.sleep = body.sleep || {};
body.sleep.awakeUntil = Math.max(num(body.sleep.awakeUntil), awakeUntil);
bodySystem()?.markBodyChanged?.(item, reason);
}
if (item.world) {
item.world._itemUpdateScheduler = null;
@ -181,7 +177,7 @@
if (out.length >= 128) break;
}
if (!out.length) {
if (item?.type === "poison_block") out.push([-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]);
if (item?.type === "poison_block") out.push([-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]);
else out.push(item?.type === "rotator" ? [-78, 0, 78, 0] : [-78, 0, 78, 0], ...(item?.type === "rotator" ? [[0, -52, 0, 52]] : []));
}
return out;
@ -236,7 +232,7 @@
const cache = geomCache(item);
const shape = bodyShape(item);
const raw = Array.isArray(shape?.segments) ? shape.segments : [];
const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 14 : 12);
const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 11 : 12);
const key = `${item?.type || ""}|${shapeVersion(item)}|${raw.length}|${rawSegmentSignature(raw)}|${thickKey || ""}`;
if (cache?.localKey === key && cache.localSegments) return cache.localSegments;
const normalized = normalizeRawSegments(item);
@ -248,7 +244,7 @@
return out;
}
function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 14 : 12)))); }
function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 11 : 12)))); }
function extent(item) {
const cache = geomCache(item);
@ -262,10 +258,6 @@
return value;
}
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
return global.TarinaiGeometry.orientedRectAabb(cx, cy, halfW, halfH, angle);
}
function worldSegments(item) {
if (!item || item.dead || !isMechanicalType(item)) return [];
const cache = geomCache(item);
@ -364,7 +356,7 @@
const halfH = visualThickness / 2 + collisionSkin;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle);
const aabb = global.TarinaiGeometry.orientedRectAabb(cx, cy, halfW, halfH, angle);
const base = {
left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom,
cx, cy, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true,
@ -513,7 +505,7 @@
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"); }
if (changed) { commitBody(item); wakeItem(item, "poison-block-impulse"); }
return changed;
}
if (item.type === "rotator") {
@ -525,7 +517,7 @@
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0008) return false;
const limit = powered ? 3.2 : 2.25;
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) + delta, -limit, limit), "rotator-torque");
commitBody(item, "rotator-impulse");
commitBody(item);
return true;
}
const a = axis(item);
@ -548,7 +540,7 @@
if (!Number.isFinite(delta) || Math.abs(delta) < 0.05) return false;
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) + delta, -150, 150), "slide-impulse");
}
commitBody(item, "reciprocator-impulse");
commitBody(item);
return true;
}
@ -563,7 +555,7 @@
const delta = clamp((axisDrive * 0.030 + normalDrive * 0.080) * scale, -24, 24);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.08) return false;
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.84 + delta, -125, 125), "passive-slide-impulse");
commitBody(item, "passive-reciprocator-impulse");
commitBody(item);
return true;
}
@ -577,7 +569,7 @@
const delta = clamp((tangential * 0.14 + normalTorque * incomingNormal * 0.0016) * scale, -0.18, 0.18);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.002) return false;
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.86 + delta, -1.75, 1.75), "passive-spin-impulse");
commitBody(item, "passive-rotator-impulse");
commitBody(item);
return true;
}
@ -650,7 +642,7 @@
item.x = p.x;
item.y = p.y;
const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001;
if (changed) commitBody(item, "rail-correction");
if (changed) commitBody(item);
return changed;
}
@ -688,7 +680,7 @@
pset(item, "xv", vx - nx * inward * 0.58, `${reason}-normal-damp`);
pset(item, "yv", vy - ny * inward * 0.58, `${reason}-normal-damp`);
}
commitBody(item, reason);
commitBody(item);
wakeItem(item, reason);
return true;
}
@ -708,7 +700,7 @@
const current = ps(item, "slideSpeed", 0);
if (current * along < 0) pset(item, "slideSpeed", current * 0.38, `${reason}-slide-damp`);
const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001;
if (changed) commitBody(item, reason);
if (changed) commitBody(item);
return changed;
}
return false;
@ -774,7 +766,7 @@
pset(item, "slideSpeed", -Math.abs(ps(item, "slideSpeed", 0)) * before * 0.20, "slide-reverse-passive");
item._reciprocatorBlockedTimer = 0;
item._reciprocatorAutoReverseAt = now;
commitBody(item, `reciprocator-auto-reverse:${reason}`);
commitBody(item);
worldRef?.markSpatialDirty?.("reciprocator-auto-reverse");
worldRef && (worldRef.drawListDirty = true);
return true;
@ -791,9 +783,9 @@
const driveX = a.x * dir;
const driveY = a.y * dir;
const ahead = driveX * num(nx) + driveY * num(ny);
// Side brushes are allowed to slide. A direct or moderately diagonal
// contact means the powered block has hit a physical object in its path.
if (ahead < -0.18) return false;
// Side brushes and contacts behind the motor are allowed to slide. A
// direct or moderately diagonal contact in the travel direction reverses.
if (ahead < 0.18) return false;
pset(item, "railDir", -dir, "slide-contact-reverse");
pset(item, "slideSpeed", 0, "slide-contact-clear-slide");
const body = ensureBody(item, { syncFromLegacy: false });
@ -806,7 +798,7 @@
item._reciprocatorBlockedTimer = 0;
item._reciprocatorContactReverseAt = now;
item._reciprocatorAutoReverseAt = now;
commitBody(item, `reciprocator-contact-reverse:${reason}`);
commitBody(item);
if (worldRef) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("reciprocator-contact-reverse");
@ -818,7 +810,7 @@
if (!item || item.dead || item.type !== "rotator" || !isPowered(item)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
if ((item._rotatorContactBrakeAt || -999) + 0.045 > now) return false;
const motorSpeed = Math.max(0, ps(item, "motorSpeed", 0));
const motorSpeed = Math.abs(ps(item, "motorSpeed", 0));
if (motorSpeed <= 0) return false;
const drive = signedDrive(item);
const sign = Math.sign(drive || motorSpeed) || 1;
@ -829,7 +821,7 @@
const brake = Math.max(0.32, motorSpeed * 0.82);
pset(item, "spin", clamp(current - sign * brake, -3.4, 3.4), "rotator-contact-brake");
item._rotatorContactBrakeAt = now;
commitBody(item, `rotator-contact-brake:${reason}`);
commitBody(item);
if (worldRef) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("rotator-contact-brake");
@ -946,7 +938,9 @@
other.lastKickerId = item.id || "mechanical";
}
if (item.type === "reciprocator") {
applyRailCorrection(item, -nx, -ny, best.overlap, 0.18);
// Balls, stones, pins and other movable circle items are cargo, not rail
// blockers. The reciprocator may push them, but its direction, phase
// and motor velocity are left untouched.
} else {
applyImpulse(item, best.x, best.y, -nx * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), -ny * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), item.type === "poison_block" ? 0.58 : 0.34);
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "physical-circle");
@ -956,7 +950,7 @@
if (contacts >= 18) break;
}
if (changed) {
commitBody(item, "mechanical-circle-contact");
commitBody(item);
worldRef.markSpatialDirty?.("mechanical-circle-contact");
worldRef.drawListDirty = true;
}
@ -995,6 +989,10 @@
let contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75);
if (!contact) return false;
let info = contact.info;
// Reverse from the initial impact. Separation can fully clear the overlap,
// but that still represents a real collision and must not skip reversal.
if (a.type === "reciprocator") reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
if (b.type === "reciprocator") reverseReciprocatorOnContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical");
const separated = applyMechanicalPairSeparation(a, b, info, dt, worldRef);
if (separated) {
// Position projection may fully clear the overlap. Re-query before adding
@ -1017,8 +1015,6 @@
applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, separated ? 0.78 : 1.0);
applyRailCorrection(a, info.nx, info.ny, info.overlap, b.type === "reciprocator" ? 0.42 : 0.72);
applyRailCorrection(b, -info.nx, -info.ny, info.overlap, a.type === "reciprocator" ? 0.42 : 0.72);
if (a.type === "reciprocator") reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
if (b.type === "reciprocator") reverseReciprocatorOnContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical");
if (a.type === "rotator") brakeRotatorOnContact(a, dt, worldRef, "mechanical");
if (b.type === "rotator") brakeRotatorOnContact(b, dt, worldRef, "mechanical");
@ -1056,8 +1052,13 @@
let changed = false;
for (const mechRect of rects) {
for (const fenceRect of fences) {
const velocity = bodyVelocity(item);
const vx = Number(velocity?.x || item.vx || 0);
const vy = Number(velocity?.y || item.vy || 0);
if (fenceRect?.oneWay && global.TarinaiGeometry.oneWayFenceAllows(item, fenceRect, vx, vy)) continue;
let info = rectOverlapInfo(mechRect, fenceRect, item.type === "rotator" ? 1.35 : 0.5);
if (!info) continue;
if (item.type === "reciprocator") reverseReciprocatorOnContact(item, info.nx, info.ny, dt, worldRef, "solid-obstacle");
const separated = applyMechanicalFenceSeparation(item, info, dt, worldRef);
if (separated) {
const post = strongestRectContact(obstacleRects(item), [fenceRect], 0.2);
@ -1070,7 +1071,6 @@
applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.92 : 1.10);
applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.82);
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "fence");
if (item.type === "reciprocator") reverseReciprocatorOnContact(item, info.nx, info.ny, dt, worldRef, "solid-obstacle");
changed = true;
}
}
@ -1106,8 +1106,8 @@
function updateRotator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "rotator") return false;
applyBodyState(item);
return runMechanicalStep(item, "rotator-step", () => {
if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, () => {
item.amount = 999;
if (item.playerHeld || item._heldByPlayer) return false;
const powered = isPowered(item);
@ -1203,7 +1203,7 @@
const p = positionFromPhase(item);
item.x = p.x;
item.y = p.y;
commitBody(item, "reciprocator-stall-repair");
commitBody(item);
worldRef?.markSpatialDirty?.("reciprocator-stall-repair");
if (worldRef) worldRef.drawListDirty = true;
return true;
@ -1211,8 +1211,8 @@
function updateReciprocator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "reciprocator") return false;
applyBodyState(item);
return runMechanicalStep(item, "reciprocator-step", () => {
if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, () => {
item.amount = 999;
resetAnchor(item);
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
@ -1282,14 +1282,12 @@
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;
if (!global.TarinaiGeometry.circleOverlapsRect(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;
@ -1314,7 +1312,7 @@
if (contacts >= 10) break;
}
if (changed) {
commitBody(item, "poison-block-tarinai-contact");
commitBody(item);
worldRef.markSpatialDirty?.("poison-block-tarinai-contact");
worldRef.drawListDirty = true;
}
@ -1323,8 +1321,8 @@
function updatePoisonBlock(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "poison_block") return false;
applyBodyState(item);
return runMechanicalStep(item, "poison-block-step", () => {
if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, () => {
item.amount = 999;
item.r = Math.max(item.r || 64, Math.min(460, extent(item)));
if (item.playerHeld || item._heldByPlayer) {
@ -1394,9 +1392,14 @@
function collectMechanicalBodies(worldRef, opts = {}) {
if (!worldRef?.itemsOfType) return [];
worldRef.ensureItemBuckets?.("mechanical-world");
const maxItems = Math.max(24, Number(opts.maxItems || 220) || 220);
const cacheKey = `${worldRef.itemBucketRebuildsTotal || 0}:${maxItems}`;
const cached = worldRef._mechanicalBodiesCache;
if (cached?.key === cacheKey && Array.isArray(cached.items)) {
return cached.items.filter(item => item && !item.dead);
}
const out = [];
const seen = new Set();
const maxItems = Math.max(24, Number(opts.maxItems || 220) || 220);
const add = (item) => {
if (!item || item.dead || seen.has(item)) return false;
item.world = worldRef;
@ -1417,10 +1420,12 @@
for (const type of ["rotator", "poison_block", "reciprocator"]) {
for (const item of worldRef.itemsOfType(type) || []) {
if (!item || item.dead || seen.has(item)) continue;
if (out.length >= maxItems) return out;
if (out.length >= maxItems) break;
add(item);
}
if (out.length >= maxItems) break;
}
worldRef._mechanicalBodiesCache = { key: cacheKey, items: out.slice() };
return out;
}
@ -1606,7 +1611,7 @@
const grid = gridInfo.grid;
const seen = Object.create(null);
const maxPairs = Math.max(24, Number(opts.maxPairs || 240) || 240);
const maxCandidates = Math.max(maxPairs + 48, Math.min(900, maxPairs * 3));
const maxCandidates = maxPairs >= 100000000 ? Number.POSITIVE_INFINITY : Math.max(maxPairs + 48, Math.min(900, maxPairs * 3));
const pairCandidates = [];
let pairs = 0;
for (const bucket of grid.values()) {
@ -1654,9 +1659,8 @@
// Fences are static in this runtime. A sleeping poison block or passive body
// cannot create a new fence contact by itself, so skip it before expensive geometry work.
if (activeSet && !activeSet.has(item)) continue;
// Reciprocators are rail-driven and should only reverse/stop against
// other physical items, never against static fences.
if (item.type === "reciprocator") continue;
// Rail-driven reciprocators also participate here. A solid obstacle in
// their travel path is a collision and reverses the motor direction.
if (!shouldCollideMechanical(item)) continue;
if (resolveFenceContacts(item, dt, worldRef, reach(item) + 80)) solved += 1;
count += 1;
@ -1693,6 +1697,9 @@
const clampedDt = Math.max(0.001, Math.min(0.05, rawDt));
const bodies = collectMechanicalBodies(worldRef, opts);
if (!bodies.length) return { ran: 0, active: 0, solved: 0, fenceSolved: 0, links: 0, candidates: 0 };
if (opts.statePrepared !== true) {
for (const item of bodies) applyBodyState(item);
}
const active = [];
for (const item of bodies) if (isMechanicallyActive(item)) active.push(item);
const movingWork = active.length;
@ -1700,7 +1707,7 @@
for (const item of active) maxMotion = Math.max(maxMotion, estimatedBodyMotion(item, clampedDt));
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; }
if ((item?.type === "rotator" || item?.type === "reciprocator") && 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;
@ -1721,9 +1728,9 @@
for (const item of active) {
if (!item || item.dead || item.playerHeld || item._heldByPlayer) continue;
let changed = false;
if (item.type === "rotator") changed = updateRotator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true });
else if (item.type === "reciprocator") changed = updateReciprocator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true });
else if (item.type === "poison_block") changed = updatePoisonBlock(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true });
if (item.type === "rotator") changed = updateRotator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true, statePrepared: true });
else if (item.type === "reciprocator") changed = updateReciprocator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true, statePrepared: true });
else if (item.type === "poison_block") changed = updatePoisonBlock(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true, statePrepared: true });
if (changed) { moved = true; subMoved = true; ran += 1; }
}
if (subMoved) {
@ -1791,7 +1798,7 @@
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)) || (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");
commitBody(item);
worldRef?.markSpatialDirty?.("poke-passive-rotator");
return true;
}
@ -1800,7 +1807,7 @@
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) || (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");
commitBody(item);
worldRef?.markSpatialDirty?.("poke-passive-reciprocator");
return true;
}
@ -1815,18 +1822,15 @@
isMechanicalType,
isPowered,
sanitizeSegments,
worldSegments,
obstacleRects,
placementRects,
poisonHazardRects,
boundsAabb,
extent,
reach,
resetAnchor,
axis,
railAxisAngle,
applySurfaceVelocityToCircle,
resolveInteractions,
resolveMechanicalPairs,
updateWorld: updateMechanicalWorld,
updateRotator,

View file

@ -17,15 +17,109 @@
let frameOpen = null;
let frameCount = 0;
let lastSmoothAt = 0;
const storageKey = "tarinai_visual_settings_v1";
const storageKey = "tarinai_visual_settings_v2";
const legacyStorageKey = "tarinai_visual_settings_v1";
const allowed = {
effects: new Set(["off", "low", "mid", "high"]),
shadows: new Set(["off", "low", "mid", "high"]),
details: new Set(["low", "mid", "high"]),
effects: new Set(["off", "high"]),
shadows: new Set(["off", "high"]),
details: new Set(["auto", "low", "high"]),
};
const defaults = Object.freeze({ effects: "high", shadows: "high", details: "high" });
const defaults = Object.freeze({ effects: "high", shadows: "high", details: "auto" });
const PROFILE_FULL = Object.freeze({
id: "full",
renderTier: "high",
dprScale: 1,
simulationThrottled: false,
creatureCadenceScale: 0,
creatureBackgroundBudget: 1000000000,
itemCadenceScale: 0,
scheduledItemBudget: 1000000000,
aiBudget: 1000000000,
envBudget: 1000000000,
effectCap: 1000000000,
effectUpdateBudget: 1000000000,
effectDrawBudget: 160,
fireInteractionBudget: 1000000000,
activeFireLimit: 1000000000,
physicsBudget: Object.freeze({
maxItems: 1000000000,
maxPairs: 1000000000,
maxLinks: 1000000000,
postMaxPairs: 1000000000,
maxSubsteps: 8,
focusThreshold: 1000000000,
maxSubstepPairs: 1000000000,
maxSubstepFenceItems: 1000000000,
}),
poisonContactBudget: Object.freeze({ maxPoison: 1000000000, maxTargets: 1000000000, skip: false }),
});
const PROFILE_BALANCED = Object.freeze({
id: "balanced",
renderTier: "mid",
dprScale: 0.86,
simulationThrottled: true,
creatureCadenceScale: 1,
creatureBackgroundBudget: 36,
itemCadenceScale: 1,
scheduledItemBudget: 30,
aiBudget: 10,
envBudget: 5,
effectCap: 220,
effectUpdateBudget: 84,
effectDrawBudget: 96,
fireInteractionBudget: 14,
activeFireLimit: 60,
physicsBudget: Object.freeze({
maxItems: 180,
maxPairs: 170,
maxLinks: 120,
postMaxPairs: 48,
maxSubsteps: 3,
focusThreshold: 38,
maxSubstepPairs: 96,
maxSubstepFenceItems: 24,
}),
poisonContactBudget: Object.freeze({ maxPoison: 12, maxTargets: 42, skip: false }),
});
const PROFILE_AGGRESSIVE = Object.freeze({
id: "aggressive",
renderTier: "low",
dprScale: 0.72,
simulationThrottled: true,
creatureCadenceScale: 1.55,
creatureBackgroundBudget: 16,
itemCadenceScale: 1.55,
scheduledItemBudget: 14,
aiBudget: 6,
envBudget: 3,
effectCap: 140,
effectUpdateBudget: 48,
effectDrawBudget: 36,
fireInteractionBudget: 8,
activeFireLimit: 40,
physicsBudget: Object.freeze({
maxItems: 100,
maxPairs: 82,
maxLinks: 52,
postMaxPairs: 20,
maxSubsteps: 1,
focusThreshold: 28,
maxSubstepPairs: 54,
maxSubstepFenceItems: 14,
}),
poisonContactBudget: Object.freeze({ maxPoison: 8, maxTargets: 28, skip: false }),
});
const profiles = Object.freeze({ full: PROFILE_FULL, balanced: PROFILE_BALANCED, aggressive: PROFILE_AGGRESSIVE });
let visualSettings = loadVisualSettings();
let resizeRequested = false;
let autoProfileId = "full";
let fpsAccumSeconds = 0;
let fpsAccumFrames = 0;
let smoothedFps = 0;
let pendingProfileId = "";
let pendingProfileSamples = 0;
function bucketFor(label) {
let b = buckets.get(label);
@ -43,7 +137,6 @@
return () => {
const top = stack.pop();
const active = top === frame ? frame : top || frame;
// If calls ended out of order, keep the profiler alive rather than throwing.
if (top !== frame) {
const idx = stack.lastIndexOf(frame);
if (idx >= 0) stack.splice(idx, 1);
@ -83,12 +176,11 @@
const raw = String(value || "");
if (key === "effects" || key === "shadows") {
if (raw === "off") return "off";
if (raw === "on") return "high";
if (raw === "low" || raw === "mid" || raw === "high") return raw;
if (raw === "on" || raw === "low" || raw === "mid" || raw === "high") return "high";
}
if (key === "details") {
if (raw === "low") return "low";
if (raw === "mid" || raw === "high") return raw;
if (raw === "auto" || raw === "low" || raw === "high") return raw;
if (raw === "mid") return "auto";
}
return defaults[key];
}
@ -105,8 +197,15 @@
function loadVisualSettings() {
try {
const raw = global.localStorage?.getItem(storageKey);
return sanitizeVisualSettings(raw ? JSON.parse(raw) : null);
const current = global.localStorage?.getItem(storageKey);
if (current) return sanitizeVisualSettings(JSON.parse(current));
const legacy = global.localStorage?.getItem(legacyStorageKey);
if (!legacy) return { ...defaults };
const migrated = sanitizeVisualSettings(JSON.parse(legacy));
// Old builds had no automatic mode. Preserve explicit effect/shadow choices,
// but use the new automatic drawing mode as the migration default.
migrated.details = "auto";
return migrated;
} catch (_) {
return { ...defaults };
}
@ -118,10 +217,63 @@
} catch (_) {}
}
function dprScaleForDetails(details) {
if (details === "low") return 0.72;
if (details === "mid") return 0.86;
return 1;
function activeProfile() {
if (visualSettings.details === "high") return PROFILE_FULL;
if (visualSettings.details === "low") return PROFILE_AGGRESSIVE;
return profiles[autoProfileId] || PROFILE_BALANCED;
}
function chooseAutoProfile(fps) {
if (!Number.isFinite(fps) || fps <= 0) return autoProfileId;
if (autoProfileId === "full") return fps < 51 ? "balanced" : "full";
if (autoProfileId === "aggressive") return fps >= 45 ? "balanced" : "aggressive";
if (fps >= 57) return "full";
if (fps < 38) return "aggressive";
return "balanced";
}
function applyAutoProfile(nextId) {
if (!profiles[nextId] || nextId === autoProfileId) return false;
const before = activeProfile();
autoProfileId = nextId;
const after = activeProfile();
if (before.dprScale !== after.dprScale || before.renderTier !== after.renderTier) resizeRequested = true;
if (after.id === "aggressive") global.TarinaiAssetsMemory?.trim?.("aggressive");
try {
global.dispatchEvent?.(new CustomEvent("tarinai-performance-profile", { detail: { profile: after.id, fps: smoothedFps } }));
} catch (_) {}
return true;
}
function observeFrameDelta(rawDt) {
const dt = Number(rawDt);
if (!Number.isFinite(dt) || dt <= 0 || dt > 0.25 || global.document?.hidden) return;
fpsAccumSeconds += dt;
fpsAccumFrames += 1;
if (fpsAccumSeconds < 0.75) return;
const sampledFps = fpsAccumFrames / Math.max(0.001, fpsAccumSeconds);
smoothedFps = smoothedFps > 0 ? smoothedFps * 0.65 + sampledFps * 0.35 : sampledFps;
global.__tarinaiFps = Math.round(smoothedFps);
fpsAccumSeconds = 0;
fpsAccumFrames = 0;
if (visualSettings.details !== "auto") return;
const desired = chooseAutoProfile(sampledFps);
if (desired === autoProfileId) {
pendingProfileId = "";
pendingProfileSamples = 0;
return;
}
if (pendingProfileId !== desired) {
pendingProfileId = desired;
pendingProfileSamples = 1;
return;
}
pendingProfileSamples += 1;
if (pendingProfileSamples >= 2) {
applyAutoProfile(desired);
pendingProfileId = "";
pendingProfileSamples = 0;
}
}
function endFrame() {
@ -146,6 +298,12 @@
}
function visualLevel(key, fallback = "high") {
if (key === "details") return activeProfile().renderTier;
if (key === "effects") {
if (visualSettings.effects === "off") return "off";
return activeProfile().renderTier;
}
if (key === "shadows") return shadowQualityTier();
return visualSettings[key] || fallback;
}
@ -158,15 +316,23 @@
const nextValue = normalizeVisualSetting(key, value);
if (!allowed[key]?.has(nextValue)) return false;
if (visualSettings[key] === nextValue) return true;
const before = activeProfile();
visualSettings = { ...visualSettings, [key]: nextValue };
saveVisualSettings();
if (key === "details") resizeRequested = true;
const after = activeProfile();
if (key === "details" || before.dprScale !== after.dprScale || before.renderTier !== after.renderTier) resizeRequested = true;
if (after.id === "aggressive") global.TarinaiAssetsMemory?.trim?.("aggressive");
return true;
}
function renderQualityTier() { return visualSettings.details; }
function shadowQualityTier() { return visualSettings.shadows; }
function dprScaleValue() { return dprScaleForDetails(visualSettings.details); }
function renderQualityTier() { return activeProfile().renderTier; }
function shadowQualityTier() {
if (visualSettings.shadows === "off") return "off";
return activeProfile().renderTier;
}
function dprScaleValue() { return activeProfile().dprScale; }
function performanceProfile() { return activeProfile(); }
function simulationOptimizationTier() { return activeProfile().id; }
function consumeResizeRequest() {
const v = resizeRequested;
resizeRequested = false;
@ -193,9 +359,13 @@
}));
const topInclusive = entries.slice().sort((a, b) => b.avg - a.avg).slice(0, 12);
const topSelf = entries.slice().sort((a, b) => b.selfAvg - a.selfAvg).slice(0, 12);
const profile = activeProfile();
return {
enabled: debugEnabled,
tier: visualSettings.details,
tier: profile.renderTier,
profile: profile.id,
autoProfile: autoProfileId,
fps: Number(smoothedFps.toFixed(1)),
visual: visualSettingsValue(),
dprScale: dprScaleValue(),
frameCount,
@ -211,12 +381,15 @@
begin,
beginFrame,
endFrame,
observeFrameDelta,
renderQualityTier,
shadowQualityTier,
visualLevel,
visualSettings: visualSettingsValue,
setVisualSetting,
dprScale: dprScaleValue,
performanceProfile,
simulationOptimizationTier,
consumeResizeRequest,
setMetric,
snapshot,

View file

@ -1,19 +1,13 @@
"use strict";
(function (global) {
function rectLocalPoint(rect, x, y) {
return global.TarinaiCollisionFootprints.rectLocalPoint(rect, x, y);
}
function rectWorldVector(rect, x, y) {
return global.TarinaiCollisionFootprints.rectWorldVector(rect, x, y);
}
const Geometry = global.TarinaiGeometry;
function circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts = {}) {
if (Geometry.oneWayFenceAllows(obj, rect)) return false;
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
const minBounceSpeed = Math.max(0, Number(opts.minBounceSpeed ?? rect.minBounceSpeed ?? 0) || 0);
const local = rectLocalPoint(rect, obj.x, obj.y);
const local = Geometry.rectLocalPoint(rect, obj.x, obj.y);
let clx = clamp(local.x, -(rect.halfW || 0), rect.halfW || 0);
let cly = clamp(local.y, -(rect.halfH || 0), rect.halfH || 0);
let dx = local.x - clx;
@ -23,7 +17,7 @@
const px = obj.prevX;
const py = obj.prevY;
if (!Number.isFinite(px) || !Number.isFinite(py)) return false;
const prev = rectLocalPoint(rect, px, py);
const prev = Geometry.rectLocalPoint(rect, px, py);
const expanded = { left: -(rect.halfW || 0) - hitRadius, right: (rect.halfW || 0) + hitRadius, top: -(rect.halfH || 0) - hitRadius, bottom: (rect.halfH || 0) + hitRadius };
const insideNow = local.x >= expanded.left && local.x <= expanded.right && local.y >= expanded.top && local.y <= expanded.bottom;
const insidePrev = prev.x >= expanded.left && prev.x <= expanded.right && prev.y >= expanded.top && prev.y <= expanded.bottom;
@ -54,30 +48,15 @@
}
const nxLocal = dx / d;
const nyLocal = dy / d;
const n = rectWorldVector(rect, nxLocal, nyLocal);
const n = Geometry.rectWorldVector(rect, nxLocal, nyLocal);
const nx = n.x;
const ny = n.y;
const worldClosest = rectWorldVector(rect, clx, cly);
const worldClosest = Geometry.rectWorldVector(rect, clx, cly);
obj.x = (rect.cx || 0) + worldClosest.x + nx * (hitRadius + 0.5);
obj.y = (rect.cy || 0) + worldClosest.y + ny * (hitRadius + 0.5);
const vx = obj.vx || 0;
const vy = obj.vy || 0;
const toward = vx * nx + vy * ny;
if (toward < 0) {
obj.vx = vx - (1 + restitution) * toward * nx;
obj.vy = vy - (1 + restitution) * toward * ny;
} else {
obj.vx = vx + nx * 36;
obj.vy = vy + ny * 36;
}
if (minBounceSpeed > 0) {
const normalSpeed = (obj.vx || 0) * nx + (obj.vy || 0) * ny;
if (normalSpeed < minBounceSpeed) {
const add = minBounceSpeed - normalSpeed;
obj.vx = (obj.vx || 0) + nx * add;
obj.vy = (obj.vy || 0) + ny * add;
}
}
applyNormalBounce(obj, nx, ny, vx, vy, restitution, 36, minBounceSpeed, rect.mechanical ? 0 : 7.0, 46);
if (rect.mechanical) {
global.TarinaiMechanicalSystem.applySurfaceVelocityToCircle(obj, rect, nx, ny, vx, vy, {
damping: 1,
@ -87,7 +66,7 @@
passiveImpulseScale: 1.0,
spinKick: 7.0,
});
} else if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
}
return true;
}
@ -128,7 +107,7 @@
}
function bounceCircleOffRect(obj, rect, radius, restitution = 1.08, worldRef = null, opts = {}) {
function bounceCircleOffRect(obj, rect, radius, restitution = 4.20, worldRef = null, opts = {}) {
if (!obj || !rect) return false;
if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts);
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
@ -201,9 +180,9 @@
const rects = worldRef.nearbySolidObstacleRects(item.x, item.y, searchRadius, { include: it => it?.type === "bounce_fence" || it?.type === "bounce_fence_v", exclude: item, maxChecks: 16 }) || [];
for (const rect of rects) {
if (!rect?.bounce) continue;
if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 1.08), worldRef, opts)) {
if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 4.20), worldRef, opts)) {
bounced = true;
const maxSpeed = opts.bounceFenceMaxSpeed ?? 760;
const maxSpeed = opts.bounceFenceMaxSpeed ?? 1680;
item.vx = clamp(item.vx || 0, -maxSpeed, maxSpeed);
item.vy = clamp(item.vy || 0, -maxSpeed, maxSpeed);
const now = worldRef.time || 0;
@ -254,5 +233,14 @@
return speed;
}
global.TarinaiPhysics = { bounceCircleOffRect, applyKinematicItemMotion };
global.TarinaiPhysics = {
oneWayFenceAllowsMotion(...args) {
return global.TarinaiGeometry.oneWayFenceAllowsMotion(...args);
},
oneWayFenceAllows(...args) {
return global.TarinaiGeometry.oneWayFenceAllows(...args);
},
bounceCircleOffRect,
applyKinematicItemMotion,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -18,18 +18,33 @@
undo: "\u21B6 \u4E00\u3064\u623B\u3059",
clear: "\uD83D\uDDD1 \u5168\u6D88\u3057",
});
const DEFAULT_GRID_SIZE = 20;
function normalizedGridSize(value = DEFAULT_GRID_SIZE) {
return Math.max(10, Math.min(80, Math.round(Number(value || DEFAULT_GRID_SIZE) || DEFAULT_GRID_SIZE)));
}
function snapValue(value, gridSize = DEFAULT_GRID_SIZE) {
const grid = normalizedGridSize(gridSize);
return Math.round((Number(value) || 0) / grid) * grid;
}
function snapPoint(point, gridSize = DEFAULT_GRID_SIZE) {
return { x: snapValue(point?.x, gridSize), y: snapValue(point?.y, gridSize) };
}
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 === "bar") return [[-120, 0, 120, 0]];
if (name === "cross") return [[-120, 0, 120, 0], [0, -80, 0, 80]];
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;
// 24-sided lattice circle. More vertices preserve a round silhouette while
// every endpoint remains on the shared 20px editor grid.
const points = [
[120, 0], [120, 40], [100, 60], [80, 80], [60, 100], [40, 120],
[0, 120], [-40, 120], [-60, 100], [-80, 80], [-100, 60], [-120, 40],
[-120, 0], [-120, -40], [-100, -60], [-80, -80], [-60, -100], [-40, -120],
[0, -120], [40, -120], [60, -100], [80, -80], [100, -60], [120, -40],
];
return points.map((point, index) => {
const next = points[(index + 1) % points.length];
return [point[0], point[1], next[0], next[1]];
});
}
return null;
}
@ -65,23 +80,32 @@
function cleanSegments(segments = [], opts = {}) {
const limit = Math.max(1, Number(opts.limit || 96) || 96);
const bound = Math.max(1, Number(opts.bound || 420) || 420);
const minLength = Math.max(0, Number(opts.minLength || 4) || 4);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
const gridSize = normalizedGridSize(opts.gridSize);
const snap = opts.snap !== false;
const minLength = Math.max(gridSize, Number(opts.minLength || gridSize) || gridSize);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0];
const coord = value => {
const raw = snap ? snapValue(value, gridSize) : (Number(value) || 0);
return Math.max(-bound, Math.min(bound, raw));
};
const out = [];
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = Math.max(-bound, Math.min(bound, Number(seg[0]) || 0));
const y1 = Math.max(-bound, Math.min(bound, Number(seg[1]) || 0));
const x2 = Math.max(-bound, Math.min(bound, Number(seg[2]) || 0));
const y2 = Math.max(-bound, Math.min(bound, Number(seg[3]) || 0));
const x1 = coord(seg[0]);
const y1 = coord(seg[1]);
const x2 = coord(seg[2]);
const y2 = coord(seg[3]);
if (Math.hypot(x2 - x1, y2 - y1) >= minLength) out.push([x1, y1, x2, y2]);
if (out.length >= limit) break;
}
if (!out.length) out.push(fallback.slice(0, 4));
if (!out.length) {
const snappedFallback = fallback.slice(0, 4).map(value => coord(value));
out.push(snappedFallback);
}
return out;
}
function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) {
function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0, gridSize = DEFAULT_GRID_SIZE) {
if (!ctx || !canvas) return false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
@ -90,8 +114,9 @@
ctx.translate(cx, cy);
ctx.strokeStyle = "rgba(100,80,60,0.16)";
ctx.lineWidth = 1;
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
const grid = normalizedGridSize(gridSize);
for (let x = -cx; x <= cx; x += grid) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += grid) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
ctx.strokeStyle = "rgba(70,60,50,0.38)";
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
@ -173,19 +198,20 @@
const prefix = opts.prefix || "rotator";
const cx = opts.cx ?? canvas.width / 2;
const cy = opts.cy ?? canvas.height / 2;
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
const gridSize = normalizedGridSize(opts.gridSize);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0];
let mode = "line";
let segments = (opts.segments || []).map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4);
let segments = cleanSegments(opts.segments || [], { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true });
let draft = null;
let drawing = false;
const lineWidth = () => Math.max(1, Number(typeof opts.lineWidth === "function" ? opts.lineWidth() : opts.lineWidth || 12) || 12);
const strokeStyle = () => typeof opts.strokeStyle === "function" ? opts.strokeStyle() : opts.strokeStyle;
const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength }); };
const toLocal = (e) => canvasLocalPoint(canvas, e, cx, cy);
const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); };
const toLocal = (e) => snapPoint(canvasLocalPoint(canvas, e, cx, cy), gridSize);
const draw = () => {
drawEditorBase(ctx, canvas, cx, cy);
drawEditorBase(ctx, canvas, cx, cy, gridSize);
if (typeof opts.drawBeforeSegments === "function") opts.drawBeforeSegments(ctx, { cx, cy, lineWidth: lineWidth(), segments: segments.slice() });
const lw = lineWidth();
drawSegments(ctx, segments, { lineWidth: lw, strokeStyle: strokeStyle() || "rgba(80,150,205,0.92)" });
@ -240,7 +266,7 @@
const last = segments[segments.length - 1];
if (!last) return;
const lx = last[2], ly = last[3];
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
if (Math.hypot(p.x - lx, p.y - ly) >= gridSize) {
last[2] = p.x; last[3] = p.y;
segments.push([p.x, p.y, p.x, p.y]);
}
@ -257,7 +283,7 @@
if (!draft) return;
if (mode === "line") {
const seg = [draft[0], draft[1], p.x, p.y];
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= gridSize) segments.push(seg);
}
draft = null;
clean();
@ -281,22 +307,17 @@
draw,
getSegments: () => segments.map(seg => seg.slice(0, 4)),
setMode,
setSegments(next = []) { segments = next.map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); draft = null; draw(); },
setSegments(next = []) { segments = cleanSegments(next, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); draft = null; draw(); },
};
}
global.TarinaiPhysicsShapeEditorSystem = Object.freeze({
decorateToolbar,
templateSegments,
pointSegmentDistance,
canvasLocalPoint,
cleanSegments,
drawEditorBase,
drawSegments,
eraseSegmentsAt,
syncInputPair,
drawDraftSegment,
drawErasePreview,
createSegmentEditorController,
syncInputPair,
snapPoint,
cleanSegments,
templateSegments,
gridSize: DEFAULT_GRID_SIZE,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -5,7 +5,7 @@
// Physical state lives only in item.physicsBody / item.physicsConstraint.
(function (global) {
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
const LINK_TYPES = new Set(["rope", "rod"]);
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
function num(value, fallback = 0) {
const n = Number(value);
@ -29,7 +29,7 @@
function isPhysicsType(typeOrItem) { return isBodyType(typeOrItem) || isConstraintType(typeOrItem); }
function defaultSegments(type) {
if (type === "poison_block") return [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]];
if (type === "poison_block") return [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]];
if (type === "rotator") return [[-78, 0, 78, 0], [0, -52, 0, 52]];
return [[-78, 0, 78, 0]];
}
@ -63,7 +63,7 @@
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: type !== "poison_block", speed: type === "rotator" ? Math.PI * 0.65 : (type === "reciprocator" ? 92 : 0), direction: 1 },
rail: type === "reciprocator" ? { axisAngle: angle, travel: 150, phase: 0, anchorX: x, anchorY: y } : null,
shape: { model: "segments", thickness: type === "poison_block" ? 14 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 },
shape: { model: "segments", thickness: type === "poison_block" ? 11 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 },
collision: { solid: true, hazard: type === "poison_block" },
hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null,
sleep: { awakeUntil: 0 },
@ -96,7 +96,7 @@
out.velocity.angular = num(out.velocity.angular);
out.velocity.linear = num(out.velocity.linear);
out.motor.powered = type === "poison_block" ? false : out.motor.powered !== false;
out.motor.speed = Math.max(0, num(out.motor.speed, base.motor.speed));
out.motor.speed = type === "rotator" ? num(out.motor.speed, base.motor.speed) : Math.max(0, num(out.motor.speed, base.motor.speed));
out.motor.direction = Math.sign(num(out.motor.direction, 1)) || 1;
if (out.rail) {
out.rail.axisAngle = num(out.rail.axisAngle, out.pose.angle);
@ -137,7 +137,6 @@
item.world = worldRef || item.world || null;
if (!item.physicsBody || typeof item.physicsBody !== "object" || item.physicsBody.type !== item.type || opts.rebuild === true) {
item.physicsBody = defaultBody(item);
markBodyChanged(item, opts.rebuild === true ? "rebuild" : "init");
} else if (itemPoseShouldFeedBody(item, opts)) {
const sx = num(item.x);
const sy = num(item.y);
@ -148,13 +147,7 @@
item.physicsBody.pose.angle = sa;
item._physicsExternalPoseDirty = false;
}
const body = normalizeBody(item.physicsBody, item);
item.physicsBody = body;
return body;
}
function markBodyChanged(item, reason = "physics-body") {
if (!item) return;
return item.physicsBody;
}
function scalar(item, key, fallback = 0) {
@ -191,14 +184,14 @@
b.velocity = b.velocity || {};
b.motor = b.motor || {};
b.rail = b.rail || (item.type === "reciprocator" ? { axisAngle: num(item.angle), travel: 150, phase: 0, anchorX: num(item.x), anchorY: num(item.y) } : null);
b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 14 : 12, segments: defaultSegments(item.type), version: 0 };
b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 11 : 12, segments: defaultSegments(item.type), version: 0 };
b.collision = b.collision || { solid: true, hazard: item.type === "poison_block" };
b.sleep = b.sleep || { awakeUntil: 0 };
if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 };
switch (key) {
case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 14 : 12))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break;
case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 11 : 12))); break;
case "motorOn": b.motor.powered = item.type === "poison_block" ? false : !!value; break;
case "motorSpeed": b.motor.speed = Math.max(0, num(value)); break;
case "motorSpeed": b.motor.speed = item.type === "rotator" ? num(value) : Math.max(0, num(value)); break;
case "spin": b.velocity.angular = num(value); break;
case "xv": b.velocity.x = num(value); break;
case "yv": b.velocity.y = num(value); break;
@ -218,9 +211,6 @@
case "awakeUntil": b.sleep.awakeUntil = num(value); break;
default: return false;
}
normalizeBody(b, item);
markBodyChanged(item, reason);
if (key === "thickness" || key === "solid") global.TarinaiMechanicalSystem.invalidateGeometry(item);
return true;
}
@ -231,9 +221,6 @@
b.shape = b.shape || {};
b.shape.model = "segments";
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);
return true;
}
function syncPoseFromItem(item) {
@ -244,7 +231,9 @@
const sx = num(item.x);
const sy = num(item.y);
const sa = num(item.angle);
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
const b = item.physicsBody && item.physicsBody.type === item.type
? item.physicsBody
: ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return null;
b.pose = b.pose || { x: sx, y: sy, angle: sa };
b.pose.x = sx;
@ -256,7 +245,9 @@
return b;
}
function applyPoseToItem(item) {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
const b = item?.physicsBody && item.physicsBody.type === item.type
? item.physicsBody
: ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return false;
const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
item.x = num(b.pose?.x, item.x);
@ -292,7 +283,7 @@
const x = num(item?.x), y = num(item?.y);
return {
type: item?.type,
kind: item?.type === "rope" ? "flexible-distance" : "rigid-distance",
kind: item?.type === "rope" ? "flexible-distance" : (item?.type === "rod" ? "rigid-distance" : (item?.type === "spring" ? "spring-distance" : "signal-wire")),
endpoints: [null, null],
length: 80,
mid: { x, y: y + 10, vx: 0, vy: 0 },
@ -317,7 +308,7 @@
out.sleep = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep;
out.sleep.awakeUntil = num(out.sleep.awakeUntil);
out.particles = Array.isArray(out.particles) ? out.particles : null;
item.r = Math.max(18, item.type === "rod" ? out.length * 0.5 : (num(item.r, out.length * 0.5) || out.length * 0.5));
item.r = Math.max(18, (item.type === "rod" || item.type === "spring" || item.type === "wire" || item.type === "insulated_wire") ? out.length * 0.5 : (num(item.r, out.length * 0.5) || out.length * 0.5));
return out;
}
function ensureConstraint(item, worldRef = null, opts = {}) {
@ -326,7 +317,6 @@
if (!item.physicsConstraint || typeof item.physicsConstraint !== "object" || item.physicsConstraint.type !== item.type || opts.rebuild === true) {
item.physicsConstraint = defaultConstraint(item);
}
item.physicsConstraint = normalizeConstraint(item.physicsConstraint, item);
return item.physicsConstraint;
}
function endpoint(item, index = 0) { return ensureConstraint(item, item?.world || null, { syncFromLegacy: false })?.endpoints?.[index] || null; }
@ -377,36 +367,63 @@
return true;
}
function collectPhysicsItems(worldRef) {
function bodyLikelyActive(item) {
if (!item || item.dead) return false;
if (item.playerHeld || item._heldByPlayer || item._physicsExternalPoseDirty) return true;
const b = item.physicsBody || null;
const v = b?.velocity || {};
const motor = b?.motor || {};
if (item.type === "rotator") return motor.powered !== false || Math.abs(num(v.angular ?? item.spin)) > 0.004;
if (item.type === "reciprocator") return motor.powered !== false || Math.abs(num(v.linear ?? item.slideSpeed)) > 0.08;
if (item.type === "poison_block") {
const now = Number(item.world?.time || 0) || 0;
return now < num(b?.sleep?.awakeUntil ?? item.awakeUntil, -Infinity)
|| Math.hypot(num(v.x ?? item.vx), num(v.y ?? item.vy)) > 0.08
|| Math.abs(num(v.angular ?? item.spin)) > 0.004;
}
return false;
}
function collectPhysicsItems(worldRef, opts = {}) {
const bodies = [];
const constraints = [];
if (!worldRef?.itemsOfType) return { bodies, constraints };
worldRef.ensureItemBuckets?.("physics-world-system");
for (const type of BODY_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) bodies.push(item);
for (const type of LINK_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) constraints.push(item);
const maxBodies = Math.max(1, Number(opts.maxItems || Infinity) || Infinity);
const maxConstraints = Math.max(0, Number(opts.maxLinks || Infinity) || Infinity);
const bodyCandidates = [];
for (const type of BODY_TYPES) {
for (const item of worldRef.itemsOfType(type) || []) {
if (!item || item.dead) continue;
bodyCandidates.push(item);
}
}
bodyCandidates.sort((a, b) => Number(bodyLikelyActive(b)) - Number(bodyLikelyActive(a)));
for (const item of bodyCandidates) {
if (bodies.length >= maxBodies) break;
bodies.push(item);
}
for (const type of LINK_TYPES) {
for (const item of worldRef.itemsOfType(type) || []) {
if (!item || item.dead) continue;
if (constraints.length >= maxConstraints) break;
constraints.push(item);
}
if (constraints.length >= maxConstraints) break;
}
return { bodies, constraints };
}
function prepareWorld(worldRef, opts = {}) {
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 = { 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;
}
function commitWorld(worldRef, frame = worldRef?._physicsWorldFrame, opts = {}) {
if (!frame) return { bodies: 0, constraints: 0, moved: 0 };
let moved = 0;
for (const item of frame.bodies || []) {
const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
ensureBody(item, worldRef, { syncFromLegacy: false });
applyPoseToItem(item);
const after = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
if (before !== after) moved += 1;
const { bodies, constraints } = collectPhysicsItems(worldRef, opts);
for (const item of bodies) {
ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true });
normalizeBodyState(item);
}
for (const item of frame.constraints || []) ensureConstraint(item, worldRef, { syncFromLegacy: false });
if (opts.markDirty && moved) worldRef?.markSpatialDirty?.("physics-world-commit");
return { bodies: frame.bodies?.length || 0, constraints: frame.constraints?.length || 0, moved };
for (const item of constraints) {
ensureConstraint(item, worldRef, { syncFromLegacy: false });
normalizeConstraintState(item);
}
return { bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length };
}
function updateWorld(worldRef, dt = 0.016, opts = {}) {
if (!worldRef?.itemsOfType) return { ran: 0, bodies: 0, constraints: 0 };
@ -414,11 +431,21 @@
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 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 };
const mechanicalStats = global.TarinaiMechanicalSystem.updateWorld(worldRef, dt, {
maxPairs: opts.maxPairs || 320,
maxLinks: 0,
skipLinks: true,
maxItems: opts.maxItems || 220,
maxSubsteps: opts.maxSubsteps || 5,
focusThreshold: opts.focusThreshold || 44,
maxSubstepPairs: opts.maxSubstepPairs || 140,
maxSubstepFenceItems: opts.maxSubstepFenceItems || 32,
statePrepared: true,
}) || { ran: 0, active: 0 };
const constraintRan = global.TarinaiConstraintSystem.updateWorld(worldRef, dt, { maxLinks: opts.maxLinks || 220, statePrepared: true }) || 0;
const constraintChanged = Number(worldRef._constraintWorldStats?.changed || 0) || 0;
const postConstraintPairs = constraintChanged ? (global.TarinaiMechanicalSystem.resolveMechanicalPairs(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0;
const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, constraintChanged, postConstraintPairs };
return stats;
} finally { if (end) end(); }
}
@ -466,7 +493,7 @@
body.velocity = { x: num(velArr[0]) / 10, y: num(velArr[1]) / 10, angular: num(velArr[2]) / 1000, linear: num(velArr[3]) / 10 };
body.motor = { powered: bool(motorArr[0], true), speed: num(motorArr[1]) / 10, direction: Math.sign(num(motorArr[2], 1)) || 1 };
body.rail = railArr ? { axisAngle: num(railArr[0]) / 1000, travel: Math.max(24, num(railArr[1], 1500) / 10), phase: clamp(num(railArr[2]) / 1000, -1, 1), anchorX: num(railArr[3], item.x), anchorY: num(railArr[4], item.y) } : null;
body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 140 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 };
body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 110 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 };
body.collision = { solid: collArr[0] !== 0, hazard: collArr[1] !== 0 };
body.hazard = hazardArr ? { kind: hazardArr[0] || "poison", damage: Math.max(1, num(hazardArr[1], 70) / 10) } : null;
body.mass = Math.max(0.2, num(p.mass, Math.round(num(body.mass, 1) * 10)) / 10);
@ -486,7 +513,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({ 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" : (item.type === "rod" ? "rigid-distance" : (item.type === "spring" ? "spring-distance" : "signal-wire"))), 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 = []) {
@ -500,7 +527,6 @@
if (isBodyType(item)) {
ensureBody(item, item.world || null, { rebuild: false, syncFromLegacy: false });
global.TarinaiMechanicalSystem.invalidateGeometry(item);
markBodyChanged(item, reason);
return true;
}
if (isConstraintType(item)) {
@ -510,17 +536,6 @@
}
return false;
}
function purgeLegacyPhysicsStorage(item) {
if (!item || typeof item !== "object") return false;
// Physical state is schema-only now; remove any data fields left by older experiments.
const bodyKeys = ["rotator" + "Thickness", "rotator" + "Segments", "rotator" + "Powered", "rotator" + "Speed", "rotator" + "AngularVelocity", "poison" + "CollisionEnabled", "poison" + "Damage", "poison" + "Mass", "poison" + "Inertia", "reciprocator" + "Powered", "reciprocator" + "Speed", "reciprocator" + "Travel", "reciprocator" + "Phase", "reciprocator" + "Direction", "reciprocator" + "AxisAngle", "reciprocator" + "Velocity", "reciprocator" + "AnchorX", "reciprocator" + "AnchorY", "_physics" + "AwakeUntil"];
const linkKeys = ["link" + "A", "link" + "B", "link" + "Length", "link" + "MidX", "link" + "MidY", "link" + "MidVX", "link" + "MidVY", "_link" + "AwakeUntil", "rope" + "Particles"];
for (const key of bodyKeys.concat(linkKeys)) { try { delete item[key]; } catch (_) {} }
if (isBodyType(item)) ensureBody(item, item.world || null, { syncFromLegacy: false });
if (isConstraintType(item)) ensureConstraint(item, item.world || null, { syncFromLegacy: false });
return true;
}
// 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; };
@ -530,8 +545,8 @@
const bodyApi = Object.freeze({
isBodyType, isConstraintType, isPhysicsType,
ensureBody, ensureConstraint,
normalizeBodyState, applyBodyState, markBodyChanged, normalizeConstraintState, applyConstraintState,
compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage,
normalizeBodyState, applyBodyState, normalizeConstraintState, applyConstraintState,
compactItemExtra, applyCompactExtra,
invalidateItem,
scalar, setScalar, segments, setSegments, syncPoseFromItem,
endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles,

View file

@ -10,10 +10,10 @@
return Number.isFinite(n) ? n : fallback;
}
function itemRadius(type, fallback = 12) {
return typeof global.itemRadiusFor === "function" ? global.itemRadiusFor(type, fallback) : fallback;
return global.itemRadiusFor(type, fallback);
}
function toolTypeFor(worldRef) {
return typeof global.toolItemType === "function" ? global.toolItemType(worldRef?.tool || "") : "";
return global.toolItemType(worldRef?.tool || "");
}
function toolSizeScale(worldRef, type) {
return worldRef?.toolSizeScale ? worldRef.toolSizeScale(type) : 1;
@ -22,15 +22,16 @@
return worldRef?.toolSizeFor ? worldRef.toolSizeFor(type) : (worldRef?.toolSize || "medium");
}
function isRotatable(type) {
return typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type);
return global.isRotatableItemType(type);
}
function defaultAngle(type) {
return typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
return global.defaultItemAngle(type);
}
function rectOutside(worldRef, rect) {
function rectOutside(worldRef, rect, itemType = "") {
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;
const fence = global.isFenceItemType?.(itemType);
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < (fence ? 0 : pad) || num(rect.bottom) > (worldRef.h || 0) - (fence ? 0 : pad);
}
function circleOutside(worldRef, x, y, radius) {
if (!worldRef) return true;
@ -50,6 +51,34 @@
function footprintRadius(worldRef, item, rects = null) {
return global.TarinaiCollisionFootprints.itemFootprintRadius(worldRef, item, rects);
}
function applyToolOrientation(worldRef, item, requestedAngle = null) {
if (!item || !item.type) return item;
const type = String(item.type || "");
if (!isRotatable(type)) return item;
const angle = requestedAngle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type));
if (type === "reciprocator") {
// The tool angle controls the rail axis. The body itself keeps its
// canonical placement angle. Apply both after the body exists so the
// preview and the placed Item cannot diverge by 90 degrees.
const bodyAngle = defaultAngle(type);
item.angle = bodyAngle;
const pb = global.TarinaiPhysicsBodySystem;
const body = pb?.ensureBody?.(item, worldRef || null, { syncFromLegacy: false });
if (body?.pose) body.pose.angle = bodyAngle;
pb?.setScalar?.(item, "railAxis", angle, "placement-rail-axis");
pb?.invalidateItem?.(item, "placement-orientation");
return item;
}
item.angle = angle;
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(type)) {
const pb = global.TarinaiPhysicsBodySystem;
const body = pb?.ensureBody?.(item, worldRef || null, { syncFromLegacy: false });
if (body?.pose) body.pose.angle = angle;
pb?.invalidateItem?.(item, "placement-orientation");
}
return item;
}
function ensureMechanicalPreview(item, worldRef) {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return item;
const pb = global.TarinaiPhysicsBodySystem;
@ -60,7 +89,7 @@
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");
pb?.setScalar?.(item, "thickness", 11, "preview");
item.world = worldRef || null;
} else if (item.type === "reciprocator") {
pb?.setScalar?.(item, "railOn", true, "preview");
@ -86,19 +115,17 @@
item.amount = item.foodServingsRemaining;
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
}
if (isRotatable(type)) item.angle = opts.angle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type));
if (type === "reciprocator") {
const axis = item.angle ?? defaultAngle(type);
global.TarinaiPhysicsBodySystem.setScalar(item, "railAxis", axis, "preview-axis");
item.angle = defaultAngle(type);
}
if (isRotatable(type)) applyToolOrientation(worldRef, item, opts.angle);
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)));
if (rects.length) {
const fence = global.isFenceItemType?.(item.type);
return rects.some(rect => rectOutside(worldRef, fence ? rect : orientedAabb(rect), item.type));
}
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);
@ -110,14 +137,16 @@
if (rects.length) {
let left = 0, right = 0, top = 0, bottom = 0;
for (const rect of rects) {
const aabb = orientedAabb(rect);
const aabb = global.isFenceItemType?.(item.type) ? rect : 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 fence = global.isFenceItemType?.(item.type);
const yPad = fence ? 0 : pad;
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, yPad + top, (worldRef.h || 0) - yPad - bottom) : Math.max(yPad + top, Math.min((worldRef.h || 0) - yPad - 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)) };
@ -125,7 +154,7 @@
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) {
if ((item.type === "fence_v" || item.type === "fence_h" || item.type === "bounce_fence" || item.type === "bounce_fence_v" || item.type === "gate_fence" || item.type === "one_way_fence") && worldRef.fenceRect) {
return { rect: worldRef.fenceRect(item), rects: [] };
}
if ((item.type === "nest_box" || item.type === "pipe") && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] };
@ -177,6 +206,7 @@
return { type, x, y, r, rect: geometry.rect || null, rects: geometry.rects || [], influence: extra.influence || null, blocked, item: tmp };
}
global.TarinaiPlacementPreviewSystem = Object.freeze({
applyToolOrientation,
boundsBlocked,
clampPointFor,
forItem: previewForItem,

View file

@ -13,14 +13,6 @@ function toolCursorIconDefinition(world) {
}
function canvasEllipsizeText(ctx, text, maxW) {
let s = String(text || "");
if (!ctx || ctx.measureText(s).width <= maxW) return s;
while (s.length > 1 && ctx.measureText(`${s}\u2026`).width > maxW) s = s.slice(0, -1);
return `${s}\u2026`;
}
TARINAI_RENDER_GLOBAL.canvasEllipsizeText = TARINAI_RENDER_GLOBAL.canvasEllipsizeText || canvasEllipsizeText;
function staticVersionedAssetPath(path) {
const raw = String(path || "");
if (!raw) return "";
@ -237,7 +229,7 @@ function drawToolTargetHoverHighlight(ctx, world) {
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") {
} else if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(target.type)) {
const runtime = TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime;
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world);
@ -544,6 +536,37 @@ function drawPlacementPreview(ctx, world) {
ctx.stroke();
}
ctx.setLineDash([]);
if (type === "one_way_fence") {
const rr = rect || (rects && rects[0]) || null;
const angle = Number(rr?.angle || preview.item?.angle || 0) || 0;
const nx = Math.sin(angle);
const ny = -Math.cos(angle);
const arrowLength = Math.max(38, Math.min(68, Number(rr?.halfW || 48) * 0.70));
const head = Math.max(10, Math.min(17, arrowLength * 0.28));
const sx = x - nx * arrowLength * 0.48;
const sy = y - ny * arrowLength * 0.48;
const ex = x + nx * arrowLength * 0.48;
const ey = y + ny * arrowLength * 0.48;
const px = -ny;
const py = nx;
ctx.save();
ctx.globalAlpha = blocked ? 0.68 : 0.96;
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.96)" : "rgba(246,252,226,0.98)";
ctx.fillStyle = blocked ? "rgba(194,70,58,0.96)" : "rgba(246,252,226,0.98)";
ctx.lineWidth = Math.max(3.2, ctx.lineWidth * 1.55);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - nx * head + px * head * 0.62, ey - ny * head + py * head * 0.62);
ctx.lineTo(ex - nx * head - px * head * 0.62, ey - ny * head - py * head * 0.62);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = blocked ? 0.52 : 0.70;
ctx.beginPath();
ctx.moveTo(x - 8, y);
@ -555,6 +578,57 @@ function drawPlacementPreview(ctx, world) {
}
function drawPressureSwitchRangeOverlay(ctx, world) {
if (!ctx || !world) return;
const specs = [];
const editing = world._pressureSwitchOverlay;
const switches = typeof world.itemsOfType === "function"
? (world.itemsOfType("pressure_switch") || [])
: (world.items || []).filter(item => item && !item.dead && item.type === "pressure_switch");
for (const item of switches) {
if (!item || item.dead) continue;
const isEditing = editing?.item === item;
specs.push({
x: Number(item.x || 0) || 0,
y: Number(item.y || 0) || 0,
width: Math.max(60, Math.min(840, Number(isEditing ? editing.width : item.pressureWidth || 220) || 220)),
height: Math.max(60, Math.min(840, Number(isEditing ? editing.height : item.pressureHeight || 160) || 160)),
active: Boolean(item.signalActive),
preview: false,
});
}
if (world.tool === "pressure_switch" && world.pointer?.inside) {
specs.push({
x: Number(world.pointer.x || 0) || 0,
y: Number(world.pointer.y || 0) || 0,
width: 220,
height: 160,
active: false,
preview: true,
});
}
if (!specs.length) return;
const line = world.screenSizeToWorld ? world.screenSizeToWorld(2.0) : 2.0;
ctx.save();
for (const spec of specs) {
const color = spec.active ? "rgba(255,196,50,0.88)" : "rgba(72,166,232,0.74)";
const fill = spec.active ? "rgba(255,196,50,0.075)" : "rgba(72,166,232,0.055)";
ctx.fillStyle = fill;
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(1.2, line);
ctx.setLineDash(spec.preview ? [10, 7] : [8, 8]);
ctx.beginPath();
ctx.rect(spec.x - spec.width * 0.5, spec.y - spec.height * 0.5, spec.width, spec.height);
ctx.fill();
ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
}
function drawOperationToolPreview(ctx, world) {
const p = world?.pointer;
if (!p?.inside) return;
@ -611,16 +685,16 @@ function drawOperationToolPreview(ctx, world) {
return;
}
if ((world?.tool === "rope" || world?.tool === "rod") && world.pendingLinkEndpoint) {
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(world?.tool) && world.pendingLinkEndpoint) {
const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(world.pendingLinkEndpoint, world) || world.pendingLinkEndpoint;
ctx.save();
ctx.globalAlpha = 0.72;
ctx.lineWidth = 3.2;
ctx.lineCap = "round";
if (world?.tool === "rope") ctx.setLineDash([8, 6]);
if (world?.tool === "rope" || world?.tool === "wire" || world?.tool === "insulated_wire") ctx.setLineDash([8, 6]);
else ctx.setLineDash([]);
ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : "rgba(142,94,45,0.88)";
ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : (world?.tool === "spring" ? "rgba(105,112,124,0.92)" : ((world?.tool === "wire" || world?.tool === "insulated_wire") ? "rgba(232,196,64,0.92)" : "rgba(142,94,45,0.88)"));
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(p.x, p.y);
@ -756,15 +830,66 @@ function isCachedTerrainItem(it) {
return false;
}
const LINK_RENDER_TYPES = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire"]);
function isLinkRenderItem(it) {
return Boolean(it && !it.dead && LINK_RENDER_TYPES.includes(it.type));
}
function linkIntersectsVisibleRect(it, worldRef, rect, margin = 18) {
if (!isLinkRenderItem(it) || !worldRef || !rect) return false;
const runtime = window.TarinaiLinkRuntime;
const pb = window.TarinaiPhysicsBodySystem;
const points = [];
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), worldRef);
if (a) points.push(a);
if (b) points.push(b);
if (it.type === "rope") {
const particles = pb?.particles?.(it);
if (Array.isArray(particles)) {
for (const point of particles) if (point && Number.isFinite(point.x) && Number.isFinite(point.y)) points.push(point);
}
}
if (points.length < 2) return false;
let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
for (const point of points) {
left = Math.min(left, Number(point.x) || 0);
right = Math.max(right, Number(point.x) || 0);
top = Math.min(top, Number(point.y) || 0);
bottom = Math.max(bottom, Number(point.y) || 0);
}
const pad = Math.max(4, Number(margin) || 18);
return right + pad >= rect.left && left - pad <= rect.right && bottom + pad >= rect.top && top - pad <= rect.bottom;
}
function isBehindSpriteLayerItem(it) {
return Boolean(it && !it.dead && (it.type === "rope" || it.type === "rod" || it.type === "bed" || it.type === "grass_bed"));
return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed"));
}
function renderLayerSortY(entity) {
if (!entity) return 0;
if (entity.type === "bed" || entity.type === "grass_bed") return (entity.y || 0) - Math.max(10, (entity.r || 20) * 0.45);
if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return (entity.y || 0) + Math.max(6, (entity.radius || 20) * 0.28);
return entity.y || 0;
const y = Number.isFinite(Number(entity.y)) ? Number(entity.y) : 0;
// Layering should be decided by the bottom edge/ground contact, not by the center.
// This prevents tall or large items from flipping in front/behind too early.
if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) {
return y + Math.max(8, (Number(entity.radius) || 20) * 0.86);
}
if (entity.kind === "queen" || entity.kind === "worker") return y + Math.max(4, Number(entity.size || 10) || 10);
const type = String(entity.type || "");
const r = Math.max(0, Number(entity.r || entity.radius || entity.size || 0) || 0);
if (type === "bed" || type === "grass_bed") return y + Math.max(8, r * 0.72);
if (type === "nest_box") return y + Math.max(10, r * 0.76);
if (type === "pipe") return y + Math.max(8, r * 0.64);
if (type === "fan") return y + Math.max(10, r * 0.95);
if (type === "rotator" || type === "reciprocator" || type === "poison_block") {
const reach = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || r;
return y + Math.max(r, reach * 0.32);
}
if (type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") {
return y + Math.max(8, r * 0.52);
}
return y + Math.max(0, r);
}
function renderLayerKindRank(entity) {
@ -906,7 +1031,7 @@ function isEntityVisibleInRect(entity, rect, extra = 0) {
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);
const owner = worldRef.liveTarinaiById?.(it.carriedById) || (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById);
if (!owner) return false;
const ox = it.x, oy = it.y;
it.ownerId = it.ownerId || owner.id;
@ -931,7 +1056,9 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
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;
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it)) return;
const visible = isEntityVisibleInRect(it, visibleRect) || linkIntersectsVisibleRect(it, worldRef, visibleRect);
if (!visible) return;
seen.add(it);
if (isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
@ -963,6 +1090,13 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
for (const it of itemScratch) addItem(it);
// Link-like items can cross the viewport while their midpoint lives in an
// off-screen spatial cell. Pull them from their type buckets as a small,
// deterministic fallback so a newly connected or very long link never
// disappears merely because its cached origin is outside the query rect.
for (const type of LINK_RENDER_TYPES) {
for (const it of worldRef.itemsOfType?.(type) || []) addItem(it);
}
const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
tarinaiScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
@ -971,7 +1105,14 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
for (const a of antScratch) addLayered(a);
for (const it of worldRef.items || []) {
if (!Array.isArray(worldRef.carriedPlushies) || (worldRef.time || 0) >= (worldRef._nextCarriedPlushieRefreshAt || 0)) {
const plushies = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("plushie") : (worldRef.items || []);
worldRef.carriedPlushies = [];
for (const it of plushies || []) if (it && !it.dead && it.isStructure && it.type === "plushie" && it.carriedById) worldRef.carriedPlushies.push(it);
worldRef._nextCarriedPlushieRefreshAt = (worldRef.time || 0) + 0.45;
}
const plushieCandidates = worldRef.carriedPlushies;
for (const it of plushieCandidates || []) {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
}
} else {
@ -1240,26 +1381,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.fill();
}
// edge grasses and tiny flowers
const edgeGrassCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
for (let i = 0; i < edgeGrassCount; i++) {
const side = i % 2;
const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
const py = side === 0 ? y + 20 + randSeed(5700 + i, -4, 8) : y + bh - 24 + randSeed(6000 + i, -8, 4);
activeCtx.save();
activeCtx.translate(px, py);
activeCtx.strokeStyle = light > 0.34 ? `rgba(101, 157, 68, ${0.38 + lighting.goldenStrength * 0.16})` : "rgba(97, 126, 128, 0.25)";
activeCtx.lineWidth = 1.4;
for (let j = 0; j < 3; j++) {
const len = randSeed(6300 + i * 5 + j, 7, 14);
const dir = randSeed(6600 + i * 5 + j, -0.65, 0.65);
activeCtx.beginPath();
activeCtx.moveTo(0, 0);
activeCtx.quadraticCurveTo(Math.sin(dir) * len * 0.35, -len * 0.45, Math.sin(dir) * len, -len);
activeCtx.stroke();
}
activeCtx.restore();
}
// Edge grass decorations are intentionally disabled; real grass is rendered through item/terrain systems.
const flowerCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" || groundType === "ice" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
for (let i = 0; i < flowerCount; i++) {
@ -1372,10 +1494,11 @@ function drawAtmosphericLighting(ctx, w, h, lighting) {
ctx.restore();
}
function drawRain(ctx, w, h, t) {
function drawRain(ctx, w, h, t, opts = {}) {
if (world.weather !== "light_rain") return;
ctx.save();
const count = 96;
const rainAlphaScale = Math.max(0.25, Math.min(1.25, Number(opts.alphaScale ?? 1) || 1));
const count = Math.round(118 * rainAlphaScale);
const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites.
const slant = Math.tan(angle);
for (let i = 0; i < count; i++) {
@ -1391,7 +1514,7 @@ function drawRain(ctx, w, h, t) {
const alpha = y < 60 ? clamp((y + 90) / 150, 0, 1) : 1;
const dx = Math.sin(angle) * len;
const dy = Math.cos(angle) * len;
ctx.globalAlpha = (0.18 + 0.34 * randSeed(19000 + i, 0.35, 1.0)) * alpha;
ctx.globalAlpha = (0.22 + 0.40 * randSeed(19000 + i, 0.35, 1.0)) * alpha * rainAlphaScale;
ctx.strokeStyle = "rgba(88, 150, 218, 0.86)";
ctx.lineWidth = randSeed(18700 + i, 0.8, 1.45);
ctx.lineCap = "round";
@ -1400,7 +1523,7 @@ function drawRain(ctx, w, h, t) {
ctx.lineTo(x + dx * 0.5, y + dy * 0.5);
ctx.stroke();
if (i % 4 === 0) {
ctx.globalAlpha = 0.13 * alpha;
ctx.globalAlpha = 0.16 * alpha * rainAlphaScale;
ctx.strokeStyle = "rgba(255,255,255,0.70)";
ctx.lineWidth = 0.7;
ctx.beginPath();
@ -1412,9 +1535,6 @@ function drawRain(ctx, w, h, t) {
ctx.restore();
}
function weatherLabel(weather) {
return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c";
}
function renderPerfBegin(label) {
return window.TarinaiPerf.begin(label);
@ -1430,9 +1550,7 @@ function renderVisualLevel(kind, fallback = "high") {
function visualEffectLimit(level) {
if (level === "off") return 0;
if (level === "low") return 36;
if (level === "mid") return 96;
return Infinity;
return Math.max(0, Number(TARINAI_RENDER_GLOBAL.TarinaiPerf?.performanceProfile?.().effectDrawBudget || 96));
}
function fillScreenFallback(ctx, w, h, lighting) {
@ -1504,8 +1622,6 @@ function render() {
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);
const endBackItems = renderPerfBegin("render.draw.backItems");
for (const it of renderStack.backItems) {
it.draw(ctx, world.time, lighting);
@ -1539,18 +1655,28 @@ function render() {
}
};
const carriedPlushiesByOwner = new Map();
for (const it of renderStack.carriedPlushies) {
if (!it?.carriedById) continue;
const list = carriedPlushiesByOwner.get(it.carriedById) || [];
list.push(it);
carriedPlushiesByOwner.set(it.carriedById, list);
}
const endLayered = renderPerfBegin("render.draw.layered");
for (const entry of renderStack.layered) {
entry.entity.draw(ctx, world.time, lighting);
drawAttachedPinsForEntity(entry.entity);
// A carried plushie shares its owner's depth position instead of being
// rendered above every entity as a global attachment layer.
for (const it of carriedPlushiesByOwner.get(entry.entity.id) || []) {
syncCarriedPlushieToOwner(it, world);
it.draw(ctx, world.time, lighting);
}
}
renderPerfEnd(endLayered);
const endAttachments = renderPerfBegin("render.draw.attachments");
for (const it of renderStack.carriedPlushies) {
syncCarriedPlushieToOwner(it, world);
it.draw(ctx, world.time, lighting);
}
for (const t of renderStack.champions || []) {
t.drawChampionCrown(ctx, world.time, lighting);
}
@ -1572,20 +1698,42 @@ function render() {
const effectLevel = renderVisualLevel("effects");
let drawnEffects = 0;
const maxEffects = visualEffectLimit(effectLevel);
for (const ef of world.effects) {
if (drawnEffects >= maxEffects) break;
if (isEntityVisibleInRect(ef, visibleRect)) {
ef.draw(ctx);
drawnEffects += 1;
const effects = world.effects || [];
const totalEffects = effects.length;
let effectScanCap = 0;
if (totalEffects > 0 && maxEffects > 0) {
const scanCap = totalEffects > 180 ? Math.min(totalEffects, Math.max(maxEffects * 8, 160)) : totalEffects;
effectScanCap = scanCap;
let cursor = totalEffects > 180 ? (Math.max(0, Math.floor(world._renderEffectCursor || 0)) % totalEffects) : 0;
for (let scanned = 0; scanned < scanCap && drawnEffects < maxEffects; scanned += 1) {
const ef = effects[cursor];
cursor = (cursor + 1) % totalEffects;
if (isEntityVisibleInRect(ef, visibleRect)) {
ef.draw(ctx);
drawnEffects += 1;
}
}
if (totalEffects > 180) world._renderEffectCursor = cursor;
}
world._effectRenderStats = {
total: totalEffects,
drawn: drawnEffects,
skipped: Math.max(0, totalEffects - drawnEffects),
budget: maxEffects,
scanCap: effectScanCap,
level: effectLevel,
};
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects);
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
renderPerfEnd(endEffects);
drawPressureSwitchRangeOverlay(ctx, world);
ctx.restore();
// Light affecting the whole visible scene, including characters and ground.
const endLighting = renderPerfBegin("render.lightingWeather");
if (renderVisualLevel("details") !== "low") drawAtmosphericLighting(ctx, screenW, screenH, lighting);
if (effectLevel !== "off" && world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time, { alphaScale: effectLevel === "off" ? 0.42 : 1 });
renderPerfEnd(endLighting);
const endUiOverlay = renderPerfBegin("render.uiOverlay");
@ -1597,11 +1745,20 @@ function render() {
const endHud = renderPerfBegin("render.hud");
// Time HUD
ctx.save();
const hudW = 126;
const hudH = 50;
const w = screenW, h = screenH;
const hudX = w - hudW - 22;
const hudY = 16;
const seasonDay = world.seasonDayString?.() || `${world.day}\u65E5`;
const playDay = `${Math.max(1, Number(world.day || 1) || 1)}\u65E5\u76EE`;
const hudTemp = world.currentTemperature ?? world.updateTemperature?.(0) ?? 15;
const hudLine1 = `${seasonDay} ${playDay} ${world.clockString()}`;
const hudLine2 = `${world.phaseName()} ${weatherLabel(world.weather)} ${Math.round(hudTemp)}\u2103`;
ctx.font = "bold 13px ui-rounded, sans-serif";
const line1W = ctx.measureText(hudLine1).width;
ctx.font = "11px ui-rounded, sans-serif";
const line2W = ctx.measureText(hudLine2).width;
const hudW = Math.ceil(Math.min(Math.max(104, w - 44), Math.max(line1W, line2W) + 20));
const hudX = w - hudW - 22;
ctx.fillStyle = light > 0.42 ? "rgba(255,251,244,0.76)" : "rgba(247,246,255,0.62)";
roundedRect(ctx, hudX, hudY, hudW, hudH, 14);
ctx.fill();
@ -1610,12 +1767,10 @@ function render() {
ctx.fillStyle = "#2a241d";
ctx.font = "bold 13px ui-rounded, sans-serif";
ctx.textAlign = "left";
const seasonDay = world.seasonDayString?.() || `${world.day}\u65E5\u76EE`;
ctx.fillText(`${seasonDay} ${world.clockString()}`, hudX + 10, hudY + 20);
ctx.fillText(hudLine1, hudX + 10, hudY + 20);
ctx.font = "11px ui-rounded, sans-serif";
ctx.fillStyle = "rgba(111,101,88,0.86)";
const hudTemp = world.currentTemperature ?? world.updateTemperature?.(0) ?? 15;
ctx.fillText(`${world.phaseName()} ${weatherLabel(world.weather)} ${Math.round(hudTemp)}\u2103`, hudX + 10, hudY + 38);
ctx.fillText(hudLine2, hudX + 10, hudY + 38);
ctx.restore();
ctx.save();
@ -1635,10 +1790,16 @@ function drawPointerItemTooltip(ctx, world) {
if (!target || !rawLines.length) return;
ctx.save();
ctx.font = "12px Yomogi, sans-serif";
const maxTextW = 238;
const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y };
const canvasW = world.viewportW || world.w || ctx.canvas.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas.height || 1;
const isTarinaiTooltip = info?.kind === "tarinai";
const isPipeTooltip = info?.kind === "pipe";
const maxTextW = (isTarinaiTooltip || isPipeTooltip) ? Math.max(120, canvasW - 38) : 238;
const ellipsize = (text, maxW) => canvasEllipsizeText(ctx, text, maxW);
const wrapLine = (text, maxW) => {
const s = String(text || "");
if (isTarinaiTooltip) return [ellipsize(s, maxW)];
if (ctx.measureText(s).width <= maxW) return [s];
if (s.includes("\u3001")) {
const parts = s.split("\u3001");
@ -1659,14 +1820,13 @@ 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("\u3001"), maxTextW)];
if (!isTarinaiTooltip && lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("\u3001"), maxTextW)];
if (isTarinaiTooltip) lines = lines.slice(0, 4);
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));
const maxBoxW = (isTarinaiTooltip || isPipeTooltip) ? Math.max(120, canvasW - 16) : 280;
const w = Math.min(maxBoxW, Math.max(92, textW + 22));
const h = Math.max(38, 16 + lines.length * lineH);
const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y };
const canvasW = world.viewportW || world.w || ctx.canvas.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas.height || 1;
const x = clamp(pos.x - w / 2, 8, canvasW - w - 8);
const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, canvasH - h - 8);
ctx.fillStyle = "rgba(46,38,30,0.66)";
@ -1698,15 +1858,5 @@ function drawPointerItemTooltip(ctx, world) {
}
function roundedRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
window.TarinaiRender = { invalidateRenderCaches };

View file

@ -95,13 +95,13 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
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);
const t = worldRef.liveTarinaiById?.(id) || (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;
const byId = typeof worldRef.itemById === "function" ? worldRef.itemById(id) : null;
const found = byId || (worldRef.items || []).find(it => it && it.id === id && !it.dead);
const matchesKey = found && (found.type === key || (key === "ant_corpse" && (found.type === "trace" || found.type === "splat")));
return matchesKey && robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null;
}
function robotTargetCandidates(item, worldRef) {
@ -200,7 +200,8 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
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";
const cleanReason = "掃除";
const cleanLogReason = "ロボ掃除機に吸い込まれた";
if (type === "tarinai" || isRobotTarinaiTarget(target)) {
triggerRobotPanic(item, target, worldRef, true);
if (typeof target.die === "function") {
@ -215,7 +216,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
} 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,
userReason: type === "grass_bed" ? "ロボ掃除機に片付けられた" : cleanLogReason,
wake: true,
breaker: item,
}));
@ -308,6 +309,17 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
if (!item || item.dead || !worldRef) return false;
normalizeRobotCleanerState(item);
dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0));
// A connected signal wire becomes the cleaner's power source. Unwired
// cleaners keep their original always-on behavior.
if (item._signalDriven && !item._signalOn) {
item.vx = 0;
item.vy = 0;
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
item.robotCleanerScanTimer = 0;
worldRef.drawListDirty = true;
return true;
}
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);

File diff suppressed because it is too large Load diff

View file

@ -1,81 +1,47 @@
"use strict";
// Layer: persistence/schema
// Owns stable save identifiers shared by snapshot_system.js and save_codec.js.
// Current-format save identifiers only.
// Current-format identifiers. Schema 35 removes warp manual power and expands gate channels to nine.
(function (global) {
const SNAPSHOT_VERSION = 19;
const BINARY_SCHEMA_VERSION = 24;
const ids = global.TarinaiDomainIds;
const itemCatalog = global.TarinaiItemTypeCatalog;
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js");
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
const WEATHER_IDS = Object.freeze(["sunny", "cloudy", "light_rain"]);
const MOOD_IDS = Object.freeze(["relaxed", "devastation", "crisis", "confusion", "fearful", "disease_spread", "weakened", "breeding", "happy", "overcrowded", "isolated"]);
const STATE_IDS = Object.freeze(["idle", "sleep"]);
const POWER_MODE_IDS = Object.freeze(["", "protein", "niteropu"]);
const SIZE_MODE_IDS = Object.freeze(["", "giant_drug", "dwarf_drug"]);
const LIFE_MODE_IDS = Object.freeze(["", "mercury"]);
const PIN_STATE_IDS = Object.freeze(["loose", "lodged", "falling", "stuck"]);
const STAGE_IDS = Object.freeze(["fresh", "aging", "old", "dry"]);
const ITEM_TYPE_IDS = Object.freeze([
"grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi",
"sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h",
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker", "balloon", "fan", "pipe"
]);
function typeIds(names) {
return Object.freeze(new Set((names || []).map(name => ITEM_TYPE_IDS.indexOf(name)).filter(id => id >= 0)));
}
const STRUCTURE_TYPE_IDS = typeIds(["grass_bed", "plushie"]);
const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]);
const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]);
const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator", "rope", "rod"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "fan"]);
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] ?? "";
}
const SNAPSHOT_VERSION = 30;
const BINARY_SCHEMA_VERSION = 36;
function moodValue(index, fallback = "relaxed") {
const i = Number(index) | 0;
return canonicalMoodId(MOOD_IDS[i] ?? fallback);
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));
}
global.TarinaiSaveSchema = Object.freeze({
SNAPSHOT_VERSION,
BINARY_SCHEMA_VERSION,
FIELD_IDS,
GROUND_IDS,
WEATHER_IDS,
MOOD_IDS,
canonicalMoodId,
FIELD_IDS: ids.FIELD_IDS,
GROUND_IDS: ids.GROUND_IDS,
WEATHER_IDS: ids.WEATHER_IDS,
MOOD_IDS: ids.MOOD_IDS,
STATE_IDS: ids.STATE_IDS,
POWER_MODE_IDS: ids.POWER_MODE_IDS,
SIZE_MODE_IDS: ids.SIZE_MODE_IDS,
LIFE_MODE_IDS: ids.LIFE_MODE_IDS,
PIN_STATE_IDS: ids.PIN_STATE_IDS,
STAGE_IDS: ids.STAGE_IDS,
ITEM_TYPE_IDS: itemCatalog.ITEM_TYPE_IDS,
FIELD_INDEX: ids.FIELD_INDEX,
GROUND_INDEX: ids.GROUND_INDEX,
WEATHER_INDEX: ids.WEATHER_INDEX,
MOOD_INDEX: ids.MOOD_INDEX,
ITEM_TYPE_INDEX: itemCatalog.ITEM_TYPE_INDEX,
canonicalFieldId: ids.canonicalFieldId,
canonicalGroundId: ids.canonicalGroundId,
canonicalWeatherId: ids.canonicalWeatherId,
canonicalMoodId: ids.canonicalMoodId,
moodValue,
STATE_IDS,
POWER_MODE_IDS,
SIZE_MODE_IDS,
LIFE_MODE_IDS,
PIN_STATE_IDS,
STAGE_IDS,
ITEM_TYPE_IDS,
STRUCTURE_TYPE_IDS,
SERVING_FOOD_TYPE_IDS,
PIN_TYPE_IDS,
JSON_EXTRA_ITEM_TYPE_IDS,
ROTATABLE_ITEM_TYPE_IDS,
enumIndex,
enumValue,
enumIndex: ids.enumIndex,
enumValue: ids.enumValue,
itemTypeIndex: itemCatalog.itemTypeIndex,
itemTypeValue: itemCatalog.itemTypeValue,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -52,6 +52,63 @@
return `${date} / ${fieldType} / ${day}\u65e5\u76ee / ${population}\u5339${hashLen}`;
}
function ensureLoadConfirmDialog() {
let confirmDialog = document.getElementById("saveLoadConfirmDialog");
if (confirmDialog) return confirmDialog;
confirmDialog = document.createElement("div");
confirmDialog.id = "saveLoadConfirmDialog";
confirmDialog.className = "field-dialog save-confirm-dialog hidden";
confirmDialog.setAttribute("role", "alertdialog");
confirmDialog.setAttribute("aria-modal", "true");
confirmDialog.setAttribute("aria-labelledby", "saveLoadConfirmTitle");
confirmDialog.setAttribute("aria-describedby", "saveLoadConfirmMessage");
confirmDialog.innerHTML = `
<div class="field-dialog-panel save-confirm-panel">
<h2 id="saveLoadConfirmTitle">ロード前の確認</h2>
<p id="saveLoadConfirmMessage" class="field-dialog-lead save-confirm-message"></p>
<p class="hint save-warning">現在の状態は上書きされます。必要であれば先にセーブしてください。</p>
<div class="field-dialog-actions">
<button class="btn" data-save-load-cancel type="button">キャンセル</button>
<button class="btn primary" data-save-load-confirm type="button">ロードする</button>
</div>
</div>`;
document.body.appendChild(confirmDialog);
return confirmDialog;
}
function requestLoadConfirmation(message) {
const confirmDialog = ensureLoadConfirmDialog();
const messageEl = confirmDialog.querySelector("#saveLoadConfirmMessage");
if (messageEl) messageEl.textContent = String(message || "保存した状態をロードします。");
confirmDialog.classList.remove("hidden");
const confirmBtn = confirmDialog.querySelector("[data-save-load-confirm]");
const cancelBtn = confirmDialog.querySelector("[data-save-load-cancel]");
queueMicrotask(() => confirmBtn?.focus?.());
return new Promise((resolve) => {
let settled = false;
const finish = (accepted) => {
if (settled) return;
settled = true;
confirmDialog.classList.add("hidden");
confirmDialog.removeEventListener("click", onClick);
document.removeEventListener("keydown", onKeyDown, true);
resolve(Boolean(accepted));
};
const onClick = (event) => {
if (event.target === confirmDialog || event.target.closest("[data-save-load-cancel]")) finish(false);
else if (event.target.closest("[data-save-load-confirm]")) finish(true);
};
const onKeyDown = (event) => {
if (event.key === "Escape") { event.preventDefault(); finish(false); }
else if (event.key === "Enter" && document.activeElement === confirmBtn) { event.preventDefault(); finish(true); }
};
confirmDialog.addEventListener("click", onClick);
document.addEventListener("keydown", onKeyDown, true);
cancelBtn?.setAttribute?.("aria-label", "ロードをキャンセル");
});
}
function ensureDialog() {
let dialog = document.getElementById("saveDialog");
if (dialog) return dialog;
@ -142,7 +199,7 @@
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
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;
if (!await requestLoadConfirmation(`スロット${slot}をロードします。`)) return;
try {
await loadSlot(slot);
closeDialog();
@ -165,19 +222,40 @@
}
if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) {
const exportBtn = dialog.querySelector("#saveExportBtn");
const area = dialog.querySelector("#saveHashText");
if (area) {
area.value = await encodeSnapshot(createSnapshot(global.world));
area.focus();
area.select();
if (exportBtn?.dataset.busy === "1") return;
if (exportBtn) {
exportBtn.dataset.busy = "1";
exportBtn.disabled = true;
exportBtn.textContent = "\u751f\u6210\u4e2d...";
}
global.showToast?.("\u546a\u6587\u3092\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002");
try {
const hash = await encodeSnapshot(createSnapshot(global.world));
if (!hash || typeof hash !== "string") throw new Error("save text was not generated");
if (area) {
area.value = hash;
area.focus();
area.select();
}
global.showToast?.("\u546a\u6587\u3092\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002");
} catch (error) {
console.warn("save text generation failed", error);
global.showToast?.("\u546a\u6587\u306e\u751f\u6210\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
} finally {
if (exportBtn) {
exportBtn.dataset.busy = "0";
exportBtn.disabled = false;
exportBtn.textContent = "\u546a\u6587\u3092\u30bb\u30fc\u30d6";
}
}
return;
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
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;
if (!await requestLoadConfirmation("貼り付けた呪文をロードします。")) return;
try {
restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog();

251
js/signal_system.js Normal file
View file

@ -0,0 +1,251 @@
"use strict";
// Signal network, rectangular detectors, and conductive wires.
(function (global) {
const WIRE_TYPES = new Set(["wire", "insulated_wire"]);
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
function num(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function clamp(value, min, max) { return Math.max(min, Math.min(max, num(value, min))); }
function isWire(item) { return Boolean(item && !item.dead && WIRE_TYPES.has(item.type)); }
function emitEffect(worldRef, type, x, y, options = {}) {
if (!worldRef?.effects) return false;
const EffectCtor = global.TarinaiEffect || (typeof Effect === "function" ? Effect : null);
if (typeof EffectCtor !== "function") return false;
worldRef.effects.push(new EffectCtor(type, x, y, options));
return true;
}
function endpointObject(endpoint, worldRef) {
if (!endpoint || !worldRef) return null;
const cached = endpoint._ref;
if (cached && !cached.dead && (!endpoint.id || cached.id === endpoint.id)) return cached;
let found = null;
if (endpoint.kind === "tarinai") {
found = worldRef.liveTarinaiById?.(endpoint.id, endpoint.liveToken ?? null)
|| worldRef.tarinaiById?.get?.(endpoint.id)
|| (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id)
|| null;
} else if (endpoint.kind === "ant") {
found = (worldRef.ants || []).find(ant => ant && !ant.dead && ant.id === endpoint.id) || null;
} else {
found = worldRef.itemById?.(endpoint.id)
|| (worldRef.items || []).find(item => item && !item.dead && item.id === endpoint.id)
|| null;
}
endpoint._ref = found;
return found;
}
function endpointPoint(endpoint, worldRef) {
return global.TarinaiLinkRuntime?.endpointWorld?.(endpoint, worldRef)
|| (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null);
}
function countPressureTargets(item, worldRef) {
const width = clamp(item.pressureWidth || 220, 60, 840);
const height = clamp(item.pressureHeight || 160, 60, 840);
const halfW = width * 0.5;
const halfH = height * 0.5;
const radius = Math.hypot(halfW, halfH) + 48;
const inside = target => {
const tr = Math.max(0, num(target?.r ?? target?.radius));
return Math.abs(num(target?.x) - num(item.x)) <= halfW + tr && Math.abs(num(target?.y) - num(item.y)) <= halfH + tr;
};
if (item.pressureTarget === "item") {
const nearby = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items || [];
let count = 0;
for (const target of nearby) {
if (!target || target.dead || target === item || LINK_TYPES.has(target.type)) continue;
if (inside(target)) count += 1;
}
return count;
}
const nearby = worldRef.nearbyTarinai?.(item.x, item.y, radius, true) || worldRef.tarinai || [];
let count = 0;
for (const target of nearby) {
if (!target || target.dead || worldRef.isTarinaiHiddenInNestBox?.(target)) continue;
if (inside(target)) count += 1;
}
return count;
}
function updatePressureSwitches(worldRef) {
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
if (!item || item.dead) continue;
item.pressureTarget = item.pressureTarget === "item" ? "item" : "tarinai";
item.pressureThreshold = Math.max(1, Math.min(999, Number(item.pressureThreshold || 1) | 0));
item.pressureWidth = clamp(item.pressureWidth || 220, 60, 840);
item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840);
const count = countPressureTargets(item, worldRef);
const active = count >= item.pressureThreshold;
if (active !== Boolean(item.signalActive)) worldRef.drawListDirty = true;
item.pressureCount = count;
item.signalActive = active;
}
}
function buildSignalNetwork(worldRef) {
const adjacency = new Map();
const connected = new Set();
const wires = [];
let wireGeometryMoved = false;
const addEdge = (a, b) => {
if (!a || !b || a === b) return;
if (!adjacency.has(a)) adjacency.set(a, new Set());
if (!adjacency.has(b)) adjacency.set(b, new Set());
adjacency.get(a).add(b); adjacency.get(b).add(a);
connected.add(a); connected.add(b);
};
for (const wire of worldRef.items || []) {
if (!isWire(wire)) continue;
const pb = global.TarinaiPhysicsBodySystem;
const endpointA = pb?.endpoint?.(wire, 0);
const endpointB = pb?.endpoint?.(wire, 1);
const a = endpointObject(endpointA, worldRef);
const b = endpointObject(endpointB, worldRef);
const pointA = endpointPoint(endpointA, worldRef);
const pointB = endpointPoint(endpointB, worldRef);
if (pointA && pointB) {
const nextX = (pointA.x + pointB.x) * 0.5;
const nextY = (pointA.y + pointB.y) * 0.5;
const nextR = Math.max(18, Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y) * 0.5);
if (Math.hypot(num(wire.x) - nextX, num(wire.y) - nextY) > 0.25 || Math.abs(num(wire.r) - nextR) > 0.25) wireGeometryMoved = true;
wire.x = nextX; wire.y = nextY; wire.r = nextR;
if (endpointA) { endpointA.x = pointA.x; endpointA.y = pointA.y; }
if (endpointB) { endpointB.x = pointB.x; endpointB.y = pointB.y; }
}
wires.push({ wire, a, b });
if (a && b) addEdge(a, b);
}
const visited = new Set();
for (const start of connected) {
if (visited.has(start)) continue;
const stack = [start];
const component = [];
let active = false;
while (stack.length) {
const node = stack.pop();
if (!node || visited.has(node)) continue;
visited.add(node); component.push(node);
if (node.type === "pressure_switch" && node.signalActive) active = true;
for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next);
}
const hasSource = component.some(node => node.type === "pressure_switch");
for (const node of component) {
const sink = node.type === "rotator" || node.type === "reciprocator" || node.type === "robot_cleaner";
if (sink) {
node._signalDriven = hasSource;
node._signalOn = hasSource && active;
}
}
for (const entry of wires) {
if ((entry.a && component.includes(entry.a)) || (entry.b && component.includes(entry.b))) entry.wire.signalActive = hasSource && active;
}
}
for (const item of worldRef.items || []) {
if (!item || item.dead) continue;
if ((item.type === "rotator" || item.type === "reciprocator" || item.type === "robot_cleaner") && !connected.has(item)) {
item._signalDriven = false;
item._signalOn = false;
}
if (isWire(item) && !wires.some(entry => entry.wire === item)) item.signalActive = false;
}
if (wireGeometryMoved) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("signal-wire-motion");
}
return wires;
}
function distanceToSegment(px, py, ax, ay, bx, by) {
const vx = bx - ax, vy = by - ay;
const len2 = vx * vx + vy * vy || 1;
const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1);
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
}
function shockTarget(worldRef, wire, target, now, { connected = false } = {}) {
if (!target || target.dead) return false;
if (!wire._shockCooldowns || typeof wire._shockCooldowns.get !== "function") wire._shockCooldowns = new WeakMap();
if (now < num(wire._shockCooldowns.get(target), -Infinity)) return false;
wire._shockCooldowns.set(target, now + 0.38);
const isAnt = target.kind === "worker" || target.kind === "queen" || (target.r && !target.radius && Number.isFinite(Number(target.hp)));
if (isAnt) {
target.hp = Math.max(0, num(target.hp, target.maxHp || 32) - 2.4);
target.hpBarTimer = Math.max(num(target.hpBarTimer), 0.85);
target.state = "panic";
target.targetId = "";
target.targetToken = 0;
target.targetRef = null;
target.electricShockUntil = Math.max(num(target.electricShockUntil), now + 0.55);
} else {
target.damage?.(2.4, connected ? "通電中の電線に接続された" : "通電中の電線に触れた");
target.panicTimer = Math.max(num(target.panicTimer), 0.8);
target.fearTimer = Math.max(num(target.fearTimer), 0.8);
target.electricShockUntil = Math.max(num(target.electricShockUntil), now + 0.62);
target.setActionState?.("panic", { target: null, reason: "感電している", wake: true, sleeping: false });
}
emitEffect(worldRef, "electric_shock", target.x, target.y - Math.max(3, num(target.radius ?? target.r, 12) * 0.12), {
size: Math.max(12, num(target.radius ?? target.r, 12) * 1.15),
life: 0.28,
color: "rgba(118,224,255,0.96)",
});
return true;
}
function damageFromWires(worldRef, wires) {
const now = num(worldRef.time);
for (const { wire, a, b } of wires) {
if (!wire.signalActive) continue;
// Living endpoints are directly energized even through insulated wire.
if (a && (a.radius || a.kind === "worker" || a.kind === "queen")) shockTarget(worldRef, wire, a, now, { connected: true });
if (b && b !== a && (b.radius || b.kind === "worker" || b.kind === "queen")) shockTarget(worldRef, wire, b, now, { connected: true });
// Only bare wire shocks creatures merely touching the span.
if (wire.type !== "wire") continue;
const pb = global.TarinaiPhysicsBodySystem;
const pointA = endpointPoint(pb?.endpoint?.(wire, 0), worldRef);
const pointB = endpointPoint(pb?.endpoint?.(wire, 1), worldRef);
if (!pointA || !pointB) continue;
const cx = (pointA.x + pointB.x) * 0.5, cy = (pointA.y + pointB.y) * 0.5;
const radius = Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y) * 0.5 + 42;
const near = worldRef.nearbyTarinai?.(cx, cy, radius, true) || worldRef.tarinai || [];
for (const t of near) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (distanceToSegment(t.x, t.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(8, num(t.radius, 20) * 0.48)) continue;
shockTarget(worldRef, wire, t, now, { connected: false });
}
const ants = worldRef.nearbyAnts?.(cx, cy, radius, true) || worldRef.ants || [];
for (const ant of ants) {
if (!ant || ant.dead) continue;
if (distanceToSegment(ant.x, ant.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(5, num(ant.r, 6) * 0.9)) continue;
shockTarget(worldRef, wire, ant, now, { connected: false });
}
}
}
function powerOverride(item) {
if (!item || !item._signalDriven) return null;
return Boolean(item._signalOn);
}
function updateWorld(worldRef, dt = 0.016) {
if (!worldRef?.items) return false;
updatePressureSwitches(worldRef);
const wires = buildSignalNetwork(worldRef);
damageFromWires(worldRef, wires);
return true;
}
global.TarinaiSignalSystem = Object.freeze({
updateWorld,
powerOverride,
isWire,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -485,6 +485,41 @@ class Effect {
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 === "electric_shock") {
const phase = 1 - alpha;
const reach = this.size * (0.78 + phase * 0.22);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.shadowColor = this.color || "rgba(118,224,255,0.96)";
ctx.shadowBlur = Math.max(5, this.size * 0.42);
for (let bolt = 0; bolt < 4; bolt++) {
const baseAngle = this.seed + bolt * Math.PI * 0.5 + phase * (bolt % 2 ? -0.8 : 0.8);
const points = 6;
ctx.beginPath();
ctx.moveTo(0, 0);
for (let i = 1; i <= points; i++) {
const t = i / points;
const jitter = (i === points ? 0 : randSeed(this.seed + bolt * 31 + i * 7, -0.28, 0.28)) * this.size;
const px = Math.cos(baseAngle) * reach * t + Math.cos(baseAngle + Math.PI * 0.5) * jitter * (1 - t * 0.45);
const py = Math.sin(baseAngle) * reach * t + Math.sin(baseAngle + Math.PI * 0.5) * jitter * (1 - t * 0.45);
ctx.lineTo(px, py);
}
ctx.globalAlpha = alpha * 0.42;
ctx.strokeStyle = this.color || "rgba(118,224,255,0.96)";
ctx.lineWidth = Math.max(3.0, this.size * 0.16);
ctx.stroke();
ctx.globalAlpha = alpha * 0.96;
ctx.strokeStyle = "rgba(255,255,238,0.98)";
ctx.lineWidth = Math.max(1.1, this.size * 0.055);
ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.globalAlpha = alpha * 0.72;
ctx.strokeStyle = "rgba(255,238,96,0.90)";
ctx.lineWidth = Math.max(1.2, this.size * 0.07);
ctx.beginPath();
ctx.arc(0, 0, this.size * (0.28 + phase * 0.24), 0, Math.PI * 2);
ctx.stroke();
} else if (this.type === "fight") {
ctx.lineCap = "round";
ctx.lineJoin = "round";
@ -623,6 +658,9 @@ class Effect {
}
}
// Explicit export for systems implemented inside module-style IIFEs.
globalThis.TarinaiEffect = Effect;
class SpatialGrid {
constructor(cellSize = 96) {
this.cellSize = cellSize;
@ -746,7 +784,7 @@ class SpatialGrid {
if (!it || it.dead) return false;
const type = it.type || "";
if (type === "ball" || type === "balloon" || type === "fan" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if (type === "rope" || type === "rod") return true;
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(type)) return true;
const globalRef = (typeof window !== "undefined" ? window : globalThis);
const physics = globalRef.TarinaiPhysicsBodySystem;
const body = physics?.isBodyType?.(it) ? physics.ensureBody?.(it, null, { syncFromLegacy: false }) : null;

View file

@ -4,9 +4,10 @@
// Owns low-frequency ambient Tarinai/grass/rain-water creation.
(function (global) {
function spawnAmbientTarinai(worldRef, dt) {
if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
if ((worldRef.canAddTarinai?.(1) ?? true) && worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
const spot = worldRef.edgeSpawnPoint(null, null, 42, true);
const t = worldRef.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, entryTimer: 2.0 });
if (!t) return;
t.wanderAngle = spot.angle;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
@ -27,8 +28,7 @@
if (spot) {
const sprout = new Item("grass", spot.x, spot.y);
global.TarinaiGrass.setStage(sprout, 0, { force: true });
if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn");
else worldRef.items.push(sprout);
worldRef.addItem?.(sprout, "grass-spawn");
}
}
}
@ -39,8 +39,7 @@
// share the rain overlay renderer.
if (global.TarinaiWeatherSystem.shouldDropRainWater(worldRef, dt)) {
const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef);
worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop);
worldRef.emit?.("rain:itemDropped", { item: drop, type: "water" });
worldRef.addItem?.(drop, "rain-water");
}
}

View file

@ -12,6 +12,15 @@
map[k] = (map[k] || 0) + n;
}
function backgroundFullBudgetFor() {
return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().creatureBackgroundBudget || 36));
}
function consumesBackgroundBudget(cadence) {
if (!cadence || cadence.urgent || cadence.visible) return false;
return true;
}
function updateCreatures(worldRef, dt) {
const h = helpers;
const end = global.TarinaiPerf.begin("update.tarinai.individual") || null;
@ -35,6 +44,10 @@
skippedStates: {},
sleepPhysical: 0,
passivePhysical: 0,
fullBudget: backgroundFullBudgetFor(worldRef),
fullBudgetUsed: 0,
fullBudgetSkips: 0,
offscreenSkipped: 0,
};
const prevSpatialDeferMode = worldRef.deferSpatialRebuildMode || "";
worldRef.deferSpatialRebuildDepth = (worldRef.deferSpatialRebuildDepth || 0) + 1;
@ -60,6 +73,7 @@
else if (interval < 0.75) stats.near += 1;
else stats.far += 1;
if (!Number.isFinite(interval)) continue;
const usesBackgroundBudget = consumesBackgroundBudget(cadence);
const context = { cadence, motionDt: dt };
if (interval <= 0) {
@ -77,11 +91,21 @@
t._nextLowFreqUpdateAt = now + interval * (0.65 + jitter * 0.70);
}
if (now >= t._nextLowFreqUpdateAt) {
if (usesBackgroundBudget && stats.fullBudgetUsed >= stats.fullBudget) {
const jitter = stableUnit(t.id || t.familyKey || Math.random(), `creature-budget:${Math.floor(now * 4)}`);
t._nextLowFreqUpdateAt = now + Math.max(dt, interval * (0.18 + jitter * 0.18));
stats.fullBudgetSkips += 1;
stats.offscreenSkipped += cadence.visible ? 0 : 1;
stats.skipped += 1;
inc(stats.skippedStates, behaviorId);
continue;
}
const runDt = Math.max(dt, t._updateAccum || dt);
const jitter = stableUnit(t.id || t.familyKey || Math.random(), `creature-cadence:${Math.floor(now * 2)}`);
t._updateAccum = 0;
t._nextLowFreqUpdateAt = now + interval * (0.92 + jitter * 0.22);
stats.full += 1;
if (usesBackgroundBudget) stats.fullBudgetUsed += 1;
global.TarinaiCreatureRuntime.updateOne(t, runDt, { context });
} else if (cadence.smoothMotion) {
if (global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) {
@ -119,16 +143,18 @@
worldRef.limitBallChasers();
worldRef.resolveBallInteractions(dt);
if (endBallInteractions) endBallInteractions();
const hasFastTarinai = (worldRef.tarinai || []).some(t => {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) return false;
return Math.hypot(t.vx || 0, t.vy || 0) >= 150;
});
const collisionInterval = hasFastTarinai ? 0 : 0.55;
if (hasFastTarinai || now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) {
worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval;
let fastTarinai = null;
const collisionSpeedThreshold = global.TarinaiCollisionResponseSystem?.highSpeedThreshold?.() || 165;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (Math.hypot(t.vx || 0, t.vy || 0) < collisionSpeedThreshold) continue;
if (!fastTarinai) fastTarinai = [];
fastTarinai.push(t);
}
if (fastTarinai?.length) {
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
const endCollision = global.TarinaiPerf.begin("update.tarinai.collisions") || null;
try { worldRef.resolveTarinaiHighSpeedCollisions(dt); }
try { worldRef.resolveTarinaiHighSpeedCollisions(dt, { fastSources: fastTarinai, threshold: collisionSpeedThreshold }); }
finally { if (endCollision) endCollision(); }
} else {
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);

View file

@ -3,10 +3,52 @@
// Layer: simulation/system-effects
// Owns frame updates for transient visual/effect entities.
(function (global) {
function updateEffect(effect, dt, frameId = 0) {
function effectPriority(effect) {
const type = String(effect?.type || "");
if (type === "explosion" || type === "shoot_impact" || type === "bubble" || type === "heart" || type === "electric_shock") return 3;
if (type === "ring" || type === "fall") return 2;
return 1;
}
function compactEffectPressure(worldRef, effects) {
if (!Array.isArray(effects) || !effects.length) return { compacted: 0, dropped: 0 };
const before = effects.length;
let write = 0;
for (let i = 0; i < effects.length; i += 1) {
const effect = effects[i];
if (effect && !effect.dead) effects[write++] = effect;
}
effects.length = write;
const compacted = before - write;
const cap = Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().effectCap || 220));
if (effects.length <= cap) return { compacted, dropped: 0 };
const drop = effects
.map((effect, index) => ({
effect,
index,
score: effectPriority(effect) * 10 + Math.max(0, Math.min(1, Number(effect.life || 0) / Math.max(0.001, Number(effect.maxLife || effect.life || 1)))),
}))
.sort((a, b) => a.score - b.score)
.slice(0, effects.length - cap);
const dropSet = new Set(drop.map(entry => entry.index));
write = 0;
for (let i = 0; i < effects.length; i += 1) {
if (!dropSet.has(i)) effects[write++] = effects[i];
}
effects.length = write;
worldRef._effectPressureDroppedTotal = (worldRef._effectPressureDroppedTotal || 0) + dropSet.size;
return { compacted, dropped: dropSet.size };
}
function effectUpdateBudgetFor(total) {
const budget = Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().effectUpdateBudget || 84));
return Math.min(total, budget);
}
function updateEffect(effect, dt, frameId = 0, runScale = 1) {
if (!effect || effect.dead) return false;
const runDt = Math.min(0.24, Math.max(0, Number(effect._effectUpdateAccum || 0) + dt));
effect._effectUpdateAccum = 0;
const runDt = Math.min(0.24, Math.max(0, dt * Math.max(1, Number(runScale) || 1)));
effect._effectUpdatedFrame = frameId;
effect.update(runDt);
return true;
@ -14,15 +56,18 @@
function updateEffects(worldRef, dt) {
const effects = worldRef.effects || [];
const beforePressure = effects.length;
const pressure = compactEffectPressure(worldRef, effects);
const total = effects.length;
const frameId = (worldRef._effectUpdateFrameId || 0) + 1;
worldRef._effectUpdateFrameId = frameId;
if (total <= 56) {
for (const ef of effects) updateEffect(ef, dt, frameId);
worldRef._effectUpdateStats = { total, updated: total, skipped: 0, batched: false };
const budget = effectUpdateBudgetFor(total);
if (budget >= total) {
for (const ef of effects) updateEffect(ef, dt, frameId, 1);
worldRef._effectUpdateStats = { total, beforePressure, updated: total, skipped: 0, budget: total, compacted: pressure.compacted, dropped: pressure.dropped, batched: false };
return;
}
const budget = Math.max(40, Math.ceil(total * 0.42));
const runScale = Math.min(3.0, Math.max(1, total / Math.max(1, budget)));
let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % total;
let updated = 0;
let scanned = 0;
@ -31,17 +76,22 @@
cursor = (cursor + 1) % total;
scanned += 1;
if (!ef || ef.dead) continue;
updateEffect(ef, dt, frameId);
updateEffect(ef, dt, frameId, runScale);
updated += 1;
}
for (let i = 0; i < total; i += 1) {
const ef = effects[i];
if (!ef || ef.dead || ef._effectUpdatedFrame === frameId) continue;
if (Number.isFinite(ef._effectUpdateAccum)) ef._effectUpdateAccum = Math.min(0.24, ef._effectUpdateAccum + dt);
else ef._effectUpdateAccum = dt;
}
worldRef._effectUpdateCursor = cursor;
worldRef._effectUpdateStats = { total, updated, skipped: Math.max(0, total - updated), batched: true };
worldRef._effectUpdateStats = {
total,
beforePressure,
updated,
skipped: Math.max(0, total - updated),
budget,
runScale: Number(runScale.toFixed(2)),
compacted: pressure.compacted,
dropped: pressure.dropped,
droppedTotal: worldRef._effectPressureDroppedTotal || 0,
batched: true,
};
}
global.TarinaiEffectsSimulationSystem = Object.freeze({

View file

@ -39,10 +39,7 @@
worldRef.updateTemperature?.(dt);
worldRef.updateTerrainDirtyState?.(dt);
const now = worldRef.time || 0;
if (now >= (worldRef.nextColonyMoodEvaluateAt || 0)) {
worldRef.nextColonyMoodEvaluateAt = now + 0.65;
worldRef.evaluateColonyMood?.();
}
if (now >= (worldRef.nextColonyMoodEvalAt || 0)) worldRef.evaluateColonyMood?.();
}
function prepareSpatialFrame(worldRef) {
@ -61,7 +58,9 @@
}
function finalizeDiagnostics(worldRef) {
worldRef.lastRuntimeDiagnostics = worldRef.runtimeDiagnostics?.() || null;
if (!worldRef) return;
const wantsDiagnostics = Boolean(global.__tarinaiDebugOverlayEnabled || global.__tarinaiForceRuntimeDiagnostics);
worldRef.lastRuntimeDiagnostics = wantsDiagnostics ? (worldRef.runtimeDiagnostics?.() || null) : null;
}
const phases = Object.freeze({

View file

@ -39,6 +39,19 @@
return false;
}
function activeBallLikeCount(worldRef) {
if (!worldRef?.itemsOfType) return (worldRef?.items || []).filter(it => it && !it.dead && (it.type === "ball" || it.type === "balloon") && (Math.hypot(it.vx || 0, it.vy || 0) > 0.08 || (it.dropTimer || 0) > 0 || it.playerHeld || it._heldByPlayer)).length;
let count = 0;
for (const type of ["ball", "balloon"]) {
for (const it of worldRef.itemsOfType(type) || []) {
if (!it || it.dead) continue;
const active = Math.hypot(it.vx || 0, it.vy || 0) > 0.08 || (it.dropTimer || 0) > 0 || it.playerHeld || it._heldByPlayer || (worldRef.time || 0) - Number(it.lastKickedAt || -999) < 0.35;
if (active) count += 1;
}
}
return count;
}
function updateItemsAndAnts(worldRef, dt) {
const h = helpers;
const itemCountBefore = worldRef.items.length;
@ -51,10 +64,13 @@
h.updateItemsScheduled(worldRef, dt);
updateRandomGrassGrowth(worldRef, dt);
itemsMoved = h.markScheduledItemsMoved(worldRef, "items-moved", 0.25);
if (worldRef.itemCounts?.ball) {
const activeBalls = activeBallLikeCount(worldRef);
if (activeBalls >= 2) {
worldRef.ensureSpatial?.("ball-ball-collisions");
worldRef.resolveBallBallCollisions(dt);
worldRef.markSpatialDirty?.("ball-ball-collisions");
const solved = worldRef.resolveBallBallCollisions(dt) || 0;
if (solved > 0) worldRef.markSpatialDirty?.("ball-ball-collisions");
} else {
worldRef.workStats && (worldRef.workStats.ballCollisionSkips = (worldRef.workStats.ballCollisionSkips || 0) + 1);
}
worldRef.updateAnts?.(dt);
antsMoved = h.markSpatialDirtyIfMoved(worldRef, worldRef.ants || [], "ants-moved", 0.25);

View file

@ -10,7 +10,6 @@
let itemsCompacted = false;
if (worldRef.compactTimer >= compactInterval) {
itemsCompacted = worldRef.compactItems();
worldRef.compactEffects();
worldRef.compactTarinai();
worldRef.compactAnts?.();
worldRef.compactTimer = 0;
@ -50,7 +49,8 @@
worldRef.nextGrassBedExpiryCheckAt = (worldRef.time || 0) + 5.0;
const lifespan = Math.max(1, CONFIG.dayLength || 120); // \u30B2\u30FC\u30E0\u518524\u6642\u9593
let removedBeds = 0;
for (const it of worldRef.items || []) {
const grassBeds = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("grass_bed") : (worldRef.items || []);
for (const it of grassBeds || []) {
if (!it || it.dead || it.type !== "grass_bed") continue;
if (!Number.isFinite(Number(it.createdAt))) it.createdAt = worldRef.time || 0;
if ((worldRef.time || 0) - Number(it.createdAt || 0) < lifespan) continue;
@ -75,13 +75,9 @@
worldRef.sanitizeOutOfBounds();
}
worldRef.drawSortTimer += dt;
const visibleMoving = Boolean(worldRef.spatialTarinaiDirty || worldRef.spatialDynamicItemsDirty || worldRef.spatialAntDirty);
const sortInterval = visibleMoving ? 0.25 : 0.55;
if (worldRef.drawSortTimer >= sortInterval) {
worldRef.drawListDirty = true;
worldRef.drawSortTimer = 0;
}
// Render builds the visible stack from spatial cells every frame; the old
// drawListDirty periodic invalidation is no longer a render input.
worldRef.drawSortTimer = 0;
}
global.TarinaiMaintenanceSimulationSystem = Object.freeze({

View file

@ -63,17 +63,11 @@
return { left: cameraX - margin, top: cameraY - margin, right: cameraX + vw + margin, bottom: cameraY + vh + margin };
}
function pointInRect(x, y, rect) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
const helpers = Object.freeze({
updateItemsScheduled,
markSpatialDirtyIfMoved,
markScheduledItemsMoved,
trackSpatialEntityMoved,
updateVisibleWorldRect,
pointInRect,
});
global.TarinaiSimulationRuntime = Object.freeze({

View file

@ -20,6 +20,7 @@
PIN_STATE_IDS,
STAGE_IDS,
ITEM_TYPE_IDS,
itemTypeIndex,
enumIndex,
enumValue,
} = SaveSchema;
@ -31,6 +32,38 @@
const n = Number(value);
return Number.isFinite(n) ? n / scale : fallback;
}
function birthHash32(seed = "") {
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
if (match) {
const parsed = parseInt(match[1], 36);
if (Number.isFinite(parsed)) return parsed >>> 0;
}
let h = 2166136261;
const value = String(seed || "");
for (let i = 0; i < value.length; i++) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function compactBirthDescriptor(t = null, fallbackSerial = 1) {
const seed = String(t?.birthSeed || t?.familyKey || t?.id || "");
const parsed = global.TarinaiSeedFactory?.serialFromBirthSeed?.(seed) || 0;
const serial = Math.max(1, Math.floor(Number(t?.birthSerial) || parsed || fallbackSerial || 1));
return [serial, birthHash32(seed)];
}
function expandBirthDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) {
const serial = Math.max(1, Math.floor(Number(descriptor?.[0]) || fallbackSerial || 1));
const expected = global.TarinaiSeedFactory?.childSeed?.(worldSeed, serial, []) || `b${serial.toString(36)}_0`;
const hash = Number.isFinite(Number(descriptor?.[1])) ? (Number(descriptor[1]) >>> 0) : birthHash32(expected);
const expectedHash = birthHash32(expected);
return {
serial,
seed: hash === expectedHash ? expected : `b${serial.toString(36)}_${hash.toString(36)}`,
};
}
function mortonKeyXY(x = 0, y = 0) {
const ix = Math.max(0, Math.min(65535, Math.round(Number(x) || 0)));
const iy = Math.max(0, Math.min(65535, Math.round(Number(y) || 0)));
@ -71,7 +104,7 @@
return NEED_KEYS.map(k => q(needs[k], 1));
}
function arrayToNeeds(arr = []) {
const out = typeof global.createDefaultNeeds === "function" ? global.createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
const out = global.createDefaultNeeds();
NEED_KEYS.forEach((k, i) => { out[k] = u(arr[i], 1); });
return out;
}
@ -85,7 +118,7 @@
return out;
}
function geneticsToArray(g = {}) {
const normalized = typeof global.normalizeGenetics === "function" ? global.normalizeGenetics(g, "") : g;
const normalized = global.normalizeGenetics(g, "");
return [
q(Number(normalized?.lifeSpanMul) || 1, 100, 100),
q(Number(normalized?.attackMul) || 1, 100, 100),
@ -102,7 +135,7 @@
sizeMul: u(arr[3], 100, 1),
temperatureOffset: u(arr[4], 10, 0),
};
return typeof global.normalizeGenetics === "function" ? global.normalizeGenetics(out, "") : out;
return global.normalizeGenetics(out, "");
}
function flagPack(t = {}) {
let flags = 0;
@ -146,6 +179,8 @@
q(worldRef?.deadCount, 1, 0),
worldRef?.worldSeed || global.TarinaiSeedFactory.ensureWorldSeed(worldRef) || "",
q(worldRef?.birthSerial, 1, 0),
q(worldRef?.tarinaiPopulationLimit, 1, 0),
q(worldRef?.objectLimit, 1, 0),
];
}
function applyCompactWorld(worldRef, arr = []) {
@ -156,6 +191,8 @@
worldRef.weather = enumValue(WEATHER_IDS, arr[4], "sunny");
worldRef.worldSeed = String(arr[8] || "") || global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`;
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
worldRef.tarinaiPopulationLimit = Math.max(0, u(arr[10], 1, 0));
worldRef.objectLimit = Math.max(0, u(arr[11], 1, 0));
worldRef.day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
worldRef.time = totalTime - (worldRef.day - 1) * dayLength;
worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
@ -216,7 +253,7 @@
targetIdx = itemIndex.get(targetId) ?? itemIndex.get(t.target?.id) ?? -1;
}
return [
flagPack(t), t.birthSeed || t.familyKey || t.id || `s${idx}`, q(t.x, 1), q(t.y, 1),
flagPack(t), compactBirthDescriptor(t, idx + 1), q(t.x, 1), q(t.y, 1),
q(t.age, 10), q(t.generation, 1, 1), q(t.hunger, 1), q(t.energy, 1), q(t.circadianSleepPressure, 1),
needsToArray(t.needs || t.needRaw || {}), personalityToArray(t.currentPersonality),
parentIdx, compactRelationships(t, tarinaiIndex), timers, modes, pinIdx, nestIdx, [enumIndex(STATE_IDS, state), targetIdx],
@ -228,12 +265,13 @@
if (!TarinaiClass) throw new Error("Tarinai is not available");
const needs = arrayToNeeds(row[9]);
const age = u(row[4], 10);
const birthSeed = String(row[1] || `s${index}`);
const birthIdentity = expandBirthDescriptor(row[1], worldRef?.worldSeed || "", index + 1);
const birthSeed = birthIdentity.seed;
const profile = global.TarinaiSeedFactory.birthProfile(birthSeed) || {};
const opts = {
familyKey: birthSeed,
birthSeed,
birthSerial: global.TarinaiSeedFactory.serialFromBirthSeed(birthSeed) || 0,
birthSerial: birthIdentity.serial,
id: `st${index}`,
liveToken: 1,
currentPersonality: arrayToPersonality(row[10]),
@ -290,33 +328,41 @@
if (packed) return packed;
}
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) return { pf: 2, missing: true };
if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)];
if (type === "duplicator") return [itemTypeIndex(item.storedFoodType || "", -1)];
if (type === "ball" || type === "balloon") return [q(item.vx, 10), q(item.vy, 10)];
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
if (type === "ant_corpse") return [q(item.decayTimer, 10)];
if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)];
if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1];
if (type === "fire") return [q(item.flameLife, 10)];
if (typeof global.isPinType === "function" && global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
if (type === "nest_box") return [q(item.burnTimer, 10), q(item.nestFireEvacuationTimer, 10), item.burning ? 1 : 0, item.nestFireEvacuated ? 1 : 0];
if (global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
if (type === "fan") return [
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
q(item.angle, 1000, q(global.defaultItemAngle(type), 1000)),
item.fanSwingOn ? 1 : 0,
q(item.fanSwingCenter ?? item.angle ?? 0, 1000),
q(item.fanSwingRange ?? (35 * Math.PI / 180), 1000),
q(item.fanSwingSpeed ?? (48 * Math.PI / 180), 1000),
q(item.fanSwingPhase || 0, 1000),
q(item.fanBodyAngle ?? item.angle ?? 0, 1000),
];
if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) return [q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0];
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
if (type === "pressure_switch") return [item.pressureTarget === "item" ? 1 : 0, Math.max(1, Math.min(999, Number(item.pressureThreshold || 1) | 0)), Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0)), Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0))];
if (global.isRotatableItemType(type)) return [q(item.angle, 1000, q(global.defaultItemAngle(type), 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0];
if (global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
return [];
}
function compactItem(item, index, tarinaiIndex = new Map(), itemIndex = new Map()) {
const typeId = enumIndex(ITEM_TYPE_IDS, item?.type || "", -1);
const typeId = itemTypeIndex(item?.type || "", -1);
if (typeId < 0 || item?.type === "trace") return null;
return [typeId, q(item?.x, 1), q(item?.y, 1), q(item?.amount ?? item?.hp, 10), itemExtra(item, tarinaiIndex, itemIndex)];
}
function applyItemExtra(item, extra, tarinaiList = [], itemList = []) {
if (!Array.isArray(extra)) return;
const type = item.type || "";
if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) return;
if (!Array.isArray(extra)) {
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) global.TarinaiPhysicsBodySystem.invalidateItem(item, "physics-extra-missing");
return;
}
if (item.isStructure) {
item.hp = u(extra[0], 10, item.hp || 1);
item.maxHp = u(extra[1], 10, item.maxHp || item.hp || 1);
@ -351,11 +397,7 @@
item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60);
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
} else if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) {
return;
} else if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
// Physics items only accept the normalized pf:2 payload from this schema.
// Old array payloads are intentionally unsupported.
global.TarinaiPhysicsBodySystem.invalidateItem(item, "physics-extra-missing");
return;
} else if (type === "duplicator") {
@ -368,24 +410,42 @@
item.decayTimer = u(extra[0], 10);
} else if (type === "firecracker" || type === "flame_firecracker") {
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
} else if (type === "sticky_bomb") {
item.fuseTimer = u(extra[0], 10, item.fuseTimer || 12); item.fuseMax = Math.max(item.fuseMax || 12, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || "";
} else if (type === "fire") {
item.flameLife = u(extra[0], 10, item.flameLife || 6);
} else if (typeof global.isPinType === "function" && global.isPinType(type)) {
} else if (type === "nest_box") {
item.burnTimer = u(extra[0], 10, 0);
item.nestFireEvacuationTimer = u(extra[1], 10, 0);
item.burning = Boolean(extra[2]) || item.burnTimer > 0.02 || item.nestFireEvacuationTimer > 0.02;
item.nestFireEvacuated = Boolean(extra[3]);
if (item.burning) {
item.flameWaterCooldown = 0;
item.nestFireEffectCooldown = 0;
}
} else if (global.isPinType(type)) {
item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10);
} else if (type === "fan") {
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback);
const fallback = global.defaultItemAngle(type);
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);
item.fanSwingOn = Boolean(extra[1]);
item.fanSwingCenter = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[2], 1000, item.angle), item.angle) : u(extra[2], 1000, item.angle);
item.fanSwingCenter = global.normalizedItemAngle(u(extra[2], 1000, item.angle), item.angle);
item.fanSwingRange = Math.max(0, Math.min(150 * Math.PI / 180, u(extra[3], 1000, 35 * Math.PI / 180)));
item.fanSwingSpeed = Math.max(0, Math.min(360 * Math.PI / 180, u(extra[4], 1000, 48 * Math.PI / 180)));
item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0);
} else if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) {
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback);
item.fanBodyAngle = global.normalizedItemAngle(u(extra[6], 1000, item.angle), item.angle);
} else if (type === "pressure_switch") {
item.pressureTarget = Number(extra[0]) === 1 ? "item" : "tarinai";
item.pressureThreshold = Math.max(1, Math.min(999, Number(extra[1] || 1) | 0));
item.pressureWidth = Math.max(60, Math.min(840, Number(extra[2] || 220) | 0));
item.pressureHeight = Math.max(60, Math.min(840, Number(extra[3] || 160) | 0));
item.signalActive = false;
} else if (global.isRotatableItemType(type)) {
const fallback = global.defaultItemAngle(type);
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);
if (type === "gate_fence") item.gateOpen = !!extra[1];
} else if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) {
item.foodServingsRemaining = u(extra[0], 10, item.amount || 1); item.foodServingsMax = u(extra[1], 10, item.foodServingsRemaining || item.amount || 1); item.amount = item.foodServingsRemaining; (typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
} else if (global.isServingFoodType(type)) {
item.foodServingsRemaining = u(extra[0], 10, item.amount || 1); item.foodServingsMax = u(extra[1], 10, item.foodServingsRemaining || item.amount || 1); item.amount = item.foodServingsRemaining; global.updateServingFoodVisualSize(item);
}
}
function createSnapshot(worldRef = global.world) {
@ -399,8 +459,9 @@
tarinaiLive.forEach((t, i) => { if (t.id) tarinaiIndex.set(t.id, i); if (t.familyKey) familyIndex.set(t.familyKey, i); });
const itemRecords = [];
for (const it of (worldRef.items || [])) {
if (!it || it.dead || it.type === "trace" || enumIndex(ITEM_TYPE_IDS, it.type || "", -1) < 0) continue;
const typeId = enumIndex(ITEM_TYPE_IDS, it.type || "", -1);
if (!it || it.dead || it.type === "trace") continue;
const typeId = itemTypeIndex(it.type || "", -1);
if (typeId < 0) continue;
itemRecords.push({ item: it, sortTypeId: typeId, sortX: q(it.x, 1), sortY: q(it.y, 1) });
}
itemRecords.sort((a, b) => (a.sortTypeId || 0) - (b.sortTypeId || 0) || mortonKeyXY(a.sortX, a.sortY) - mortonKeyXY(b.sortX, b.sortY) || String(a.item?.id || "").localeCompare(String(b.item?.id || "")));
@ -498,8 +559,9 @@
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships; delete t.__savedStateInfo;
}
if (typeof global.updateNeeds === "function") {
for (const t of worldRef.tarinai) global.updateNeeds(t, worldRef, 0);
const updateNeedsRuntime = global.TarinaiNeedsRuntime?.updateNeeds;
if (typeof updateNeedsRuntime === "function") {
for (const t of worldRef.tarinai) updateNeedsRuntime(t, worldRef, 0);
}
worldRef.liveIdNext = Math.max(1, worldRef.tarinai.length + 1);
worldRef.liveIdSerial = Math.max(1, worldRef.tarinai.length + 1);

View file

@ -51,7 +51,7 @@
function triggerOwnedStructureDestroyedPanic(worldRef, structure, breaker = null, reason = "structure-destroyed") {
if (!worldRef || !shouldPanicOwnerOnStructureGone(structure, reason)) return false;
const owner = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId);
const owner = worldRef.liveTarinaiById?.(structure.ownerId) || (worldRef.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId);
if (!owner) return false;
const label = ownedStructureDestroyedLabel(structure);
const detail = `\u81EA\u5206\u306E${label}\u304C\u58CA\u3055\u308C\u305F`;
@ -142,7 +142,7 @@
while (changed) {
changed = false;
for (const link of worldRef.items || []) {
if (!link || link.dead || (link.type !== "rope" && link.type !== "rod")) continue;
if (!link || link.dead || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(link.type)) continue;
const a = pb.endpoint(link, 0);
const b = pb.endpoint(link, 1);
const hit = (a && a.kind === "item" && removedIds.has(a.id)) || (b && b.kind === "item" && removedIds.has(b.id));
@ -172,7 +172,6 @@
removeDependentLinks(worldRef, structure, reason);
if (ownerPanic && opts.panicOwner !== false) triggerOwnedStructureDestroyedPanic(worldRef, structure, opts.breaker || null, reason);
markWorldAfterStructureChange(worldRef, reason);
worldRef?.emit?.("structure:deleted", { structure, type: structure.type, reason });
return true;
}
@ -184,7 +183,6 @@
const changed = markStructureGone(bed, "grass-bed-wake-used");
if (!changed) return false;
markWorldAfterStructureChange(worldRef, "grass-bed-wake-used");
worldRef?.emit?.("structure:usedUp", { structure: bed, user, reason: "wake" });
return true;
}
@ -196,7 +194,6 @@
if (!changed) return false;
removeDependentLinks(worldRef, item, reason);
markWorldAfterStructureChange(worldRef, reason, item, Math.max(36, Number(item.r || item.radius || 24) + 18));
worldRef?.emit?.("item:deleted", { item, type: item.type, reason });
return true;
}

View file

@ -43,7 +43,7 @@
}
owner(worldRef = this.world) {
return (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === this.ownerId) || null;
return worldRef?.liveTarinaiById?.(this.ownerId) || (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === this.ownerId) || null;
}
update(dt, worldRef = this.world) {
@ -71,6 +71,8 @@
this.dead = true;
this.hp = 0;
this.amount = 0;
global.StructureRegistry?.get?.(this.type)?.onDestroyed?.(this, worldRef, { type: "fling_out", name: "画面外" });
global.TarinaiStructureLifecycle?.triggerOwnedStructureDestroyedPanic?.(worldRef, this, null, "plushie-fling-out");
worldRef?.markItemBucketsDirty?.("plushie-fling-out");
worldRef?.markSpatialDirty?.("plushie-fling-out");
}
@ -78,7 +80,7 @@
return;
}
const owner = this.owner(worldRef);
if (!owner) {
if (!this.ownerId || !owner) {
this.dead = true;
this.hp = 0;
this.amount = 0;
@ -170,7 +172,7 @@
dy = -0.24;
len = Math.hypot(dx, dy);
}
const base = Math.max(520, Math.min(980, Number(options.speed || 650) || 650));
const base = Math.max(520 / 3, Math.min(980 / 3, Number(options.speed || (650 / 3)) || (650 / 3)));
vx = dx / len * base;
vy = dy / len * base - Math.max(80, base * 0.18);
if (options.source === "poke") {
@ -179,14 +181,14 @@
vy = Math.min(vy, -base * 0.26);
}
speed = Math.hypot(vx, vy);
} else if (speed < 520) {
const scale = 520 / Math.max(1, speed);
} else if (speed < 520 / 3) {
const scale = (520 / 3) / Math.max(1, speed);
vx *= scale;
vy *= scale;
speed = Math.hypot(vx, vy);
}
this.vx = clamp(vx, -1100, 1100);
this.vy = clamp(vy, -1100, 980);
this.vx = clamp(vx, -1700, 1700);
this.vy = clamp(vy, -1350, 1100);
this.prevX = this.x;
this.prevY = this.y;
this.detachedUntil = world ? Math.max(this.detachedUntil || 0, (Number(world.time || 0) || 0) + 0.35) : (this.detachedUntil || 0);
@ -194,7 +196,7 @@
const spinSign = Math.sign(this.vx) || (Math.random() < 0.5 ? -1 : 1);
const autoSpin = spinSign * (11 + Math.min(14, speed / 80));
this.spinVelocity = clamp(Number(options.spinVelocity || autoSpin) || autoSpin, -28, 28);
this.plushieFlingTimer = Math.max(1.3, Math.min(3.4, Number(options.life || 2.7) || 2.7));
this.plushieFlingTimer = Math.max(1.3, Math.min(options.destroyOffscreen ? 5.4 : 3.4, Number(options.life || 2.7) || 2.7));
this.plushieTrailTimer = 0;
this.carriedById = "";
this.onHead = false;
@ -343,7 +345,6 @@
structure.amount = Math.max(0.001, structure.hp);
worldRef?.markItemBucketsDirty?.("grass-bed-owner-adopted");
worldRef?.markTerrainDirty?.("grass-bed-owner-adopted");
worldRef?.emit?.("structure:ownerAdopted", { structure, owner: user });
}
const ownerUse = user?.id && user.id === structure.ownerId;
if (ownerUse) structure.attachment = Math.min(100, (structure.attachment || 0) + 2);
@ -361,7 +362,6 @@
structure.amount = Math.max(0.001, structure.hp || structure.amount || 1);
worldRef?.markItemBucketsDirty?.("grass-bed-sleep-reserved");
worldRef?.markTerrainDirty?.("grass-bed-sleep-reserved");
worldRef?.emit?.("structure:useReserved", { structure, user });
}
return true;
},
@ -401,7 +401,6 @@
},
});
global.TarinaiStructure = TarinaiStructure;
global.StructureRegistry = StructureRegistry;
global.damageNearbyStructures = damageNearbyStructures;
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -22,7 +22,12 @@ function selectOwnedStructure(type, range = Infinity) {
}
function selectMateTarget(tarinai, world, range = 440) {
return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null;
const eligible = other => other && other !== tarinai && !other.dead && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease;
if (tarinai?.isTarinaiChampion) {
const champion = world?.nearestOther?.(tarinai, range + 260, other => eligible(other) && other.isTarinaiChampion) || null;
if (champion) return champion;
}
return world?.nearestOther?.(tarinai, range, eligible) || null;
}
function basicCanStartWithTarget(t, world, ctx = {}) {
@ -217,7 +222,7 @@ function createTemperatureComfortActionSpec() {
},
weight: 42,
selectTarget(t, world, ctx = {}) {
return world?.findComfortTemperatureSpot?.(t, { force: !!ctx.force, requireComfort: true }) || null;
return world?.findComfortTemperatureSpot?.(t, { force: !!ctx.force, requireComfort: false }) || null;
},
canStart(t, world, ctx = {}) {
const felt = world?.feltTemperatureFor?.(t) ?? world?.temperatureAt?.(t.x, t.y, t);
@ -239,11 +244,20 @@ function createTemperatureComfortActionSpec() {
t.goIdle?.("\u5FEB\u9069\u306A\u6E29\u5EA6\u306B\u306A\u3063\u305F");
return "finished";
}
const target = t.target && !t.target.dead ? t.target : (ctx.target ?? this.selectTarget(t, world, { force: true, requireComfort: true }));
const target = t.target && !t.target.dead ? t.target : (ctx.target ?? this.selectTarget(t, world, { force: true, requireComfort: false }));
if (!target) return false;
t.target = target;
if (distXY(t.x, t.y, target.x, target.y) < Math.max(24, (t.radius || 20) * 0.9)) {
const next = this.selectTarget(t, world, { force: true, requireComfort: true });
const shelter = target.shelterItem && !target.shelterItem.dead
? target.shelterItem
: ((target.type === "nest_box" || target.type === "pipe") ? target : null);
if (shelter && (shelter.type === "nest_box" || shelter.type === "pipe")) {
if (typeof t.enterNestBox === "function" && t.enterNestBox(shelter, dt, { purpose: "temperature" })) {
t.thought = shelter.type === "pipe" ? "土管の中で気温をしのいでいる" : "巣箱の中で気温をしのいでいる";
return true;
}
}
const next = this.selectTarget(t, world, { force: true, requireComfort: false });
if (!next || distXY(t.x, t.y, next.x, next.y) < Math.max(28, (t.radius || 20))) return "finished";
t.target = next;
}
@ -371,7 +385,7 @@ function createApproachMateActionSpec() {
phrase: "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b",
weight: 48,
selectTarget(t, world) { return selectMateTarget(t, world, 520); },
canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && (world?.canAddTarinai?.(1) ?? true) && !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start(t, world, ctx = {}) {
const other = ctx.target ?? this.selectTarget(t, world, ctx);
if (!other) return false;
@ -439,7 +453,7 @@ function createIntimidateEnemyActionSpec() {
const rival = ctx.target ?? this.selectTarget(t, world, ctx);
if (!isLiveTarinaiEntity(rival) || rival === t || rival.isTarinaiChampion) return false;
t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false });
t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85);
t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.42);
return true;
},
tick(t, world) {

View file

@ -115,7 +115,7 @@
target.singleUsePending = true;
target.singleUseConsumedById = this.id || target.singleUseConsumedById || "";
}
const targetIsNestContainer = Boolean(target && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe")));
const targetIsNestContainer = Boolean(target && global.TarinaiToolRuntime?.isNestContainerItem?.(target, { alive: false }));
if (target && !targetIsNestContainer) {
this._sleepExternalMotionUntil = 0;
this._sleepExternalWakeSuppressUntil = Math.max(Number(this._sleepExternalWakeSuppressUntil || 0) || 0, now + 2.4);

View file

@ -1,5 +1,6 @@
"use strict";
(function (global) {
function fulfillPrimaryReason(tarinai, action = null) {
const behavior = currentTarinaiBehavior(tarinai);
const actionId = action?.id || behavior?.actionId || "";
@ -100,6 +101,9 @@ function causePhraseForReason(chosenNeed, action, tarinai = null, options = {})
}
function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null, options = {}) {
if (action?.id === "sunbath" && (options?.leisure || tarinai?.sunbathLeisure || currentTarinaiBehavior(tarinai)?.data?.leisure)) {
return "気分がいいので、日光浴している。";
}
if (action?.id === "seek_comfort_temperature") {
const felt = tarinai?.world?.feltTemperatureFor?.(tarinai) ?? world?.temperatureAt?.(tarinai?.x, tarinai?.y, tarinai) ?? tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15);
const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null;
@ -145,9 +149,23 @@ function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {
return causePhraseForReason(behavior?.need || action?.need || "fulfill", action, tarinai, options);
}
function hasVisibleFireThreatForBehavior(tarinai) {
if (!tarinai || !tarinai.world) return false;
const target = tarinai.target || currentBehaviorTarget?.(tarinai) || null;
if (target && !target.dead && (target.type === "fire" || target.type === "flame_firecracker" || target.burning || (target.burnTimer || 0) > 0.02)) return true;
if ((tarinai.firePanicTimer || 0) > 0.02) return true;
const fires = typeof tarinai.world.itemsOfType === "function" ? tarinai.world.itemsOfType("fire") : (tarinai.world.items || []).filter(it => it && it.type === "fire");
return (fires || []).some(f => f && !f.dead && (f.amount || 0) > 0 && distXY(tarinai.x, tarinai.y, f.x, f.y) <= Math.max(190, (f.r || 14) + 180));
}
function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) {
if (tarinai && (tarinai.burning || (tarinai.burnTimer || 0) > 0.02)) return globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
if (tarinai && ((tarinai.firePanicTimer || 0) > 0.02 || (tarinai.state === "panic" && tarinai.lastPanicCause === "fire_seen"))) return globalThis.TarinaiItemDynamicToolSystem?.fireSeenText || "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002";
if (tarinai && hasVisibleFireThreatForBehavior(tarinai)) return globalThis.TarinaiItemDynamicToolSystem?.fireSeenText || "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002";
if (tarinai && tarinai.lastPanicCause === "fire_seen" && !hasVisibleFireThreatForBehavior(tarinai)) {
tarinai.lastPanicCause = "";
if (tarinai.lastPanicDetail === (globalThis.TarinaiItemDynamicToolSystem?.fireSeenText || "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002")) tarinai.lastPanicDetail = "";
}
const b = behavior || (currentTarinaiBehavior(tarinai)) || null;
if (!b) return "";
const stored = String(b.text || b.reason || "").trim();
@ -186,5 +204,9 @@ function refreshBehaviorReasonText(tarinai, action = null) {
return patchBehaviorTextFromComposer(tarinai, behavior, { action });
}
Object.assign(global, {
buildReasonText,
composeTarinaiBehaviorText,
refreshBehaviorReasonText,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -55,10 +55,6 @@ function forceBuiltStructureVisualRefresh(world, structure, reason = "structure-
if (structure) {
structure.world = world;
}
if (typeof globalThis.render === "function") {
globalThis.render();
globalThis.requestAnimationFrame?.(() => globalThis.render?.());
}
}
function findStructureBuildSpot(t, world, type) {
@ -108,25 +104,42 @@ function grassMaterialAvailableStages(material) {
return Math.max(0, Math.floor(Number(stage) || 0) + 1);
}
function reservedBuildGrassStages(world, material, type, self = null) {
if (!world || !material) return 0;
const id = material.id || "";
if (!id) return 0;
let reserved = 0;
function buildGrassReservationIndex(world, self = null) {
const index = new Map();
if (!world) return index;
for (const other of world.tarinai || []) {
if (!other || other.dead || other === self) continue;
const plan = other.buildPlan || null;
const planType = plan?.type || (/build_plushie/.test(other.behavior?.actionId || "") ? "plushie" : (/build_grass_bed/.test(other.behavior?.actionId || "") ? "grass_bed" : ""));
if (type && planType && planType !== type) continue;
const targetId = plan?.materialId || other.target?.id || "";
if (targetId !== id) continue;
if (!targetId) continue;
const state = String(other.state || "");
const actionId = String(other.behavior?.actionId || other.currentActionId || "");
if (state === "seek_material" || state === "build" || /build_grass_bed|build_plushie|seek_material/.test(actionId)) {
reserved += structureGrassStageCost(planType || type);
if (state !== "seek_material" && state !== "build" && !/build_grass_bed|build_plushie|seek_material/.test(actionId)) continue;
const cost = structureGrassStageCost(planType || "");
if (cost <= 0) continue;
let rec = index.get(targetId);
if (!rec) {
rec = { total: 0, byType: Object.create(null), unknownReservations: 0 };
index.set(targetId, rec);
}
rec.total += cost;
if (planType) rec.byType[planType] = (rec.byType[planType] || 0) + cost;
else rec.unknownReservations += 1;
}
return reserved;
return index;
}
function reservedBuildGrassStages(world, material, type, self = null, reservationIndex = null) {
if (!world || !material) return 0;
const id = material.id || "";
if (!id) return 0;
const index = reservationIndex || buildGrassReservationIndex(world, self);
const rec = index.get(id);
if (!rec) return 0;
if (!type) return rec.total || 0;
const unknownCost = (rec.unknownReservations || 0) * structureGrassStageCost(type);
return (rec.byType?.[type] || 0) + unknownCost;
}
function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) {
@ -134,13 +147,14 @@ function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) {
let best = null;
let bestScore = Infinity;
const candidates = world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist + 40, true) || world?.items || [];
const reservationIndex = buildGrassReservationIndex(world, tarinai);
for (const item of candidates) {
if (!item || item.dead || !roleMatches(item, "grassMaterial")) continue;
if (tarinai?.shouldAvoidTarget?.(item)) continue;
if (world?.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65))) continue;
const available = grassMaterialAvailableStages(item);
if (available < required) continue;
const reserved = reservedBuildGrassStages(world, item, type, tarinai);
const reserved = reservedBuildGrassStages(world, item, type, tarinai, reservationIndex);
if (available - reserved < required) continue;
const d = dist(tarinai, item);
if (d > maxDist) continue;
@ -154,6 +168,7 @@ function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) {
}
function buildOwnedStructureFromGrass(t, world, type, label) {
if (!(world?.canAddObjects?.(1) ?? true)) return false;
const material = findNearbyBuildMaterial(world, t, type, 520);
if (!material || !globalThis.StructureRegistry) return false;
t.buildPlan = {
@ -194,6 +209,9 @@ function continueBuildPlan(t, world, dt) {
if (!spot) {
return stopFailedBuildPlan(t, world, plan.type, "\u4F5C\u308B\u5834\u6240\u304C\u898B\u3064\u304B\u3089\u306A\u3044");
}
if (!(world?.canAddObjects?.(1) ?? true)) {
return stopFailedBuildPlan(t, world, plan.type, "\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u308b");
}
consumeGrassMaterial(material, structureGrassStageCost(plan.type));
const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world);
if (!structure) {
@ -205,7 +223,7 @@ function continueBuildPlan(t, world, dt) {
structure.x = t.x;
structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28);
}
const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || (world.items.push(structure), structure);
const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || null;
if (!addedStructure) {
return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}

View file

@ -74,6 +74,13 @@ const d = contact.distance;
if (it.type === "ball" || it.type === "balloon") {
const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1);
if (touch > 0.02) {
const now = tarinai.world?.time || 0;
if (typeof tarinai.adjustPersonality === "function" && now >= (tarinai.nextToyOpennessAt || 0)) {
tarinai.nextToyOpennessAt = now + rand(3.2, 5.6);
tarinai.adjustPersonality("openness", 0.0011 + touch * 0.0015, "after touching a toy");
}
}
if (tarinai.state === "play_ball" || tarinai.target === it || tarinai.shouldApplyPersonalityBehavior("openness", 1)) {
tarinai.goodMode = "smile";
applyNeedRelief(tarinai, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) });

View file

@ -5,11 +5,37 @@
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_disease_nest.js");
function isNestContainerItem(item) {
return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
return Boolean(global.TarinaiToolRuntime?.isNestContainerItem?.(item));
}
function containerLabel(item) { return item?.type === "pipe" ? "\u571f\u7ba1" : "\u5de3\u7bb1"; }
function containerInsideSleepText(item, verb = "\u7720\u3063\u3066\u3044\u308b") { return `${containerLabel(item)}\u306e\u4e2d\u3067${verb}`; }
function containerFullText(item) { return `${containerLabel(item)}\u304c\u3044\u3063\u3071\u3044\u3067\u5165\u308c\u306a\u3044`; }
function targetShelterItem(target = null) {
return isNestContainerItem(target?.shelterItem) ? target.shelterItem : (isNestContainerItem(target?.hostItem) ? target.hostItem : null);
}
function targetReferencesNestBox(target = null, box = null) {
if (!box || !isNestContainerItem(box)) return false;
return target === box || targetShelterItem(target) === box;
}
function zunchiDiseaseTemperatureInfectionMultiplier(actor = null) {
const worldRef = actor?.world || global.world || null;
if (!actor || !worldRef) return 1;
const felt = worldRef.feltTemperatureFor?.(actor) ?? worldRef.temperatureAt?.(actor.x, actor.y, actor) ?? actor.feltTemperature ?? actor.tempComfort ?? (CONFIG.standardTemperature ?? 15);
const status = worldRef.temperatureStatusFor?.(felt, actor) || null;
if (!status || status.comfortable) return 1;
const discomfort = Math.max(0, Number(status.discomfort || 0) || 0);
if (status.direction === "hot") return clamp(1 + discomfort * 0.070, 1, 2.85);
if (status.direction === "cold") return clamp(1 - discomfort * 0.030, 0.42, 1);
return 1;
}
if (typeof global !== "undefined") global.zunchiDiseaseTemperatureInfectionMultiplier = zunchiDiseaseTemperatureInfectionMultiplier;
function temperatureShelterStillUseful(t, box = null) {
if (!t || !box || !t.world) return false;
const now = Number(t.world.time || 0) || 0;
if (now < Number(t._nestBoxShelterUntil || 0)) return true;
const status = t.world.temperatureStatusFor?.(t.world.feltTemperatureFor?.(t) ?? t.tempComfort ?? t.feltTemperature ?? (CONFIG.standardTemperature ?? 15), t);
return Boolean(status && !status.comfortable && (status.discomfort || 0) >= 1.2 && targetReferencesNestBox(t.target, box));
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
infectZunchiDisease(source = null, force = false) {
@ -23,7 +49,6 @@
this.thought = "\u305a\u3093\u3061\u75c5\u306b\u304b\u304b\u3063\u305f";
audio.disease?.();
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "\u3046\u3045\u2026", "rgba(38,82,38,0.82)");
this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_zunchi", source });
this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u305a\u3093\u3061\u75c5");
return true;
},
@ -38,19 +63,18 @@
this.thought = reason || "\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f";
audio.heal?.();
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(62,104,62,0.78)");
this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_zunchi", reason });
this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u305a\u3093\u3061\u75c5");
return true;
},
emitZunchiDiseaseEffect(dt) {
if (!this.zunchiDisease || !this.world?.effects) return;
if (deterministicChance(this.world, "zunchi-disease-miasma", dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4), this)) {
if (deterministicChance(this.world, "zunchi-disease-miasma", dt * (0.48 + (this.zunchiDiseaseSeverity || 0) * 0.62), this)) {
this.world.effects.push(new Effect("zunchi_miasma", this.x + deterministicRange(this.world, "zunchi-miasma-x", -this.radius * 0.45, this.radius * 0.45, this), this.y - this.radius * deterministicRange(this.world, "zunchi-miasma-y", 0.05, 0.72, this), {
vx: deterministicRange(this.world, "zunchi-miasma-vx", -5, 5, this),
vy: deterministicRange(this.world, "zunchi-miasma-vy", -10, -3, this),
life: deterministicRange(this.world, "zunchi-miasma-life", 1.4, 2.4, this),
size: deterministicRange(this.world, "zunchi-miasma-size", 8, 18, this) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45),
life: deterministicRange(this.world, "zunchi-miasma-life", 1.6, 2.8, this),
size: deterministicRange(this.world, "zunchi-miasma-size", 12, 26, this) * (1.0 + (this.zunchiDiseaseSeverity || 0) * 0.55),
color: "rgba(16,78,28,0.70)",
}));
}
@ -63,7 +87,9 @@
this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100);
if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006);
}
const zunchiSelfInfectChance = this.diseaseChance ? this.diseaseChance(dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) : dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045;
const zunchiTempInfectMul = zunchiDiseaseTemperatureInfectionMultiplier(this);
const zunchiSelfInfectBase = dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045 * zunchiTempInfectMul;
const zunchiSelfInfectChance = this.diseaseChance ? this.diseaseChance(zunchiSelfInfectBase) : zunchiSelfInfectBase;
if (!this.zunchiDisease && this.zunchiStain > 82 && deterministicChance(this.world, "zunchi-self-infect", zunchiSelfInfectChance, this)) {
this.infectZunchiDisease(null, true);
this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] });
@ -93,7 +119,6 @@
audio.disease?.();
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(72,76,120,0.76)");
this.world?.log?.(`${this.name}\u306f\u306d\u3080\u308a\u75c5\u306b\u304b\u304b\u3063\u305f\u3002`, "accident", { participants: [this] });
this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_sleep", source });
this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u306d\u3080\u308a\u75c5");
return true;
},
@ -111,7 +136,6 @@
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65);
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "!", "rgba(72,76,120,0.76)");
this.world?.log?.(`${this.name}\u306e\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] });
this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_sleep", reason });
this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u306d\u3080\u308a\u75c5");
return true;
},
@ -125,7 +149,6 @@
audio.disease?.();
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.2, "!", "rgba(178,78,42,0.82)");
this.world?.log?.(`${this.name}\u306f\u7206\u767a\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] });
this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_explosion", source });
this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u7206\u767a\u75c5");
return true;
},
@ -139,7 +162,6 @@
if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -22, safety: -8 });
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)");
this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] });
this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_explosion", reason });
this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u7206\u767a\u75c5");
return true;
},
@ -157,7 +179,6 @@
audio.disease?.();
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "?", "rgba(150,78,44,0.80)");
this.world?.log?.(`${this.name}\u306f\u304d\u305a\u3064\u304d\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight", { participants: [this] });
this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_fight", source });
this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u304d\u305a\u3064\u304d\u75c5");
return true;
},
@ -171,7 +192,6 @@
if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -24, safety: -10 });
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)");
this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight", { participants: [this] });
this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_fight", reason });
this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u304d\u305a\u3064\u304d\u75c5");
return true;
},
@ -251,19 +271,26 @@
if (!isNestContainerItem(box)) return false;
const now = this.world?.time || 0;
if (this._nestBoxAvoidId === box.id && now < (this._nestBoxAvoidUntil || -Infinity)) return false;
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true;
if (!(this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return false;
return this.target === box || this.insideNestBoxId === box.id;
const sleepIntent = this.state === "sleep" || this.state === "seek_bed" || this.sleeping;
const temperatureIntent = this.state === "seek_temperature" || targetReferencesNestBox(this.target, box) || temperatureShelterStillUseful(this, box);
if (this.insideNestBoxId === box.id && now < (this.nestBoxStayUntil || -Infinity) && (sleepIntent || temperatureIntent)) return true;
if (this.insideNestBoxId === box.id && temperatureShelterStillUseful(this, box)) return true;
if (!(sleepIntent || temperatureIntent)) return false;
return targetReferencesNestBox(this.target, box) || this.insideNestBoxId === box.id;
},
enterNestBox(box, dt = 0.016, options = {}) {
if (!isNestContainerItem(box) || this.dead) return false;
const alreadyInside = this.insideNestBoxId === box.id;
if (!alreadyInside && box?.type === "nest_box" && globalThis.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(box)) {
return this.abortNestBoxEntry?.(box, "巣箱が燃えていて入れない") || false;
}
const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5;
let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
const alreadyInside = this.insideNestBoxId === box.id;
const now = this.world?.time || 0;
if (!alreadyInside && occupants.length >= capacity) {
if (options?.forceDisplace) {
const kingSleepDisplace = Boolean(this.isTarinaiChampion && box.type === "nest_box" && (this.state === "seek_bed" || this.state === "sleep" || this.sleeping));
if (options?.forceDisplace || kingSleepDisplace) {
const victim = occupants.find(t => t && t !== this && !t.dead && !t.isTarinaiChampion)
|| occupants.find(t => t && t !== this && !t.dead);
if (victim) {
@ -278,11 +305,15 @@
}
if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, containerFullText(box)) || false;
}
const shelterPurpose = options?.purpose === "temperature" || this.state === "seek_temperature" || targetShelterItem(this.target) === box;
if (shelterPurpose) {
this._nestBoxShelterUntil = Math.max(Number(this._nestBoxShelterUntil || 0) || 0, now + 12);
}
if (!alreadyInside) {
this._nestBoxSeekStartedAt = NaN;
this._nestBoxSeekTargetId = "";
const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(box) : { x: box.x, y: box.y };
this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, now + 14);
this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, now + (shelterPurpose ? 12 : 14));
this.nestBoxSlotIndex = Math.min(occupants.length, Math.max(0, capacity - 1));
this._nestBoxEntryStartX = Number.isFinite(this.x) ? this.x : entry.x;
this._nestBoxEntryStartY = Number.isFinite(this.y) ? this.y : entry.y;
@ -354,14 +385,40 @@
this._nestBoxExitEndY = Number.isFinite(exit.y) ? exit.y : this._nestBoxExitDoorY;
this._nestBoxExitDuration = 0.58;
this._nestBoxExitSmoothUntil = now + this._nestBoxExitDuration;
if (b?.type === "nest_box" && globalThis.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(b)) {
this._burningNestExitBoxId = b.id || "";
this._burningNestExitUntil = Math.max(Number(this._burningNestExitUntil || 0) || 0, now + this._nestBoxExitDuration + 1.2);
}
this.nestFade = Math.max(this.nestFade || 0, 0.96);
return true;
},
forceIgniteFromBurningNestExit(box = null) {
const now = Number(this.world?.time || 0) || 0;
const b = box || this.nestBoxById?.(this._burningNestExitBoxId);
const active = Boolean(b?.type === "nest_box" && globalThis.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(b));
const taggedExit = Boolean(this._burningNestExitBoxId && now < Number(this._burningNestExitUntil || 0));
if (!active && !taggedExit) return false;
if (!this.burning || !(this.burnTimer > 0.02)) {
if (typeof this.igniteFire === "function") this.igniteFire(b || box || null, true);
else {
const text = globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
this.burning = true;
this.burnTimer = Math.max(Number(this.burnTimer || 0) || 0, 5.2);
this.thought = text;
this.sleeping = false;
this.setActionState?.("panic", { target: null, reason: text, wake: true, sleeping: false });
}
}
this._burningNestExitUntil = Math.max(Number(this._burningNestExitUntil || 0) || 0, now + 0.75);
return true;
},
updateNestBoxExitAnimation(dt = 0.016) {
const now = this.world?.time || 0;
const until = this._nestBoxExitSmoothUntil || -Infinity;
if (!(now < until)) return false;
if (this._burningNestExitBoxId && now < Number(this._burningNestExitUntil || 0)) this.forceIgniteFromBurningNestExit?.(this.nestBoxById?.(this._burningNestExitBoxId));
const duration = Math.max(0.20, Number(this._nestBoxExitDuration || 0.58) || 0.58);
const progress = clamp(1 - Math.max(0, until - now) / duration, 0, 1);
const eased = progress * progress * (3 - 2 * progress);
@ -415,6 +472,7 @@
this.sleepSession = null;
if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.(`${containerLabel(b)}\u304b\u3089\u51fa\u305f`);
this.nestBoxStayUntil = -Infinity;
this._nestBoxShelterUntil = 0;
this.nestBoxSlotIndex = -1;
this._nestBoxEntrySmoothUntil = -Infinity;
this._nestBoxEntryStartX = NaN;
@ -424,13 +482,20 @@
this._nestBoxEntryDuration = 0;
this._nestBoxSeekStartedAt = NaN;
this._nestBoxSeekTargetId = "";
if (b?.type === "nest_box" && globalThis.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(b)) {
const now = Number(this.world?.time || 0) || 0;
this._burningNestExitBoxId = b.id || this._burningNestExitBoxId || "";
this._burningNestExitUntil = Math.max(Number(this._burningNestExitUntil || 0) || 0, now + 1.4);
this.forceIgniteFromBurningNestExit?.(b);
}
return true;
},
updateNestBoxPresence(dt = 0) {
if (this.updateNestBoxExitAnimation?.(dt || 0.016)) return null;
const currentBox = this.nestBoxById(this.insideNestBoxId);
const targetBox = isNestContainerItem(this.target) ? this.target : null;
const shelterBox = targetShelterItem(this.target);
const targetBox = isNestContainerItem(this.target) ? this.target : shelterBox;
const box = currentBox || targetBox;
if (!box) {
this.insideNestBoxId = null;
@ -532,7 +597,6 @@
this.nextBurnVoiceAt = now + 2.6;
this.bubble?.("\u3042\u3061\u3085\u3044\uff01", 1.35, "rgba(214,82,38,0.90)", { force: true });
}
this.world?.emit?.("fire:ignite", { tarinai: this, source });
this.recordChangeCause?.("\u708e\u4e0a", "\u708e\u4e0a");
return true;
},
@ -545,7 +609,6 @@
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.35);
if (reason) this.thought = reason;
this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(22, this.radius * 1.2), life: 0.22, color: "rgba(108,192,236,0.60)" }));
this.world?.emit?.("fire:extinguish", { tarinai: this, reason });
return true;
},
@ -561,8 +624,24 @@
this.fearTimer = Math.max(this.fearTimer || 0, 0.8);
this.sleeping = false;
this.eatTimer = 0;
const currentNest = this.nestBoxById?.(this.insideNestBoxId);
if (currentNest) {
const water = globalThis.TarinaiItemDynamicToolSystem?.contactWaterDrop?.(this.world, currentNest.x, currentNest.y, Math.max(this.radius || 18, (currentNest.r || 42) * 1.15));
if (water) {
globalThis.TarinaiItemDynamicToolSystem?.consumeWaterForFire?.(this.world, water, Math.max(10, (this.radius || 18) * 0.55));
this.extinguishFire("水で消火された");
return;
}
const mul = globalThis.TarinaiItemDynamicToolSystem?.fireTempMultiplier?.(this.world, currentNest.x, currentNest.y, this) || 1;
if (deterministicChance(this.world, "tarinai-nest-fire-extinguish", dt * 0.014 * mul, this, currentNest)) {
this.extinguishFire("自然に火が消えた");
return;
}
if (this.burnTimer <= 0.01) this.extinguishFire("自然に火が消えた");
return;
}
if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 6.2, safety: dt * 3.0 });
if (typeof this.damage === "function") this.damage(dt * 3.8, "\u708e\u4e0a");
if (typeof this.damage === "function") this.damage(dt * 3.8, "炎上");
else this.energy = clamp((this.energy || 0) - dt * 0.12, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.22);
const water = globalThis.TarinaiItemDynamicToolSystem?.contactWaterDrop?.(this.world, this.x, this.y, Math.max(this.radius || 18, 16));
@ -577,12 +656,12 @@
return;
}
for (const viewer of [...(this.world?.nearbyTarinai?.(this.x, this.y, 190, true) || [])]) {
if (!viewer || viewer === this || viewer.dead) continue;
if (!viewer || viewer === this || viewer.dead || viewer.insideNestBoxId || this.world?.isTarinaiHiddenInNestBox?.(viewer)) continue;
globalThis.TarinaiItemDynamicToolSystem?.panicTarinaiFromFire?.(viewer, this, this.world);
}
const contactRadius = Math.max(20, (this.radius || 18) * 1.1);
for (const other of [...(this.world?.nearbyTarinai?.(this.x, this.y, contactRadius + 28, true) || [])]) {
if (!other || other === this || other.dead) continue;
if (!other || other === this || other.dead || other.insideNestBoxId || this.world?.isTarinaiHiddenInNestBox?.(other)) continue;
if (distXY(this.x, this.y, other.x, other.y) <= contactRadius + Math.max(other.radius || 18, 12) * 0.72) other.igniteFire?.(this, false);
}
for (const ant of [...(this.world?.nearbyAnts?.(this.x, this.y, contactRadius + 18, true) || [])]) {

View file

@ -37,7 +37,6 @@
if (!it.storedFoodType) return 0;
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
it.usedCount = (it.usedCount || 0) + 1;
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.storedFoodType, sourceType: "duplicator", nutrition: value });
return value;
}
this.ensureFoodServings(it);
@ -51,7 +50,6 @@
this.world?.markSpatialDirty?.("food-serving-resize");
this.world && (this.world.drawListDirty = true);
}
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value });
return value;
},
foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) {

View file

@ -230,6 +230,7 @@ function setBehaviorFromAction(tarinai, action, choice = {}, reasonText = "", op
lockSeconds: options.lockSeconds,
minDuration: options.minDuration,
phase: options.phase || "starting",
data: options.leisure ? { leisure: true } : options.data,
specId: action.id,
specKind: action.kind || "need_action",
});
@ -349,7 +350,7 @@ function processForcedBehaviorQueue(tarinai, world, needs) {
tarinai.sleepSession = null;
}
}
const started = startNeedAction(tarinai, world, choice, action, reasonText, {
const started = globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, action, reasonText, {
forced: true,
forcedId: entry.uid,
forcedReasonText: entry.reasonText || reasonText,

View file

@ -382,7 +382,7 @@
},
targetIgnoresFence(target = this.target) {
const targetIsContainer = Boolean(target && (typeof isNestContainerType === "function" ? isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe")));
const targetIsContainer = Boolean(target && globalThis.TarinaiToolRuntime?.isNestContainerItem?.(target, { alive: false }));
return Boolean(target?.type === "sweet" || targetIsContainer || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy");
},

View file

@ -70,7 +70,6 @@
if (!(id in this.itemEffectTimers)) this.itemEffectTimers[id] = 0;
this.itemEffectTimers[id] = Math.max(this.itemEffectTimers[id] || 0, Number(seconds) || 0);
if (id === "laxative") this.laxativePoopTimer = Math.min(Math.max(this.laxativePoopTimer || 3, 0.2), 3);
this.world?.emit?.("effect:applied", { tarinai: this, effectId: id, duration: this.itemEffectTimers[id] || 0 });
recordItemChange(this, id, "\u6642\u9593\u52b9\u679c");
return true;
},
@ -113,7 +112,6 @@
this.powerEffectKind = mode;
this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 3.2);
this.spawnPowerItemEffect(mode, 1.5);
this.world?.emit?.("effect:applied", { tarinai: this, effectId: mode, permanent: true });
recordItemChange(this, mode, "\u80fd\u529b\u5909\u5316");
return true;
},
@ -127,7 +125,6 @@
this.powerEffectKind = mode;
this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 4.0);
this.spawnPowerItemEffect(mode, mode === "giant_drug" ? 1.9 : 1.2);
this.world?.emit?.("effect:applied", { tarinai: this, effectId: mode, permanent: true });
recordItemChange(this, mode, "\u4f53\u683c\u5909\u5316");
return true;
},
@ -144,7 +141,6 @@
this.powerEffectKind = "mercury";
this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 2.7);
this.spawnPowerItemEffect("mercury", 0.95);
this.world?.emit?.("effect:applied", { tarinai: this, effectId: "mercury", permanent: true, multiplier: this.mercuryLifeMultiplier });
recordItemChange(this, "mercury", "\u5bff\u547d\u5909\u5316");
return true;
},
@ -169,7 +165,6 @@
this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 2.2);
this.refreshEffectiveSize();
this.spawnPowerItemEffect("zunda_juice", 1.4);
this.world?.emit?.("effect:removed", { tarinai: this, effectId: "all_removable", source: "zunda_juice" });
recordItemChange(this, "zunda_juice", "\u52b9\u679c\u89e3\u9664");
return true;
},

View file

@ -161,17 +161,24 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) {
const primary = (foodLikeRole && world?.nearbyFood)
? world.nearbyFood(tarinai.x, tarinai.y, searchDist, true)
: (world?.nearbyItems?.(tarinai.x, tarinai.y, searchDist, true) || world?.items || []);
const candidates = Array.isArray(primary) ? primary.slice() : [];
const candidates = [];
const seen = new Set();
const addCandidate = (it) => {
if (it && !it.dead && !candidates.includes(it)) candidates.push(it);
if (it && !it.dead && !seen.has(it)) {
seen.add(it);
candidates.push(it);
}
};
if (Array.isArray(primary)) for (const it of primary) addCandidate(it);
// \u98DF\u6599\u30FB\u6C34\u30FB\u85AC\u306F\u3001\u901A\u5E38\u6642\u304B\u3089\u7A7A\u9593\u30A4\u30F3\u30C7\u30C3\u30AF\u30B9\u3060\u3051\u306B\u4F9D\u5B58\u3057\u306A\u3044\u3002
// \u751F\u6210\u76F4\u5F8C/\u79FB\u52D5\u76F4\u5F8C\u306E\u30A2\u30A4\u30C6\u30E0\u304C nearbyFood \u306E\u30AD\u30E3\u30C3\u30B7\u30E5\u304B\u3089\u6F0F\u308C\u3066\u3082\u3001\u8DDD\u96E2\u3068 roleMatches \u3067\u6700\u7D42\u5224\u5B9A\u3059\u308B\u3002
// 食料・水・薬は、生成直後/移動直後の spatial cache 漏れにも耐える。
// item type buckets が使える場合は role ごとの型だけを見ることで、
// crowded world で毎回 world.items 全体を足す経路を避ける。
if (foodLikeRole) {
if (Array.isArray(world?.items)) for (const it of world.items) addCandidate(it);
if (world?.itemsOfType) {
for (const type of foodRoleSearchTypes(role)) for (const it of world.itemsOfType(type) || []) addCandidate(it);
} else if (Array.isArray(world?.items)) {
for (const it of world.items) addCandidate(it);
}
}

View file

@ -223,7 +223,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3
tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72);
const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 };
const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) {
if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, fightAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}
@ -232,7 +232,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3
if (!panicAction) return false;
const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 };
const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) {
if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, panicAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}

View file

@ -3,10 +3,9 @@
// Need planning runtime and Tarinai prototype extensions.
// Targeting, ActionSpec definitions, consumables, social ticks, and building are split into dedicated modules.
if (typeof window !== "undefined") {
Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, buildReasonText, formatCauseActionText, forcedCausePhrase, refreshBehaviorReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findPrioritySleepPlaceBeforeGrassBedBuild, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyGroundStressModifier, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setBehaviorText, setBehavior, clearBehavior, behaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, forcedBehaviorQueueOf, currentForcedBehaviorRequest, actionPriority, forcedBehaviorSourcePriority, completeForcedBehavior, retryForcedBehavior, isLiveTarinaiEntity, actionById });
}
// This file keeps its local needs-loop helpers private; cross-file helpers come
// from their owning modules loaded earlier in index.html.
(function (global) {
function decayNeedSatisfaction(tarinai, dt = 0) {
if (!tarinai) return;
@ -32,24 +31,43 @@ function decayNeedShock(tarinai, dt = 0) {
function circadianSleepPhase(world) {
const p = world?.dayProgress?.() || 0;
const night = p >= 0.66 || p < 0.18;
const afternoon = p >= 0.46 && p < 0.58;
return { p, night, afternoon };
// Sleep pressure should visibly rise before dusk and fade around dawn.
// Keep the behavior controlled by need deltas rather than forcing sleep/wake
// at a fixed clock time. The steep increase begins before the evening
// phase, and sleeping reduces the pressure slowly enough to settle near dawn.
const sunsetHour = 17;
const sunriseHour = 5;
const highGrowthStart = Math.max(0, (sunsetHour - 2) / 24);
const decayStart = sunriseHour / 24;
const night = p >= highGrowthStart || p < decayStart;
const eveningRamp = p >= Math.max(0, highGrowthStart - 2 / 24) && p < highGrowthStart;
const afternoon = p >= 0.46 && p < Math.max(0.46, highGrowthStart - 2 / 24);
return { p, night, eveningRamp, afternoon, highGrowthStart, decayStart };
}
function updateCircadianSleepPressure(tarinai, world, dt = 0) {
const phase = circadianSleepPhase(world);
const sleeping = Boolean(tarinai.state === "sleep" || tarinai.sleeping);
if (!Number.isFinite(tarinai.circadianSleepPressure)) {
tarinai.circadianSleepPressure = phase.night ? rand(68, 88) : (phase.afternoon ? rand(8, 18) : rand(0, 5));
tarinai.circadianSleepPressure = phase.night ? rand(46, 64) : (phase.afternoon || phase.eveningRamp ? rand(8, 18) : rand(0, 5));
}
const step = Math.max(0, Number(dt) || 0);
if (phase.night) {
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 1.85, 0, 94);
if (sleeping) {
const target = phase.night ? 18 : 0;
const current = Number(tarinai.circadianSleepPressure || 0) || 0;
const extra = Math.max(0, current - target) * (phase.night ? 0.002 : 0.018);
const bedLike = tarinai.target && typeof tarinai.isSleepFurniture === "function" && tarinai.isSleepFurniture(tarinai.target);
const sleepDecay = (phase.night ? (bedLike ? 0.45 : 0.36) : (bedLike ? 2.15 : 1.45)) + extra;
tarinai.circadianSleepPressure = clamp(current - step * sleepDecay, 0, 94);
} else if (phase.night) {
const duskBoost = phase.p >= phase.highGrowthStart && phase.p < 0.66 ? 1.0 : 0;
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * (2.35 + duskBoost), 0, 94);
} else if (phase.eveningRamp) {
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.82, 0, 60);
} else if (phase.afternoon) {
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.16, 0, 26);
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.10, 0, 22);
} else {
const decay = (tarinai.state === "sleep" || tarinai.sleeping) ? 1.85 : 0.42;
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * decay, 0, 94);
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * 0.74, 0, 94);
}
return { ...phase, pressure: tarinai.circadianSleepPressure };
}
@ -93,7 +111,11 @@ function updateNeeds(tarinai, world, dt = 0) {
socialParts.bond = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16);
raw.social = socialParts.bond;
if (tarinai.parentToFollow?.()) { socialParts.family += 18; raw.social += 18; }
if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; }
const championMate = tarinai.isTarinaiChampion && (tarinai.reproductionTimer || 0) <= 8 && (world?.canAddTarinai?.(1) ?? true)
? (world?.nearestOther?.(tarinai, 780, other => other && other !== tarinai && !other.dead && other.isTarinaiChampion && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease && (other.reproductionTimer || 0) <= 10) || null)
: null;
if (championMate) { socialParts.mate += 48; socialParts.championMate = 1; raw.social += 48; }
else if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; }
const enemy = tarinai.strongestRelation?.("fear");
if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; }
const conflict = evaluateConflictUrge(tarinai, world, 112);
@ -271,6 +293,7 @@ function socialFightMateActionWeights(tarinai) {
fightWeight *= clamp(1 - Math.max(0, Number(parts.bond || 0) || 0) / 180, 0.55, 1.04);
if ((tarinai?.fightMochiTimer || 0) > 0.04) { fightWeight *= 3.4; mateWeight *= 0.55; }
if ((tarinai?.loveMochiTimer || 0) > 0.04) { mateWeight *= 3.15; fightWeight *= 0.58; }
if (parts.championMate) { mateWeight *= 4.0; fightWeight *= 0.35; }
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);
return { fightWeight, mateWeight, fightProbability: total > 0 ? fightWeight / total : 0.4 };
}
@ -566,9 +589,14 @@ function shouldWakeFromSleep(tarinai, world, needs) {
const foodNeed = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
if ((tarinai.hunger || 0) >= 84 || foodNeed >= needThreshold("food", "start")) return true;
const sleepNeed = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0;
if (sleepNeed <= ((typeof needThreshold === "function" ? needThreshold("sleep", "continue") : 34) + 4)) return true;
if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 74)) return true;
if (elapsed >= (Number(session.maxDuration) || 28)) return true;
const sleepContinue = (typeof needThreshold === "function" ? needThreshold("sleep", "continue") : 34);
const restedEnough = sleepNeed <= sleepContinue + 4;
if (restedEnough) return true;
// Energy can recover faster than the explicit sleep need, especially inside
// nest containers. Do not wake and immediately re-enter while the individual
// is still classified as sleepy.
if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 74) && sleepNeed <= sleepContinue + 14) return true;
if (elapsed >= (Number(session.maxDuration) || 28) && sleepNeed <= sleepContinue + 18) return true;
return false;
}
@ -588,7 +616,7 @@ function protectSleepSession(tarinai, world, needs) {
const comfort = bedLike ? Math.max(0.5, Math.min(1.4, Number(world?.bedComfort?.(tarinai.target) || tarinai.target?.comfort || 1) || 1)) : 0.55;
const energyMax = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100));
tarinai.energy = Math.min(energyMax, (Number(tarinai.energy || 0) || 0) + step * (bedLike ? (1.2 + comfort * 1.35) : 0.65));
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { sleep: -step * (bedLike ? (5.2 + comfort * 4.5) : 3.2), safety: bedLike ? -step * 0.9 : 0 });
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { sleep: -step * (bedLike ? (0.16 + comfort * 0.18) : 0.18), safety: bedLike ? -step * 0.9 : 0 });
tarinai.thought = tarinai.thought || "\u7720\u3063\u3066\u3044\u308b";
setBehavior(tarinai, { id: "sleep", need: "sleep", label: "\u7720\u3063\u3066\u3044\u308b", reason: "\u306d\u3080\u3044\u306e\u3067\u3001\u7720\u3063\u3066\u3044\u308b\u3002", text: "\u7720\u3063\u3066\u3044\u308b", state: "sleep", tiedNeeds: ["sleep"], startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration, phase: "perform" });
tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, 0.8);
@ -982,11 +1010,12 @@ function resolveNeedsCore(dt) {
const currentFelt = this.world?.feltTemperatureFor?.(this) ?? this.world?.temperatureAt?.(this.x, this.y, this) ?? this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15);
if (Number.isFinite(Number(currentFelt)) && Number(currentFelt) >= 22) return false;
if ((this.sunbathCooldown || 0) > 0) return false;
if (this.fightDisease) return false;
if (this.stuckPushpinId || this.currentLodgedPin?.()) return false;
if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_water", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false;
if (this.hunger > (options.leisure ? 62 : 72) || this.energy < (options.leisure ? 24 : 16)) return false;
const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0;
const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease;
const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease;
if (sick && healthNeed >= 34) return true;
return options.leisure ? healthNeed < needThreshold("health", "start") : healthNeed >= Math.max(38, needThreshold("health", "start") - 12);
},
@ -994,13 +1023,12 @@ function resolveNeedsCore(dt) {
tryRecoverBySunbath() {
if (this.sunbathRecoveryChecked) return false;
this.sunbathRecoveryChecked = true;
const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease;
if (!sick || Math.random() >= 0.75) return false;
const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease;
if (!sick || Math.random() >= 0.25) return false;
let healed = false;
if (this.zunchiDisease && this.recoverZunchiDisease) healed = this.recoverZunchiDisease("\u65e5\u5149\u6d74\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u3063\u305f") || healed;
if (this.sleepDisease && this.recoverSleepDisease) healed = this.recoverSleepDisease("\u65e5\u5149\u6d74\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f") || healed;
if (this.explosionDisease && this.recoverExplosionDisease) healed = this.recoverExplosionDisease("\u65e5\u5149\u6d74\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f") || healed;
if (this.fightDisease && this.recoverFightDisease) healed = this.recoverFightDisease("\u65e5\u5149\u6d74\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f") || healed;
if (healed) this.world?.log?.(`${this.name}\u306f\u65e5\u5149\u6d74\u3067\u75c5\u6c17\u304c\u8efd\u304f\u306a\u3063\u305f\u3002`, "event", { participants: [this] });
return healed;
},
@ -1013,11 +1041,13 @@ function resolveNeedsCore(dt) {
this.tryRecoverBySunbath();
this.sunbathCooldown = Math.max(this.sunbathCooldown || 0, rand(20, 38));
if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble("\u307d\u304b\u3063", 3.4, "rgba(112,86,36,0.78)");
this.sunbathLeisure = false;
return true;
},
startSunbath(options = {}) {
if (!this.canStartSunbath(options)) return false;
this.sunbathLeisure = Boolean(options.leisure);
const pad = Math.max(28, CONFIG.worldPadding || 30);
const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000);
const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720);
@ -1046,7 +1076,7 @@ function resolveNeedsCore(dt) {
this.sunbathRecoveryChecked = false;
this.vx = 0;
this.vy = 0;
setBehaviorText(this, { need: options.leisure ? "fulfill" : "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reason: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002" : "\u8abf\u5b50\u304c\u60aa\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", target: null, phase: "perform", source: "behavior" });
setBehaviorText(this, { need: options.leisure ? "fulfill" : "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reason: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002" : "\u8abf\u5b50\u304c\u60aa\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", target: null, phase: "perform", source: "behavior", causeText: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b" : "" });
if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("\u307d\u304b\u3063", 4.0, "rgba(112,86,36,0.78)");
return true;
},
@ -1158,3 +1188,9 @@ function resolveNeedsCore(dt) {
}
}));
})(typeof window !== "undefined" ? window : globalThis);
global.TarinaiNeedsRuntime = Object.freeze({
updateNeeds,
startNeedAction,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -8,7 +8,7 @@
const clampFn = typeof clamp === "function" ? clamp : ((v, a, b) => Math.max(a, Math.min(b, v)));
function isNestContainer(item) {
return Boolean(item && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
return Boolean(global.TarinaiToolRuntime?.isNestContainerItem?.(item, { alive: false }));
}
function isSleepFurniture(item) {
@ -23,13 +23,27 @@
return Boolean(worldRef.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65)));
}
function pipeItems(worldRef) {
return (worldRef?.items || []).filter(it => it && !it.dead && it.type === "pipe");
}
function tarinaiInsidePipeNetwork(worldRef, t) {
if (!worldRef || !t || t.dead || !t.insideNestBoxId) return false;
const holder = typeof worldRef.nestBoxById === "function" ? worldRef.nestBoxById(t.insideNestBoxId) : (worldRef.items || []).find(it => it && !it.dead && it.id === t.insideNestBoxId);
return holder?.type === "pipe";
}
function nestBoxCapacity(worldRef, box = null) {
return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : (box?.type === "pipe" ? 3 : 5);
if (typeof worldRef?.nestBoxCapacity === "function") return worldRef.nestBoxCapacity(box);
if (box?.type === "pipe") return Math.max(3, pipeItems(worldRef).length * 3);
return 5;
}
function nestBoxOccupants(worldRef, box, limit = 5) {
if (!worldRef || !box || box.dead || !isNestContainer(box)) return [];
const occupants = (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id);
const occupants = box.type === "pipe"
? (worldRef.tarinai || []).filter(t => tarinaiInsidePipeNetwork(worldRef, t))
: (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id);
return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants;
}
@ -41,11 +55,21 @@
return true;
}
function tarinaiTargetContainer(t) {
const target = t?.target || null;
return isNestContainer(target) ? target : (isNestContainer(target?.shelterItem) ? target.shelterItem : (isNestContainer(target?.hostItem) ? target.hostItem : null));
}
function tarinaiIsReservingNest(t, box, now = 0) {
if (!t || t.dead || !box || t.insideNestBoxId === box.id) return false;
if (t.target !== box) return false;
if (!t || t.dead || !box) return false;
if (box.type === "pipe" && t.insideNestBoxId && tarinaiInsidePipeNetwork(t.world || null, t)) return false;
if (box.type !== "pipe" && t.insideNestBoxId === box.id) return false;
const target = tarinaiTargetContainer(t);
if (box.type === "pipe") {
if (target?.type !== "pipe") return false;
} else if (target !== box) return false;
if (t._nestBoxAvoidId === box.id && now < (t._nestBoxAvoidUntil || -Infinity)) return false;
const seekingNest = t.state === "seek_bed" || t.state === "sleep" || t.sleeping || t.behavior?.actionId === "sleep_in_bed";
const seekingNest = t.state === "seek_bed" || t.state === "sleep" || t.sleeping || t.state === "seek_temperature" || t.behavior?.actionId === "sleep_in_bed";
if (!seekingNest) return false;
return true;
}
@ -83,12 +107,14 @@
function sleepPlaceAvailableFor(worldRef, tarinai, item) {
if (!worldRef || !tarinai || !item || item.dead || !isSleepFurniture(item)) return false;
if (Number(item.unreachableUntil || 0) > Number(worldRef.time || 0)) return false;
if (item.type === "nest_box" && globalThis.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(item)) return false;
if (item.abandonedById && item.abandonedById === tarinai?.id && Number(item.abandonedUntil || 0) > Number(worldRef.time || 0)) return false;
if (tarinai.shouldAvoidTarget?.(item)) return false;
if (worldRef?.sleepTargetTemperatureBlocked?.(tarinai, item)) return false;
if (sleepTargetBlocked(worldRef, tarinai, item)) return false;
const capacity = isNestContainer(item) ? nestBoxCapacity(worldRef, item) : 1;
const occupancy = bedOccupancy(worldRef, item);
if (tarinai?.isTarinaiChampion && item.type === "nest_box") return true;
return occupancy < capacity || tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id);
}
@ -154,14 +180,12 @@
}
global.TarinaiNestSleepSystem = Object.freeze({
isNestContainer,
isSleepFurniture,
isTarinaiHiddenInNestBox,
nestBoxOccupants,
bedOccupancy,
bedComfort,
sleepPlaceAvailableFor,
findStructure,
findOwnedStructure,
findUnownedStructure,
nearestSleepPlaceOfType,

View file

@ -1,5 +1,10 @@
"use strict";
function tarinaiSpriteReversesFacing(sprite) {
const id = String(sprite || "");
return id === "back" || id === "tarinai12_back" || /(^|_)12_back$/i.test(id);
}
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_render.js");
@ -279,9 +284,10 @@
const squish = this.state === "sleep"
? 1 + breathing * 0.018
: 1 + Math.sin(t * (4.5 + gait * 1.6) + this.age) * (0.012 + gait * 0.018) + headbuttPulse * 0.03;
const faceRight = this.state === "sleep"
let faceRight = this.state === "sleep"
? this.sleepFacing > 0
: this.facingDir() > 0;
if (tarinaiSpriteReversesFacing(renderSprite || sprite)) faceRight = !faceRight;
const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt;
const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1;
if (nestHideAlpha <= 0.01) return;

View file

@ -11,14 +11,20 @@
return (h >>> 0).toString(36);
}
function worldSeedFromWords(high = 0, low = 0) {
return `w${(Number(high) >>> 0).toString(16).padStart(8, "0")}${(Number(low) >>> 0).toString(16).padStart(8, "0")}`;
}
function createWorldSeed() {
const cryptoObj = global.crypto;
if (cryptoObj?.getRandomValues) {
const buf = new Uint32Array(2);
cryptoObj.getRandomValues(buf);
return `w${buf[0].toString(36)}${buf[1].toString(36)}`;
return worldSeedFromWords(buf[0], buf[1]);
}
return `w${Date.now().toString(36)}${Math.floor(Math.random() * 0xffffffff).toString(36)}`;
const high = Math.floor(Date.now() / 0x100000000) ^ Math.floor(Math.random() * 0xffffffff);
const low = (Date.now() >>> 0) ^ Math.floor(Math.random() * 0xffffffff);
return worldSeedFromWords(high, low);
}
function ensureWorldSeed(worldRef = null) {
@ -49,10 +55,9 @@
if (!next.birthSeed) {
worldRef.birthSerial = Math.max(0, Number(worldRef.birthSerial) || 0) + 1;
next.birthSerial = worldRef.birthSerial;
const parentSeeds = Array.isArray(next.seedParents)
? next.seedParents.map(p => typeof p === "string" ? p : (p?.birthSeed || p?.familyKey || p?.id || ""))
: [];
next.birthSeed = childSeed(worldRef.worldSeed, next.birthSerial, parentSeeds);
// Save schema 33 derives identity from worldSeed + birthSerial. Parentage is
// stored independently, so it must not be duplicated inside birthSeed.
next.birthSeed = childSeed(worldRef.worldSeed, next.birthSerial, []);
} else {
if (!Number.isFinite(next.birthSerial)) {
const parsedSerial = serialFromBirthSeed(next.birthSeed);
@ -142,6 +147,7 @@
global.TarinaiSeedFactory = Object.freeze({
createWorldSeed,
worldSeedFromWords,
ensureWorldSeed,
childSeed,
serialFromBirthSeed,

View file

@ -7,6 +7,12 @@ function mateTargetFor(t, world, maxDist = 520) {
if (!t || !world) return null;
const current = currentBehaviorTarget(t) || t.target;
const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04;
const championOk = o => ok(o) && !!t.isTarinaiChampion && !!o.isTarinaiChampion;
if (championOk(current) && dist(t, current) <= maxDist + 260) return current;
if (t.isTarinaiChampion) {
const champion = world.nearestOther?.(t, maxDist + 260, championOk) || null;
if (champion) return champion;
}
if (ok(current) && dist(t, current) <= maxDist + 120) return current;
return world.nearestOther?.(t, maxDist, ok) || null;
}
@ -45,12 +51,15 @@ function socialFightMateWeights(t, other, world, options = {}) {
&& (world.fightPairCooldownRemaining?.(t, other) || 0) <= 0.04
&& d <= Math.max(Number(options.fightRange || 58) || 58, (t.radius || 20) + (other.radius || 20) + 18);
const sameStatus = !!t.isZunchiSlave === !!other.isZunchiSlave;
const championPair = Boolean(t.isTarinaiChampion && other.isTarinaiChampion);
const mateWindow = Number.isFinite(Number(options.mateTimerWindow)) ? Number(options.mateTimerWindow) : 10;
const requireBirthReady = options.requireBirthReady !== false;
const timerOk = requireBirthReady ? ((t.reproductionTimer || 0) <= 0.04 && (other.reproductionTimer || 0) <= 0.04) : ((t.reproductionTimer || 0) <= mateWindow && (other.reproductionTimer || 0) <= mateWindow);
const mateDistanceOk = d <= Math.max(loveMochi ? 54 : 44, (t.radius || 20) + (other.radius || 20) + 14);
const enough = loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44);
const lonelyBond = loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24);
const enough = championPair
? ((t.hunger || 0) < 76 && (other.hunger || 0) < 78 && (t.energy || 0) > 30 && (other.energy || 0) > 30)
: (loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44));
const lonelyBond = championPair || (loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24));
const mateEligible = sameStatus
&& timerOk
&& mateDistanceOk
@ -73,6 +82,7 @@ function socialFightMateWeights(t, other, world, options = {}) {
fightWeight *= clamp(1 - affinity / 150, 0.34, 1.08);
fightWeight *= clamp(1 - fear / 170, fightMochi ? 0.72 : 0.42, 1.05);
fightWeight *= clamp(1.14 - Math.max(0, fearTrait - 1) * 0.16, 0.62, 1.22);
if (championPair) fightWeight *= 0.35;
if (options.mode === "conflict") fightWeight *= 1.35;
}
if (mateWeight > 0) {
@ -82,6 +92,7 @@ function socialFightMateWeights(t, other, world, options = {}) {
mateWeight *= 1 + Math.min(0.70, affinity / 140);
if (loveMochi) mateWeight *= 3.15;
if (fightMochi) mateWeight *= 0.52;
if (championPair) mateWeight *= 4.0;
if (options.mode === "mate") mateWeight *= 1.42;
}
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);
@ -223,7 +234,7 @@ function updatePanicBehavior(t, world, dt, needs) {
if (t.state !== "panic") t.setActionState?.("panic", { target: nest, reason: kingFleeReason, wake: true, sleeping: false });
t.fearTimer = Math.max(t.fearTimer || 0, 0.34);
if (dist(t, nest) <= Math.max(50, (t.radius || 20) + (nest.r || 24) + 18)) {
t.enterNestBox?.(nest, dt, { forceDisplace: true, reason: kingFleeReason });
t.enterNestBox?.(nest, dt, { forceDisplace: false, reason: kingFleeReason });
}
return true;
}

View file

@ -23,7 +23,7 @@
const TARINAI_KING_FLEE_REASON = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
function isNestContainerItem(item) {
return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
return Boolean(global.TarinaiToolRuntime?.isNestContainerItem?.(item));
}
function sleepContainerText(item, verb = "\u4f11\u3093\u3067\u3044\u308b") {
@ -56,6 +56,7 @@
if (d > maxDist) continue;
const padding = Math.max(16, (tarinai.radius || 20) * 0.72);
if (typeof worldRef.targetBlockedByObstacle === "function" && worldRef.targetBlockedByObstacle(tarinai, item, padding)) continue;
if (typeof worldRef.sleepPlaceAvailableFor === "function" && !worldRef.sleepPlaceAvailableFor(tarinai, item)) continue;
const occupants = typeof worldRef.nestBoxOccupants === "function" ? worldRef.nestBoxOccupants(item, Infinity) : [];
const capacity = typeof worldRef.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(item) : 5;
const fullnessPenalty = Math.max(0, occupants.length - capacity + 1) * 18;
@ -97,7 +98,7 @@
avoider.thought = TARINAI_KING_FLEE_REASON;
avoider.fearTimer = Math.max(avoider.fearTimer || 0, 1.05 * avoider.personalityProfile().fear);
if (nest && distXY(avoider.x, avoider.y, nest.x, nest.y) <= Math.max(50, (avoider.radius || 20) + (nest.r || 24) + 18)) {
avoider.enterNestBox?.(nest, dt, { forceDisplace: true, reason: TARINAI_KING_FLEE_REASON });
avoider.enterNestBox?.(nest, dt, { forceDisplace: false, reason: TARINAI_KING_FLEE_REASON });
}
return true;
}
@ -166,11 +167,15 @@
}
}
const zunchiSpreadChanceA = o.diseaseChance ? o.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1);
const zunchiTempMulA = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(o) : 1;
const zunchiSpreadBaseA = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * zunchiTempMulA;
const zunchiSpreadChanceA = o.diseaseChance ? o.diseaseChance(zunchiSpreadBaseA) : zunchiSpreadBaseA;
if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-a", zunchiSpreadChanceA, this, o)) {
if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
}
const zunchiSpreadChanceB = this.diseaseChance ? this.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1);
const zunchiTempMulB = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(this) : 1;
const zunchiSpreadBaseB = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * zunchiTempMulB;
const zunchiSpreadChanceB = this.diseaseChance ? this.diseaseChance(zunchiSpreadBaseB) : zunchiSpreadBaseB;
if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-b", zunchiSpreadChanceB, this, o)) {
if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
}
@ -237,8 +242,6 @@
if (typeof applyNeedShock === "function") applyNeedShock(o, { safety: 12, health: 6 });
const damageToThis = fightHeadbuttDamage(o, this, this.world, "to-this");
const damageToOther = fightHeadbuttDamage(this, o, this.world, "to-other");
this.world.emit?.("fight:hit", { attacker: o, target: this, amount: damageToThis, cause: "\u55a7\u5629" });
this.world.emit?.("fight:hit", { attacker: this, target: o, amount: damageToOther, cause: "\u55a7\u5629" });
this.damage(damageToThis, "\u55a7\u5629");
o.damage(damageToOther, "\u55a7\u5629");
this.world.maybeDefeatFromFightDamage?.(this, o, damageToThis);
@ -323,7 +326,7 @@
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) });
const sleepHungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1);
this.hunger = clamp(this.hunger + dt * 0.018 * sleepHungerScale, 0, 115);
this.hunger = clamp(this.hunger + dt * 0.018 * 0.75 * sleepHungerScale, 0, 115);
if (this.target && !this.target.dead && isNestContainerItem(this.target)) {
this.updateNestBoxPresence(dt);
this.vx = 0;
@ -468,7 +471,7 @@
if (this.target && !this.target.dead) {
const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target;
const routeStates = new Set(["seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "ant_attack", "seek_temperature"]);
const routeTargetItem = this.target?.hostItem || this.target;
const routeTargetItem = this.target?.shelterItem || this.target?.hostItem || this.target;
const pathPadding = Math.max(18, (this.radius || 20) * 0.82);
const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type;
const zundaFoodTarget = this.state === "seek_food" && (targetFoodType === "sweet" || targetFoodType === "zunda_juice");
@ -671,13 +674,20 @@
checkLife(dt = 0.016) {
let reason = "";
const activeDiseaseDeathReason = () => {
if (this.zunchiDisease) return "ずんち病";
if (this.sleepDisease) return "ねむり病";
if (this.explosionDisease) return "爆発病";
if (this.fightDisease) return "きずつき病";
return "";
};
if (this.energy <= 0) {
reason = this.temperatureDeathReason?.() || this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545";
reason = this.temperatureDeathReason?.() || activeDiseaseDeathReason() || this.dominantDeathCause("", { lowHealth: true }) || "事故";
}
else if (this.hunger >= 108) reason = "\u98e2\u9913";
else if (this.age >= this.lifeSpan) reason = "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f";
else if (this.hunger >= 108) reason = "飢餓";
else if (this.age >= this.lifeSpan) reason = "天寿を全うした";
else if (this.stress >= 124) {
reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a";
reason = activeDiseaseDeathReason() || this.dominantDeathCause("ストレス過多", { stressDeath: true }) || "ストレス過多";
}
if (reason) this.die(reason);
},
@ -692,7 +702,8 @@
if (!Array.isArray(this.world.recentDeathTimes)) this.world.recentDeathTimes = [];
this.world.recentDeathTimes.push(this.world.lastDeathAt);
if (this.world.recentDeathTimes.length > 24) this.world.recentDeathTimes = this.world.recentDeathTimes.slice(-24);
for (const item of this.world.items || []) {
const plushieSource = this.world.itemsOfType?.("plushie") || this.world.items || [];
for (const item of plushieSource) {
if (!item || item.dead || !item.isStructure || item.type !== "plushie" || item.ownerId !== this.id) continue;
item.hp = 0;
item.amount = 0;
@ -702,21 +713,19 @@
this.world.drawListDirty = true;
this.world.markItemBucketsDirty?.("tarinai-death-plushie-cleanup");
this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup");
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y));
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y));
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace");
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat");
if (deterministicChance(this.world, "death-grass-spawn", 0.55, this, finalReason)) {
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
if (spot) {
const deathGrass = new Item("grass", spot.x, spot.y);
if (this.world.addItem) this.world.addItem(deathGrass, "tarinai-death-grass");
else this.world.items.push(deathGrass);
this.world.addItem?.(deathGrass, "tarinai-death-grass");
}
}
this.world.markDead(this, finalReason);
this.releasedLiveToken = this.liveToken || 0;
this.world.releaseTarinaiLiveId?.(this);
audio.death();
this.world.emit?.("tarinai:died", { tarinai: this, reason: finalReason });
this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death", { participants: [this] });
if (this.world.selected === this) this.world.selected = null;
}

View file

@ -24,7 +24,7 @@
}
this.energy = clamp((this.energy || 0) + dt * 0.03, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
const felt = this.world?.feltTemperatureFor?.(this) ?? this.world?.temperatureAt?.(this.x, this.y, this) ?? this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15);
if (!this.isSunbathTime() || Number(felt) >= 22 || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0;
if (!this.isSunbathTime() || Number(felt) >= 22 || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.fightDisease || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0;
if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") {
this.finishSunbath?.();
this._activeSunbathSpriteId = "";

View file

@ -6,6 +6,7 @@
// every frame via the smooth-motion pass.
(function (global) {
const helpers = global.TarinaiSimulationRuntime.helpers;
const Geometry = global.TarinaiGeometry;
function behaviorOf(tarinai) {
return currentTarinaiBehavior(tarinai);
@ -17,7 +18,7 @@
}
function visible(worldRef, tarinai, visibleRect) {
return Boolean(helpers.pointInRect(tarinai.x || 0, tarinai.y || 0, visibleRect));
return Boolean(Geometry.pointInRect(tarinai.x || 0, tarinai.y || 0, visibleRect));
}
function hasMotionNeed(tarinai) {
@ -59,7 +60,7 @@
const lockedPassive = sleeping || state === "eat" || /eat|sunbath|birth_ritual|breed|repro/i.test(id) || /sunbath|birth_ritual|breed|repro/.test(state) || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.sunbathTimer || 0) > 0.04;
if (sleeping && isVisible && !worldRef?.isTarinaiHiddenInNestBox?.(tarinai)) return true;
if (lockedPassive && isVisible && activeMechanicalObstacleNear(tarinai, worldRef)) return true;
if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && !(typeof isNestContainerType === "function" ? isNestContainerType(tarinai.target.type) : (tarinai.target.type === "nest_box" || tarinai.target.type === "pipe"))) return true;
if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && !globalThis.TarinaiToolRuntime?.isNestContainerItem?.(tarinai.target, { alive: false })) return true;
return false;
}
@ -70,12 +71,14 @@
const id = behaviorId(tarinai);
const state = String(tarinai.state || id || "");
const count = (worldRef.tarinai || []).length;
const mobile = Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
const isVisible = visible(worldRef, tarinai, visibleRect);
const farRect = isVisible ? visibleRect : (nearVisibleRect || helpers.updateVisibleWorldRect(worldRef, 520));
const isNear = isVisible || Boolean(helpers.pointInRect(tarinai.x || 0, tarinai.y || 0, farRect));
const isNear = isVisible || Boolean(Geometry.pointInRect(tarinai.x || 0, tarinai.y || 0, farRect));
const motionNeed = hasMotionNeed(tarinai);
const perfProfile = global.TarinaiPerf?.performanceProfile?.();
if (perfProfile?.simulationThrottled === false) {
return { interval: 0, lane: "quality-high", smoothMotion: false, visible: isVisible, near: isNear, urgent: false };
}
const nestEntryActive = Boolean(tarinai.insideNestBoxId && (typeof tarinai.nestBoxEntryActive === "function" ? tarinai.nestBoxEntryActive() : ((worldRef.time || 0) < (tarinai._nestBoxEntrySmoothUntil || -Infinity) || ((tarinai.nestFade || 0) > 0.01 && (tarinai.nestFade || 0) < 0.985))));
if (nestEntryActive) return { interval: 0, lane: "nest-entry", smoothMotion: false, visible: true, urgent: true };
@ -94,7 +97,8 @@
// runs on visible skipped frames.
const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic/i.test(id);
if (panicUrgent) {
const interval = isVisible ? (mobile ? 0.13 : 0.08) : (isNear ? (mobile ? 0.24 : 0.18) : (mobile ? 0.46 : 0.36));
const baseInterval = isVisible ? 0.08 : (isNear ? 0.18 : 0.36);
const interval = baseInterval * Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().creatureCadenceScale || 1));
return { interval, lane: "panic", smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)), visible: isVisible, urgent: true };
}
@ -115,21 +119,17 @@
interval = isVisible ? 0.42 : (isNear ? 0.70 : 1.10);
} else if (/idle|none/.test(id) || state === "idle") {
lane = "idle";
if (isVisible) interval = count > 28 ? 0.16 : 0.12;
if (isVisible) interval = 0.12;
else interval = isNear ? 0.55 : 1.10;
} else if (isVisible) {
lane = "visible";
interval = count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05);
interval = 0.05;
} else {
lane = isNear ? "near" : "far";
interval = isNear ? 0.30 : 0.90;
}
if (Number.isFinite(interval) && !/birth|eat|sleep/.test(lane)) {
if (count > 54) interval *= 1.26;
else if (count > 38) interval *= 1.14;
}
if (mobile && Number.isFinite(interval)) interval *= count > 24 ? 1.35 : 1.18;
if (Number.isFinite(interval)) interval *= Math.max(1, Number(perfProfile?.creatureCadenceScale || 1));
const passivePhysicsNeed = hasPassivePhysicsNeed(tarinai, worldRef, isVisible);
const sleepPhysical = lane === "sleep" && passivePhysicsNeed;
const eatPhysical = lane === "eat" && passivePhysicsNeed;
@ -140,7 +140,7 @@
&& interval <= 0.75
&& !worldRef.isTarinaiHiddenInNestBox?.(tarinai)
&& ((smoothAllowed && motionNeed) || sleepPhysical || eatPhysical));
return { interval, lane, smoothMotion, visible: isVisible, urgent: false, sleepPhysical, eatPhysical, passivePhysicsNeed };
return { interval, lane, smoothMotion, visible: isVisible, near: isNear, urgent: false, sleepPhysical, eatPhysical, passivePhysicsNeed };
}
global.TarinaiCreatureUpdatePolicy = Object.freeze({

View file

@ -6,6 +6,13 @@
(function (global) {
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
if (t.playerHeld || t._heldByPlayer) {
t.aiTimer = 0;
t.target = null;
t.targetKey = "";
t.updateFacing?.();
return { done: false, held: true };
}
t.behaviorLockTimer = Math.max(0, (t.behaviorLockTimer || 0) - dt);
t.aiTimer += dt;
const urgentFoodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0;

View file

@ -52,7 +52,13 @@
applyNeedShock(t, { safety: dt * 4 });
if (t.state !== "fight") t.fearTimer = Math.max(t.fearTimer || 0, 0.10);
}
if (t.intimidateTimer <= 0.04) t.intimidateTargetId = null;
if (t.intimidateTimer <= 0.04) {
t.intimidateTargetId = null;
if (t.state === "intimidate" || t.state === "ant_intimidate") {
t.spriteLockUntil = 0;
t.setActionState?.("idle", { target: null, reason: "威嚇を終えた", clearTarget: true, sleeping: false });
}
}
if (t.birthRitualTimer > 0) {
const beforeBirthTimer = t.birthRitualTimer;
t.birthRitualTimer = Math.max(0, t.birthRitualTimer - dt);
@ -147,7 +153,7 @@
const tempStatus = t.temperatureStatus || t.world?.temperatureStatusFor?.(t.tempComfort ?? (CONFIG.standardTemperature ?? 15), t) || { discomfort: 0, stressExcess: 0, harmExcess: 0, direction: "comfort" };
const coldDiscomfort = tempStatus.direction === "cold" ? Math.max(0, tempStatus.discomfort || 0) : 0;
const hungerScale = t.metabolicHungerMultiplier ? t.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(t) : 1);
t.hunger += CONFIG.hungerPerMinute * minute * t.trait.hunger * hungerScale * (1 + coldDiscomfort * 0.018);
t.hunger += CONFIG.hungerPerMinute * minute * 0.75 * t.trait.hunger * hungerScale * (1 + coldDiscomfort * 0.018);
t.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - t.trait.social);
t.energy -= CONFIG.energyPerMinute * minute * t.trait.sleep * (t.state === "sleep" ? 0.025 : 1);
if (t.state === "sleep" || t.sleeping) {
@ -168,8 +174,16 @@
if ((tempStatus.harmExcess || 0) > 0) {
const harm = tempStatus.harmExcess || 0;
t.energy -= dt * (0.10 + harm * 0.075 + harm * harm * 0.006);
t.lastDamageCause = tempStatus.direction === "cold" ? "\u51CD\u7D50" : "\u71B1\u4E2D\u75C7";
t.lastTemperatureDamageAt = t.world?.time || 0;
const tempDamageCause = tempStatus.direction === "cold" ? "\u51CD\u7D50" : "\u71B1\u4E2D\u75C7";
const tempDamageAt = t.world?.time || 0;
t.lastDamageCause = tempDamageCause;
t.lastDamageAmount = Math.max(Number(t.lastDamageAmount || 0) || 0, harm);
t.lastDamageAt = tempDamageAt;
if (harm >= 1.5 || t.energy <= 30 || t.stress >= 104) {
t.lastMajorDamageCause = tempDamageCause;
t.lastMajorDamageAt = tempDamageAt;
}
t.lastTemperatureDamageAt = tempDamageAt;
}
const phase = t.world.lightLevel();

View file

@ -23,6 +23,10 @@
const delta = Math.hypot(after.vx - baseline.vx, after.vy - baseline.vy);
t._lastPhysicalVelocityX = after.vx;
t._lastPhysicalVelocityY = after.vy;
// A bounce fence is designed to launch rather than injure. Its deliberate
// velocity discontinuity must not be interpreted as generic collision
// shock damage on the following movement update.
if (now - Number(t.lastBounceFenceImpulseAt || -999) < 0.62) return false;
if (delta < SHOCK_DAMAGE_DELTA_THRESHOLD) return false;
if (now - (t.lastVelocityShockDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
if (now - (t.lastPhysicalCollisionDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
@ -89,7 +93,6 @@
t._sleepExternalWakeBubbleAt = now;
t.bubble("!", 0.55, "rgba(88,82,118,0.78)");
}
world?.emit?.("tarinai:woke", { tarinai: t, reason: "external-motion" });
return true;
}
@ -120,7 +123,7 @@
const state = String(t.state || "");
const sleepExternal = markSleepExternalMotion(t, dt, "passive-physical");
if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && !(typeof isNestContainerType === "function" ? isNestContainerType(t.target.type) : (t.target.type === "nest_box" || t.target.type === "pipe"))) {
if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && !globalThis.TarinaiToolRuntime?.isNestContainerItem?.(t.target, { alive: false })) {
const comfort = world?.bedComfort ? world.bedComfort(t.target) : 0.72;
const spot = t.sleepSpotFor ? t.sleepSpotFor(t.target) : t.target;
const dx = numeric(spot?.x, beforeX) - beforeX;
@ -223,6 +226,18 @@
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
if (t.playerHeld || t._heldByPlayer) {
t.vx = 0;
t.vy = 0;
t.impulseVx = 0;
t.impulseVy = 0;
t.target = null;
t.targetKey = "";
t.updateNestBoxPresence?.(dt);
t._lastPhysicalVelocityX = 0;
t._lastPhysicalVelocityY = 0;
return { done: false, held: true };
}
const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016));
if (context.motionOnly && /^(sleep|eat|sunbath|birth_ritual|breed|reproduction|intimidate|ant_intimidate)$/.test(String(t.state || ""))) {
return passivePhysicalMotion(t, motionDt, context);

View file

@ -36,11 +36,19 @@ const ui = {
soundCategoryControls: document.getElementById("soundPanel"),
toast: document.getElementById("toast"),
statPop: document.getElementById("statPop"),
statSick: document.getElementById("statSick"),
statJuvenile: document.getElementById("statJuvenile"),
statElder: document.getElementById("statElder"),
statDead: document.getElementById("statDead"),
statColonyMood: document.getElementById("statColonyMood"),
temperatureValue: document.getElementById("temperatureValue"),
temperatureSlider: document.getElementById("temperatureSlider"),
temperatureAutoToggle: document.getElementById("temperatureAutoToggle"),
tarinaiPopulationLimitSlider: document.getElementById("tarinaiPopulationLimitSlider"),
tarinaiPopulationLimitInput: document.getElementById("tarinaiPopulationLimitInput"),
objectLimitSlider: document.getElementById("objectLimitSlider"),
objectLimitInput: document.getElementById("objectLimitInput"),
colonyLimitStatus: document.getElementById("colonyLimitStatus"),
statGroundType: document.getElementById("statGroundType"),
groundTypeBtn: document.getElementById("groundTypeBtn"),
colonyChart: document.getElementById("colonyChart"),
@ -87,8 +95,11 @@ const uiCache = {
chartBucketStart: -Infinity,
chartBucketEnd: -Infinity,
chartBucketAccum: null,
chartBirthEventSeen: null,
chartDeathEventSeen: null,
lastChartDraw: "",
chartMode: "population",
chartHiddenSeries: new Set(),
canvasDpr: 0,
panning: false,
panLastX: 0,

View file

@ -6,7 +6,6 @@ function bindUI() {
syncLogPushControls();
syncAudioControls();
window.TarinaiTooltips.applyToolPalette();
window.bindGroundUI?.(world);
window.TarinaiSaveSystem.bindSaveSystem();
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
function isActiveToolMode() {
@ -155,6 +154,40 @@ function bindUI() {
ui.temperatureSlider?.addEventListener("input", applyTemperatureSlider);
ui.temperatureSlider?.addEventListener("change", applyTemperatureSlider);
const colonyLimitControls = (kind) => kind === "tarinai"
? { slider: ui.tarinaiPopulationLimitSlider, input: ui.tarinaiPopulationLimitInput }
: { slider: ui.objectLimitSlider, input: ui.objectLimitInput };
const syncColonyLimitControls = (kind, value) => {
const { slider, input } = colonyLimitControls(kind);
const limit = Math.max(0, Math.floor(Number(value || 0) || 0));
if (input && document.activeElement !== input) input.value = limit > 0 ? String(limit) : "";
if (slider && document.activeElement !== slider) {
const max = Math.max(0, Number(slider.max || 0) || 0);
slider.value = String(max > 0 ? Math.min(limit, max) : limit);
slider.setAttribute("aria-valuetext", limit > 0 ? String(limit) : "\u4e0a\u9650\u306a\u3057");
}
};
window.syncColonyLimitControls = syncColonyLimitControls;
const applyColonyLimit = (kind, source = "number") => {
if (!world) return;
const { slider, input } = colonyLimitControls(kind);
const sourceEl = String(source).startsWith("slider") ? slider : input;
if (!sourceEl) return;
const raw = sourceEl.value?.trim?.() === "" ? 0 : Number(sourceEl.value);
const value = Math.max(0, Math.floor(Number(raw) || 0));
if (kind === "tarinai") world.setTarinaiPopulationLimit?.(value);
else world.setObjectLimit?.(value);
syncColonyLimitControls(kind, value);
if (source !== "slider-live") audio.uiClick?.();
renderStats?.();
};
ui.tarinaiPopulationLimitSlider?.addEventListener("input", () => applyColonyLimit("tarinai", "slider-live"));
ui.tarinaiPopulationLimitSlider?.addEventListener("change", () => applyColonyLimit("tarinai", "slider"));
ui.objectLimitSlider?.addEventListener("input", () => applyColonyLimit("object", "slider-live"));
ui.objectLimitSlider?.addEventListener("change", () => applyColonyLimit("object", "slider"));
ui.tarinaiPopulationLimitInput?.addEventListener("change", () => applyColonyLimit("tarinai", "number"));
ui.objectLimitInput?.addEventListener("change", () => applyColonyLimit("object", "number"));
ui.panel?.addEventListener("scroll", () => {
const panel = ui.panel;
if (!panel || !ui.panelQuickTabs) return;
@ -323,6 +356,24 @@ function bindUI() {
uiCache.lastChartDraw = "";
renderStats();
});
const toggleChartSeries = (target) => {
const icon = target?.closest?.("i[data-series-key]");
if (!icon) return false;
const key = String(icon.dataset.seriesKey || "");
if (!key) return false;
if (!(uiCache.chartHiddenSeries instanceof Set)) uiCache.chartHiddenSeries = new Set();
if (uiCache.chartHiddenSeries.has(key)) uiCache.chartHiddenSeries.delete(key);
else uiCache.chartHiddenSeries.add(key);
uiCache.lastChartDraw = "";
audio.uiClick?.();
renderStats();
return true;
};
ui.colonyChartLegend?.addEventListener("click", (e) => toggleChartSeries(e.target));
ui.colonyChartLegend?.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
if (toggleChartSeries(e.target)) e.preventDefault();
});
ui.logPushEnabled?.addEventListener("change", () => {
uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked);
audio.notify?.();
@ -379,6 +430,7 @@ function bindUI() {
if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") {
uiCache.archiveAutoRefreshTimer = window.setInterval(() => {
if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return;
if (typeof lineageArchiveNeedsRender === "function" && !lineageArchiveNeedsRender()) return;
scheduleArchiveWindowRender();
}, 5000);
}
@ -533,6 +585,34 @@ function bindUI() {
showToast("\u5bb6\u7cfb\u56f3\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002");
});
const TOOL_SHORTCUTS = window.TarinaiItemToolMetadata?.TOOL_SHORTCUTS || {};
const TOOL_BY_SHORTCUT = Object.freeze(Object.fromEntries(Object.entries(TOOL_SHORTCUTS).map(([tool, key]) => [String(key), tool])));
const isKeyboardTypingTarget = (target) => {
const tag = String(target?.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || Boolean(target?.isContentEditable);
};
function activateControlTool(tool) {
if (!tool || !world) return false;
audio.uiClick?.();
if (tool === "undo" || tool === "redo") {
const result = window.TarinaiCommands.dispatch(world, { type: tool === "undo" ? "history.undo" : "history.redo" });
showToast(result?.ok ? (tool === "undo" ? "Undo" : "Redo") : (tool === "undo" ? "Nothing to undo" : "Nothing to redo"));
renderStats();
return true;
}
if (tool === "clear_tool") {
world.pendingLinkEndpoint = null;
world.copyBuffer = null;
selectTool("observe");
for (const b of uiCache.toolButtons || []) b.classList.remove("selected");
showToast("\u9053\u5177\u9078\u629E\u3092\u89E3\u9664\u3057\u307E\u3057\u305F\u3002");
return true;
}
if (world.tool === tool && cycleToolSize(tool)) return true;
selectTool(tool);
return true;
}
ui.toolPalette?.addEventListener("click", (e) => {
const toggle = e.target.closest(".tool-category-toggle");
if (toggle) {
@ -551,24 +631,16 @@ function bindUI() {
e.stopPropagation();
return;
}
const tool = btn.dataset.tool;
audio.uiClick?.();
if (tool === "undo" || tool === "redo") {
const result = window.TarinaiCommands.dispatch(world, { type: tool === "undo" ? "history.undo" : "history.redo" });
showToast(result?.ok ? (tool === "undo" ? "\u623b\u3057\u307e\u3057\u305f\u3002" : "\u9032\u3081\u307e\u3057\u305f\u3002") : (tool === "undo" ? "\u623b\u305b\u308b\u64cd\u4f5c\u304c\u3042\u308a\u307e\u305b\u3093\u3002" : "\u9032\u3081\u308b\u64cd\u4f5c\u304c\u3042\u308a\u307e\u305b\u3093\u3002"));
renderStats();
return;
}
if (tool === "clear_tool") {
world.pendingLinkEndpoint = null;
world.copyBuffer = null;
selectTool("observe");
for (const b of uiCache.toolButtons || []) b.classList.remove("selected");
showToast("\u9053\u5177\u9078\u629E\u3092\u89E3\u9664\u3057\u307E\u3057\u305F\u3002");
return;
}
if (world.tool === tool && cycleToolSize(tool)) return;
selectTool(tool);
activateControlTool(btn.dataset.tool);
});
window.addEventListener("keydown", (e) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
if (isKeyboardTypingTarget(e.target)) return;
const tool = TOOL_BY_SHORTCUT[String(e.key || "")];
if (!tool) return;
e.preventDefault();
activateControlTool(tool);
});
const normalizedWheelDeltaY = (e) => {

View file

@ -2,7 +2,7 @@
function colonyMoodTip() {
const mood = world?.evaluateColonyMood?.(true) || world?.colonyMood || null;
return String(mood?.description || "\u5927\u304d\u306a\u554f\u984c\u304c\u5c11\u306a\u304f\u3001\u843d\u3061\u7740\u3044\u305f\u72b6\u614b\u3067\u3059\u3002");
return String(mood?.description || "大きな問題が少なく、落ち着いた状態です。");
}
function bindColonyMoodTooltip() {
@ -14,24 +14,42 @@ function bindColonyMoodTooltip() {
window.TarinaiTooltips.bind(target, colonyMoodTip);
}
function colonyChartObjectKeys() {
return ["grass", "zunchi", "trace", "grass_bed", "water"];
}
function renderStats() {
const alive = world.tarinai;
const isDiseased = (t) => Boolean(t?.zunchiDisease || t?.sleepDisease || t?.explosionDisease || t?.fightDisease);
const lifeRatio = (t) => Math.max(0, Math.min(1, (Number(t?.age || 0) || 0) / Math.max(1, Number(t?.lifeSpan || 1) || 1)));
const sickCount = alive.reduce((n, t) => n + (isDiseased(t) ? 1 : 0), 0);
const juvenileCount = alive.reduce((n, t) => n + (lifeRatio(t) <= 0.10 ? 1 : 0), 0);
const elderCount = alive.reduce((n, t) => n + (lifeRatio(t) >= 0.80 ? 1 : 0), 0);
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const needAverages = Object.fromEntries(needKeys.map(key => [`need_${key}`, avg(t => Number(t.needs?.[key] || 0) || 0)]));
const temp = world.updateTemperature?.(0) ?? world.currentTemperature ?? (CONFIG.standardTemperature ?? 15);
const values = {
pop: alive.length,
sick: sickCount,
juvenile: juvenileCount,
elder: elderCount,
dead: world.deadCount,
birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0),
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
stress: avg(t => t.stress),
temperature: Number(temp) || 0,
...needAverages,
objects: { ...(world.itemCounts || {}) },
};
setTextIfChanged(ui.statPop, "pop", values.pop);
setTextIfChanged(ui.statSick, "sick", values.sick);
setTextIfChanged(ui.statJuvenile, "juvenile", values.juvenile);
setTextIfChanged(ui.statElder, "elder", values.elder);
setTextIfChanged(ui.statDead, "dead", values.dead);
const colonyMood = world.evaluateColonyMood?.() || world.colonyMood || { label: "\u5b89\u5b9a" };
setTextIfChanged(ui.statColonyMood, "colonyMood", colonyMood?.label || "\u5b89\u5b9a");
const temp = world.updateTemperature?.(0) ?? world.currentTemperature ?? (CONFIG.standardTemperature ?? 15);
const tempLabel = `${Math.round(temp)}\u2103`;
const colonyMood = world.evaluateColonyMood?.() || world.colonyMood || { label: "安定" };
setTextIfChanged(ui.statColonyMood, "colonyMood", colonyMood?.label || "安定");
const tempLabel = `${Math.round(temp)}℃`;
setTextIfChanged(ui.temperatureValue, "temperature", tempLabel);
if (ui.temperatureSlider) {
ui.temperatureSlider.disabled = world.temperatureAuto !== false;
@ -39,6 +57,17 @@ function renderStats() {
ui.temperatureSlider.setAttribute("aria-valuetext", tempLabel);
}
if (ui.temperatureAutoToggle) ui.temperatureAutoToggle.checked = world.temperatureAuto !== false;
const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0);
const objectLimit = Math.max(0, Number(world.objectLimit) || 0);
const objectCount = world.activeObjectCount?.() ?? (world.items || []).filter(item => item && !item.dead).length;
if (typeof window.syncColonyLimitControls === "function") {
window.syncColonyLimitControls("tarinai", tarinaiLimit);
window.syncColonyLimitControls("object", objectLimit);
} else {
if (ui.tarinaiPopulationLimitInput && document.activeElement !== ui.tarinaiPopulationLimitInput) ui.tarinaiPopulationLimitInput.value = tarinaiLimit > 0 ? String(tarinaiLimit) : "";
if (ui.objectLimitInput && document.activeElement !== ui.objectLimitInput) ui.objectLimitInput.value = objectLimit > 0 ? String(objectLimit) : "";
}
setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `たりない ${values.pop} / ${tarinaiLimit || "∞"} オブジェクト ${objectCount} / ${objectLimit || "∞"}`);
bindColonyMoodTooltip();
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
window.TarinaiGroundUI.update(world);
@ -53,14 +82,20 @@ function updateColonyHistory(values) {
const history = uiCache.chartHistory;
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
const maxPoints = 5 * 12;
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) {
const currentBirthEvents = Math.max(0, Number(values.birthEvents || 0) || 0);
const currentDeathEvents = Math.max(0, Number(values.deathEvents || 0) || 0);
const eventCountersRewound = (Number.isFinite(uiCache.chartBirthEventSeen) && currentBirthEvents < uiCache.chartBirthEventSeen)
|| (Number.isFinite(uiCache.chartDeathEventSeen) && currentDeathEvents < uiCache.chartDeathEventSeen);
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart) || eventCountersRewound) {
history.length = 0;
uiCache.chartBucketStart = -Infinity;
uiCache.chartBucketEnd = -Infinity;
uiCache.chartBucketAccum = null;
uiCache.chartBirthEventSeen = null;
uiCache.chartDeathEventSeen = null;
uiCache.lastChartDraw = "";
}
const objectKeys = ["grass", "zunchi", "trace", "grass_bed", "water"];
const objectKeys = colonyChartObjectKeys();
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const makeBucket = (start) => ({
start,
@ -68,10 +103,15 @@ function updateColonyHistory(values) {
count: 0,
sums: {
pop: 0,
sick: 0,
juvenile: 0,
elder: 0,
stress: 0,
temperature: 0,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
},
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
events: { births: 0, deaths: 0 },
});
if (!uiCache.chartBucketAccum) {
const start = Math.floor(t / interval) * interval;
@ -84,9 +124,15 @@ function updateColonyHistory(values) {
const row = {
t: bucket.end,
pop: bucket.sums.pop / bucket.count,
sick: bucket.sums.sick / bucket.count,
juvenile: bucket.sums.juvenile / bucket.count,
elder: bucket.sums.elder / bucket.count,
stress: bucket.sums.stress / bucket.count,
temperature: bucket.sums.temperature / bucket.count,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
births: Math.max(0, Number(bucket.events?.births || 0) || 0),
deaths: Math.max(0, Number(bucket.events?.deaths || 0) || 0),
};
history.push(row);
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
@ -99,25 +145,80 @@ function updateColonyHistory(values) {
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
}
const bucket = uiCache.chartBucketAccum;
if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = currentBirthEvents;
if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = currentDeathEvents;
bucket.events.births += Math.max(0, currentBirthEvents - uiCache.chartBirthEventSeen);
bucket.events.deaths += Math.max(0, currentDeathEvents - uiCache.chartDeathEventSeen);
uiCache.chartBirthEventSeen = currentBirthEvents;
uiCache.chartDeathEventSeen = currentDeathEvents;
bucket.count += 1;
bucket.sums.pop += values.pop || 0;
bucket.sums.sick += values.sick || 0;
bucket.sums.juvenile += values.juvenile || 0;
bucket.sums.elder += values.elder || 0;
bucket.sums.stress += values.stress || 0;
bucket.sums.temperature += Number(values.temperature || 0) || 0;
for (const k of needKeys) bucket.sums[`need_${k}`] += values[`need_${k}`] || 0;
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
}
function chartRows(values) {
const bucket = uiCache.chartBucketAccum;
const objectKeys = ["grass", "zunchi", "trace", "grass_bed", "water"];
// The colony chart is sampled in two-hour buckets. Do not append a live
// preview row here; it makes the graph redraw every frame even though the
// recorded statistics have not advanced yet.
if (uiCache.chartHistory.length) return uiCache.chartHistory.slice();
const objectKeys = colonyChartObjectKeys();
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const preview = bucket && bucket.count > 0 ? {
t: Math.max(world.time || 0, bucket.start || 0),
pop: bucket.sums.pop / bucket.count,
stress: bucket.sums.stress / bucket.count,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
} : null;
return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }];
return [{
t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0),
pop: 0,
sick: 0,
juvenile: 0,
elder: 0,
stress: 0,
temperature: 0,
births: 0,
deaths: 0,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])),
}];
}
function chartSeasonDayLabelForTime(t = 0) {
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
const dayNumber = Math.floor(Math.max(0, Number(t || 0) || 0) / dayLength) + 1;
const seasonIndex = Math.floor(((dayNumber - 1) % 20) / 5);
const season = ["春", "夏", "秋", "冬"][seasonIndex] || "春";
const seasonDay = ((dayNumber - 1) % 5) + 1;
return `${season}${seasonDay}日`;
}
function niceChartMax(raw = 0) {
const value = Math.max(1, Number(raw) || 0);
const pow = Math.pow(10, Math.floor(Math.log10(value)));
const unit = value / pow;
const nice = unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 5 ? 5 : 10;
return nice * pow;
}
function chartScaleForRows(mode, rows, series) {
if (mode === "life") return { min: 0, max: 100, percent: true };
const vals = [];
for (const s of series || []) for (const row of rows || []) vals.push(Number(row?.[s.key] || 0));
const finite = vals.filter(Number.isFinite);
const minRaw = Math.min(0, ...finite);
const maxRaw = Math.max(1, ...finite);
const max = niceChartMax(maxRaw);
const min = minRaw < 0 ? -niceChartMax(Math.abs(minRaw)) : 0;
return { min, max, percent: false };
}
function chartAxisLabel(value, scale) {
const n = Number(value) || 0;
if (scale?.percent) return String(Math.round(n));
if (Math.abs(n) >= 1000) return `${Math.round(n / 100) / 10}k`;
if (Math.abs(n) >= 10) return String(Math.round(n));
return String(Math.round(n * 10) / 10);
}
function drawColonyChart(values) {
@ -131,9 +232,13 @@ function drawColonyChart(values) {
}
const rows = chartRows(values);
const last = rows[rows.length - 1] || values;
const series = chartSeries(mode);
const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
const snapshot = `${mode}:${rows.length}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
const allSeries = chartSeries(mode);
const hiddenSeries = uiCache.chartHiddenSeries instanceof Set ? uiCache.chartHiddenSeries : (uiCache.chartHiddenSeries = new Set());
const series = allSeries.filter(s => !hiddenSeries.has(s.key));
const seriesSnapshot = allSeries.map(s => `${s.key}:${hiddenSeries.has(s.key) ? 0 : 1}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
const visibleDay = Math.floor(Math.max(0, Number(world.time || 0) || 0) / dayLength);
const snapshot = `${mode}:${rows.length}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
if (uiCache.lastChartDraw === snapshot) return;
uiCache.lastChartDraw = snapshot;
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
@ -141,18 +246,37 @@ function drawColonyChart(values) {
const pad = { l: 30, r: 12, t: 12, b: 24 };
const w = cssW - pad.l - pad.r;
const h = cssH - pad.t - pad.b;
const maxVal = Math.max(10, ...series.flatMap(s => rows.map(r => Number(r[s.key] || 0))));
const xFor = (i) => rows.length <= 1 ? pad.l + w : pad.l + (i / (rows.length - 1)) * w;
const scale = chartScaleForRows(mode, rows, series);
const times = rows.map(r => Math.max(0, Number(r.t || 0) || 0));
const minT = Math.min(...times, Math.max(0, Number(world.time || 0) || 0));
const maxT = Math.max(...times, Math.max(0, Number(world.time || 0) || 0), minT + 1);
const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, maxT - minT), 0, 1) * w;
const xFor = (i) => xForTime(rows[i]?.t ?? maxT);
const yFor = (row, s) => {
const raw = Number(row[s.key] || 0);
const scale = s.percent ? 100 : maxVal;
const v = clamp(raw / Math.max(scale, 1) * 100, 0, 100);
return pad.t + h - h * v / 100;
if (scale.percent) {
const v = clamp(raw / 100, 0, 1);
return pad.t + h - h * v;
}
const denom = Math.max(1, scale.max - scale.min);
const v = clamp((raw - scale.min) / denom, 0, 1);
return pad.t + h - h * v;
};
const grid = [0, 25, 50, 75, 100].map(yv => {
const y = pad.t + h - h * yv / 100;
return `<line x1="${pad.l}" y1="${y.toFixed(1)}" x2="${(pad.l + w).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(84,68,48,0.13)" stroke-width="1"/><text x="${pad.l - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(117,106,94,0.78)">${yv}</text>`;
const rawLabel = scale.percent ? yv : scale.min + (scale.max - scale.min) * yv / 100;
return `<line x1="${pad.l}" y1="${y.toFixed(1)}" x2="${(pad.l + w).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(84,68,48,0.13)" stroke-width="1"/><text x="${pad.l - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(117,106,94,0.78)">${chartAxisLabel(rawLabel, scale)}</text>`;
}).join("");
const firstDay = Math.ceil(minT / dayLength);
const lastDay = Math.floor(maxT / dayLength);
const dayMarkers = [];
for (let d = firstDay; d <= lastDay; d += 1) {
const t = d * dayLength;
if (t < minT - 0.001 || t > maxT + 0.001) continue;
const x = xForTime(t);
const label = chartSeasonDayLabelForTime(t);
dayMarkers.push(`<line x1="${x.toFixed(1)}" y1="${pad.t}" x2="${x.toFixed(1)}" y2="${(pad.t + h).toFixed(1)}" stroke="rgba(104,104,104,0.38)" stroke-width="1" stroke-dasharray="4 5"/><text x="${(x + 3).toFixed(1)}" y="${(pad.t + 11).toFixed(1)}" font-size="10" fill="rgba(92,92,92,0.76)">${label}</text>`);
}
const lines = series.map(s => {
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" ");
const last = rows[rows.length - 1];
@ -160,32 +284,43 @@ function drawColonyChart(values) {
const cy = yFor(last, s).toFixed(1);
return `<polyline points="${pts}" fill="none" stroke="${s.color}" stroke-width="${s.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${s.color}"/>`;
}).join("");
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">2\u6642\u9593\u3054\u3068 / 1\u9031\u9593</text></svg>`;
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span>`).join("");
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${dayMarkers.join("")}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">2時間ごと / 1週間</text></svg>`;
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = allSeries.map(s => {
const hidden = hiddenSeries.has(s.key);
return `<span class="${hidden ? "series-hidden" : ""}"><i role="button" tabindex="0" data-series-key="${s.key}" style="background:${s.color}" title="${hidden ? "表示する" : "非表示にする"}"></i>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span>`;
}).join("");
}
function chartSeries(mode = "population") {
if (mode === "life") return [
{ key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true },
{ key: "need_food", label: "\u6442\u990c", color: "#e4a33f", percent: true },
{ key: "need_sleep", label: "\u7761\u7720", color: "#6f8edc", percent: true },
{ key: "need_health", label: "\u5065\u5eb7", color: "#2e9d68", percent: true },
{ key: "need_safety", label: "\u5b89\u5168", color: "#d65e46", percent: true },
{ key: "need_social", label: "\u95a2\u4fc2", color: "#9b78d4", percent: true },
{ key: "need_fulfill", label: "\u5145\u8db3", color: "#0072b2", percent: true },
{ key: "stress", label: "ストレス", color: "#d96262", percent: true },
{ key: "need_food", label: "摂餌", color: "#e4a33f", percent: true },
{ key: "need_sleep", label: "睡眠", color: "#6f8edc", percent: true },
{ key: "need_health", label: "健康", color: "#2e9d68", percent: true },
{ key: "need_safety", label: "安全", color: "#d65e46", percent: true },
{ key: "need_social", label: "関係", color: "#9b78d4", percent: true },
{ key: "need_fulfill", label: "充足", color: "#0072b2", percent: true },
];
if (mode === "objects") return [
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f5a2f" },
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
{ key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" },
{ key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" },
{ key: "obj_grass", label: "草", color: "#5b9d61" },
{ key: "obj_zunchi", label: "ずんち", color: "#6f5a2f" },
{ key: "obj_trace", label: "死骸", color: "#8a7054" },
{ key: "obj_grass_bed", label: "かんたんベッド", color: "#78ff45" },
{ key: "obj_water", label: "雨滴", color: "#5aa8d6" },
{ key: "temperature", label: "気温", color: "#c46f3c" },
];
return [
{ key: "pop", label: "総数", color: "#5d8ee6" },
{ key: "sick", label: "病気", color: "#d96262" },
{ key: "juvenile", label: "幼体", color: "#78a642" },
{ key: "elder", label: "老体", color: "#8a7054" },
{ key: "births", label: "誕生数", color: "#d48ac2" },
{ key: "deaths", label: "死亡数", color: "#4d4d56" },
];
return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
}
function formatChartValue(key, values) {
const v = values[key] ?? 0;
if (key === "temperature") return `${Math.round(Number(v) || 0)}℃`;
return String(Math.round(v));
}

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