This commit is contained in:
33333-33333 2026-07-10 16:40:06 +09:00
commit 2636d580a8
75 changed files with 3890 additions and 1084 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. - 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. - 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.167`. - 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. - 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. - Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches.
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`. - Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.
@ -81,4 +81,3 @@ This section records current boundaries only. Version-by-version cleanup history
- Effect-specific spatial indexing, fuller delta-history storage, and high-speed collision partitioning. These need new indexes or semantics beyond cleanup. - 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`. - 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. - Further `World.prototype` reduction is possible, but only for helpers proven not to be runtime-facing canvas/UI/tool APIs.

View file

@ -1,156 +1,29 @@
tarinai_physics_tuning_v167 tarinai_compact_save_stage4
- Replaced the 8-sided circle template with a 24-sided, 20px-grid-aligned contour. - Save schema 33 omits standard item amounts and stores only an optional non-standard block base plus per-item differences.
- Raised bounce-fence restitution from 1.08 to 2.40 and increased minimum/capped launch speeds. - Relationship targets are sorted and delta-encoded within each Tarinai row.
- Made rope endpoint and particle constraints inextensible while retaining slack/flexibility. - World seeds use a canonical 64-bit hexadecimal identity and occupy exactly 8 bytes in the binary save.
- Set sticky-bomb blastScale to 0.68, matching the small firecracker. - 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_mechanical_collision_grid_limit_audit_v1 - Load confirmation uses the in-game dialog; fan wind is realtime; zunchi tool preview opacity, carried plushie depth, and blanket grass rules were corrected.
- Restored shared SAT geometry exports so collision-enabled rotators, reciprocators, and poison blocks physically interact with one another.
- Mechanical shape editors now snap all geometry to a coarse 20px lattice.
- Removed central colony-limit bypass options and added direct-insertion, capacity, save-roundtrip, and over-limit regression checks.
- Version: 39.15.167
tarinai_champion_limits_mechanical_defaults_v1 - Passive spring: removed time-driven oscillation; force now depends on extension/compression and relative endpoint velocity.
- Tarinai champions actively prioritize other champions as breeding partners, with stronger mate drive and shorter champion-pair cooldowns. - Signal effects use the exported TarinaiEffect constructor.
- Reciprocators now default to 90 degrees. - Pressure-switch placement/editor overlays display the configured detection radius.
- Poison blocks now use a smaller default footprint.
- Colony panel now supports optional tarinai-population and object-count limits; blank/0 means unlimited.
- Colony limits are saved in snapshot/binary data.
- Version: 39.15.165
tarinai_plushie_fling_speed_v1 - 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.
- Reduced plushie poke-fling velocity to one third.
- Lowered plushie fling minimum-speed normalization to the same one-third scale.
tarinai_sticky_bomb_plushie_stats_v1 - Earlier mechanism pass: stronger passive spring, persistent detector ranges, robot-cleaner signal power, and electric shock effect.
- Sticky bomb tooltip changed to the requested short description.
- Colony chart no longer appends the in-progress bucket as a live preview row, preventing rapid graph redraws between two-hour samples.
- Structure items now receive their own update method through the item dynamic dispatch, fixing plushie fling/offscreen destruction.
- Plushie poke fling speed/lifetime were strengthened and marked destroyOffscreen for both carried and uncarried plushies.
- Sticky bomb explosion delegates to World.explodeFirecracker; World.explodeFirecracker now accepts sticky_bomb as a compatible source.
tarinai_sticky_bomb_plushie_v1 - v39.16.17: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper.
- Added sticky_bomb as a hazard tool with timed explosion, carrier panic, and contact transfer.
- Changed poke behavior for carried plushies to fling them offscreen before destruction instead of immediate deletion.
- Removed the misleading no-explosion phrase from the flame-firecracker tooltip.
tarinai_grab_rain_death_reason_v1 - 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.
- Held Tarinai now zeroes velocity/impulses during pinch and skips AI/movement while player-held.
- Zunchi has a vector fallback sprite until its image asset is renderable.
- Poked carried plushies are destroyed instead of depending on offscreen fling removal.
- Rain streak overlay is visible during light_rain and no longer disappears entirely with transient effects disabled.
- Restored soil ground label as 砂 while keeping ground UI wording as 地面.
- Stale fire_seen panic text is cleared when no active fire/burning threat remains.
- Family tree death reason label now uses the same stored final reason as the event log.
tarinai_ground_edgegrass_duplicate_scan_v1 - 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.
- Unified ground wording for default ground / ground change log / ground change toast to 地面. - 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.
- Removed visual-only edge grass decorations from the cached background renderer.
- Duplicate-processing candidates found but not removed:
- Mechanical geometry helpers overlap across geometry_helpers.js, mechanical_shape_bridge.js, and mechanical_system.js.
- Item update scheduling helpers overlap between item_update_policy.js and item_update_scheduler.js.
- Sleep-place / nest-box search helpers overlap between tarinai_item_targeting.js and tarinai_nest_sleep_system.js.
- Physics bounce response block is repeated inside physics_helpers.js.
- Rotator / reciprocator editor close/apply scaffolding is repeated in world_placement_log.js.
- Drink/juice consumable handling has similar sound/need-relief blocks in tarinai_consumable_behavior.js.
- Family connected-component traversal logic appears in both family_graph.js and ui_family_async.js.
- No duplicate blocks were removed in this build.
tarinai_tool_fold_fan_area_delete_v1 - 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.
- Removed the over-broad collapsed-card min-height override and preserved folded tool-card header height.
- Updated fan tooltip copy from water balloons/balls to lightweight items.
- Changed area-delete to its own text icon instead of reusing the normal delete icon.
tarinai_right_ui_restore_v1
- Restored right-panel/tool/collapsible CSS to the stable pre-scroll behavior.
- Removed the later generic scroll clamps from panel-card bodies/tool palette/right UI.
- Kept scrollability only for in-game overlay/editor windows that can exceed the viewport.
tarinai_right_panel_scroll_fix_v1
- Removed generic max-height/overflow clamp from panel-card bodies.
- Restored the right panel as the scroll container.
- Preserved internal tool-palette scrolling and sticky operation category.
- Ensured collapsed right-panel cards keep a full visible header.
tarinai_tool_ui_fix_v1
- Stabilized folded tool-card height when other UI cards are expanded.
- Kept the operation category sticky while fixing display glitches inside the tool palette scroller.
- Kept tool-palette scrolling while preventing global panel-body height limits from compressing the tool card.
tarinai_event_ui_king_scroll_v1
- Hidden robot-cleaner and player/tool operation records from the event log and observation records.
- Reduced visual settings to OFF/ON for effects/shadows and low/high for details.
- Updated family/nest descriptions and implemented champion-only nest displacement during sleep.
- Strengthened flame-firecracker size scaling.
- Shortened intimidation timers.
- Added overflow scrolling safeguards for fixed UI and panel bodies.
tarinai_stats_cleaning_flame_intimidation_v1
- Colony chart day markers now use season-day labels such as 春2日.
- Robot cleaner deaths normalize to 掃除.
- Flame firecracker fire amount/count/range now scale with item size.
- Object chart label changed to 環境; population/environment y-axis labels use data max instead of fixed 100.
- Environment chart includes temperature.
- Intimidation state is cleared when its timer ends and timer ranges are shortened.
tarinai_tooltip_stats_disease_hunger_v1
- Tarinai canvas tooltip width now adapts to its four fixed lines.
- Colony charts draw dotted gray daily 0:00 markers with day labels.
- Orphan plushies with no live owner are removed.
- Fight disease blocks sunbathing and sunbath curing.
- Disease-active low-health/stress deaths prefer disease cause labels.
- Natural hunger increase is multiplied by 0.75 in awake and sleep paths.
tarinai_family_routes_v39_15_124
- Clamped above-generation lineage detours inside the SVG viewport.
- Allowed lineage edges to attach to non-center points on node sides when detouring.
- Removed fight win-rate text from family-tree nodes.
tarinai_tooltip_family_routing_v1
- Tarinai hover tooltip is fixed to 4 logical lines: name, behavior, HP/hunger/stress, felt temperature.
- Family tree partner/child link routing now detours around unrelated node cards when a direct horizontal partner line would overlap a node.
tarinai_pipe_shadow_temperature_shelter_v1
- Moved pipe shadow down to the pipe-ground contact area and enlarged it slightly.
- Temperature comfort seeking now considers thermally comfortable nest boxes and pipes.
- Tarinai can enter selected nest/pipe shelters for temperature relief.
tarinai_pipe_layer_fan_sunbath_v1
- Pipe tooltip wording now uses 土管, not 全土管/土管全部.
- Pipe shadow is moved to the ground-contact mouth area and enlarged slightly.
- Render layer sorting uses bottom-edge/ground-contact Y instead of center Y.
- Fan wind lowers felt temperature by up to 5C.
- Sunbath disease recovery chance is 25%.
tarinai_pipe_visual_tooltip_v1
- Updated water-balloon / pipe tool descriptions in the tool UI.
- Touching toys (ball / water balloon) can slightly increase openness over time.
- Pipe visuals are revised to read as a pipe embedded in the ground instead of a jar.
- Pipe tooltip occupant listing is reinforced by aggregating live occupants across all pipes.
tarinai_pipe_tooltip_temp_raindrop_v1
- Pipe tooltips list tarinai inside all pipes instead of only the hovered pipe.
- Fully hidden tarinai inside a nest box or pipe are skipped by direct tarinai tooltips.
- Pipe interior temperature correction now moves toward 20C by at most +/-5C.
- Water-balloon splash drops are created through the same rain-water item initializer used by rain.
- Version: 39.15.136
tarinai_pipe_tooltip_temp_v1
- Pipe tooltips list tarinai inside all pipes instead of only the hovered pipe.
- Fully hidden tarinai inside a nest box or pipe are skipped by direct tarinai tooltips.
- Pipe interior temperature correction now moves toward 20C by at most +/-5C.
- Version: 39.15.117
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.117
tarinai_king_fix_v10
- Fire tool button hidden; flames spawn only from flame firecracker.
- Burning tarinai and ants draw lightweight flame-shaped overlays.
- Flame firecracker is redder and shows a countdown timer; normal firecracker is darker.
- Standalone flame and fire particles use a simpler flame silhouette.
- Version: 39.15.102

View file

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

View file

@ -1,8 +1,116 @@
## 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 # 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. 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.15.167 mechanical interaction and limit audit ## v39.16.0 mechanical interaction and limit audit
- 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。 - 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。
- 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。 - 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。

View file

@ -13,7 +13,8 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
## Runtime Shape ## Runtime Shape
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.167`. - 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. - 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. - 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`. - UI loop: pointer/tool/UI intents go through `command_dispatcher.js`; canvas drawing is in render modules; side panels are `ui_*.js`.
@ -51,3 +52,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- Syntax: `node --check js/*.js` - Syntax: `node --check js/*.js`
- Regression: `python scripts/regression_check.py` - Regression: `python scripts/regression_check.py`
- Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All` - Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All`
### 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.167", "version": "39.16.18",
"css": [ "css": [
"css/base.css", "css/base.css",
"css/layout.css", "css/layout.css",
@ -36,6 +36,7 @@
"js/constraint_system.js", "js/constraint_system.js",
"js/physics_world_system.js", "js/physics_world_system.js",
"js/physics_projection_system.js", "js/physics_projection_system.js",
"js/signal_system.js",
"js/assets.js", "js/assets.js",
"js/audio.js", "js/audio.js",
"js/perf_profiler.js", "js/perf_profiler.js",

View file

@ -619,6 +619,32 @@ h1 {
background: rgba(255,255,255,0.84); background: rgba(255,255,255,0.84);
padding: 4px 6px; 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 { .rotator-editor-tools {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -728,3 +754,7 @@ h1 {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
box-sizing: border-box; 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,6 +790,22 @@
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.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;
}
/* Collapsed event card must shrink to its header. /* Collapsed event card must shrink to its header.
layout.css gives .log-card a min-height, which left a large blank panel when folded. */ layout.css gives .log-card a min-height, which left a large blank panel when folded. */
.log-card.collapsed { min-height: 0 !important; height: auto !important; flex: 0 0 auto !important; flex-basis: auto !important; } .log-card.collapsed { min-height: 0 !important; height: auto !important; flex: 0 0 auto !important; flex-basis: auto !important; }
@ -845,9 +861,11 @@
font-size: 12px; font-size: 12px;
} }
.colony-limit-head small { color: var(--muted); font-size: 10px; } .colony-limit-head small { color: var(--muted); font-size: 10px; }
.colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 8px; } .colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 8px; }
.colony-limit-grid label { display: grid; gap: 4px; min-width: 0; font-size: 11px; color: var(--muted); } .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-grid input { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid rgba(100, 78, 52, 0.22); border-radius: 8px; padding: 6px 7px; background: rgba(255,255,255,0.78); color: var(--ink); font: inherit; } .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; } .colony-limit-status { margin-top: 7px; color: rgba(68, 55, 41, 0.78); font-size: 10px; line-height: 1.35; }

View file

@ -351,12 +351,18 @@ body::before {
white-space: nowrap; white-space: nowrap;
} }
.colony-chart-legend i { .colony-chart-legend i {
width: 8px; width: 10px;
height: 8px; height: 10px;
border-radius: 999px; border-radius: 999px;
display: inline-block; display: inline-block;
box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10); 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. */ /* Family tree personality tags: consolidated final cascade. */

View file

@ -4,14 +4,14 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title> <title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.15.167" /> <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.15.167" /> <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.15.167" /> <link rel="shortcut icon" href="favicon.ico?v=39.16.18" />
<link rel="stylesheet" href="css/base.css?v=39.15.167" /> <link rel="stylesheet" href="css/base.css?v=39.16.18" />
<link rel="stylesheet" href="css/layout.css?v=39.15.167" /> <link rel="stylesheet" href="css/layout.css?v=39.16.18" />
<link rel="stylesheet" href="css/panel.css?v=39.15.167" /> <link rel="stylesheet" href="css/panel.css?v=39.16.18" />
<link rel="stylesheet" href="css/components.css?v=39.15.167" /> <link rel="stylesheet" href="css/components.css?v=39.16.18" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.167" /> <link rel="stylesheet" href="css/mobile.css?v=39.16.18" />
</head> </head>
<body> <body>
<div id="loadingScreen" class="loading-screen" aria-live="polite"> <div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -108,10 +108,10 @@
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10"> <input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
</div> </div>
<div class="colony-limit-control" aria-label="コロニー上限設定"> <div class="colony-limit-control" aria-label="コロニー上限設定">
<div class="colony-limit-head"><span>上限設定</span><small>空欄・0は上限なし</small></div> <div class="colony-limit-head"><span>上限設定</span><small>0は上限なし</small></div>
<div class="colony-limit-grid"> <div class="colony-limit-grid">
<label><span>たりない個体数</span><input id="tarinaiPopulationLimitInput" type="number" min="0" step="1" inputmode="numeric" placeholder="上限なし"></label> <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><span>オブジェクト総数</span><input id="objectLimitInput" type="number" min="0" step="1" inputmode="numeric" placeholder="上限なし"></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>
<div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div> <div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div>
</div> </div>
@ -194,7 +194,7 @@
<div id="visualSettingsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="visualSettingsDialogTitle"> <div id="visualSettingsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="visualSettingsDialogTitle">
<div class="field-dialog-panel visual-settings-dialog-panel"> <div class="field-dialog-panel visual-settings-dialog-panel">
<h2 id="visualSettingsDialogTitle">&#x8868;&#x793A;&#x8A2D;&#x5B9A;</h2> <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 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><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;"> <div class="visual-setting-options" role="group" aria-label="&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;">
@ -212,6 +212,7 @@
<div class="visual-setting-row"> <div class="visual-setting-row">
<div><strong>&#x63CF;&#x753B;</strong><span>&#x88C5;&#x98FE;&#x3068;&#x89E3;&#x50CF;&#x5EA6;&#x306E;&#x8A2D;&#x5B9A;</span></div> <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;"> <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="low">&#x4F4E;</button>
<button type="button" data-visual-setting="details" data-visual-value="high">&#x9AD8;</button> <button type="button" data-visual-setting="details" data-visual-value="high">&#x9AD8;</button>
</div> </div>
@ -260,167 +261,168 @@
</div> </div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div> <div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.15.167" defer></script> <script src="js/version.js?v=39.16.18" defer></script>
<script src="js/event_bus.js?v=39.15.167" defer></script> <script src="js/event_bus.js?v=39.16.18" defer></script>
<script src="js/domain_ids.js?v=39.15.167" defer></script> <script src="js/domain_ids.js?v=39.16.18" defer></script>
<script src="js/registry_base.js?v=39.15.167" defer></script> <script src="js/registry_base.js?v=39.16.18" defer></script>
<script src="js/disease_registry.js?v=39.15.167" defer></script> <script src="js/disease_registry.js?v=39.16.18" defer></script>
<script src="js/sound_pack.js?v=39.15.167" defer></script> <script src="js/sound_pack.js?v=39.16.18" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.167" defer></script> <script src="js/deterministic_helpers.js?v=39.16.18" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.167" defer></script> <script src="js/item_tool_metadata.js?v=39.16.18" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.167" defer></script> <script src="js/item_tool_definitions.js?v=39.16.18" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.167" defer></script> <script src="js/item_visual_definitions.js?v=39.16.18" defer></script>
<script src="js/item_food_definitions.js?v=39.15.167" defer></script> <script src="js/item_food_definitions.js?v=39.16.18" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.167" defer></script> <script src="js/item_effect_definitions.js?v=39.16.18" defer></script>
<script src="js/item_registry.js?v=39.15.167" defer></script> <script src="js/item_registry.js?v=39.16.18" defer></script>
<script src="js/data.js?v=39.15.167" defer></script> <script src="js/data.js?v=39.16.18" defer></script>
<script src="js/ground_types.js?v=39.15.167" defer></script> <script src="js/ground_types.js?v=39.16.18" defer></script>
<script src="js/input_mode_manager.js?v=39.15.167" defer></script> <script src="js/input_mode_manager.js?v=39.16.18" defer></script>
<script src="js/math.js?v=39.15.167" defer></script> <script src="js/math.js?v=39.16.18" defer></script>
<script src="js/geometry_helpers.js?v=39.15.167" defer></script> <script src="js/geometry_helpers.js?v=39.16.18" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.167" defer></script> <script src="js/collision_footprint_system.js?v=39.16.18" defer></script>
<script src="js/physics_helpers.js?v=39.15.167" defer></script> <script src="js/physics_helpers.js?v=39.16.18" defer></script>
<script src="js/placement_preview_system.js?v=39.15.167" defer></script> <script src="js/placement_preview_system.js?v=39.16.18" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.167" 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.15.167" defer></script> <script src="js/physics_shape_editor_system.js?v=39.16.18" defer></script>
<script src="js/mechanical_system.js?v=39.15.167" defer></script> <script src="js/mechanical_system.js?v=39.16.18" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.167" defer></script> <script src="js/mechanical_shape_bridge.js?v=39.16.18" defer></script>
<script src="js/constraint_system.js?v=39.15.167" defer></script> <script src="js/constraint_system.js?v=39.16.18" defer></script>
<script src="js/physics_world_system.js?v=39.15.167" defer></script> <script src="js/physics_world_system.js?v=39.16.18" defer></script>
<script src="js/physics_projection_system.js?v=39.15.167" defer></script> <script src="js/physics_projection_system.js?v=39.16.18" defer></script>
<script src="js/assets.js?v=39.15.167" defer></script> <script src="js/signal_system.js?v=39.16.18" defer></script>
<script src="js/audio.js?v=39.15.167" defer></script> <script src="js/assets.js?v=39.16.18" defer></script>
<script src="js/perf_profiler.js?v=39.15.167" defer></script> <script src="js/audio.js?v=39.16.18" defer></script>
<script src="js/display_helpers.js?v=39.15.167" defer></script> <script src="js/perf_profiler.js?v=39.16.18" defer></script>
<script src="js/render.js?v=39.15.167" defer></script> <script src="js/display_helpers.js?v=39.16.18" defer></script>
<script src="js/sim_core.js?v=39.15.167" defer></script> <script src="js/render.js?v=39.16.18" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.167" defer></script> <script src="js/sim_core.js?v=39.16.18" defer></script>
<script src="js/structures.js?v=39.15.167" defer></script> <script src="js/tarinai_seed_factory.js?v=39.16.18" defer></script>
<script src="js/item_type_catalog.js?v=39.15.167" defer></script> <script src="js/structures.js?v=39.16.18" defer></script>
<script src="js/items.js?v=39.15.167" defer></script> <script src="js/item_type_catalog.js?v=39.16.18" defer></script>
<script src="js/item_type_initializers.js?v=39.15.167" defer></script> <script src="js/items.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.167" defer></script> <script src="js/item_type_initializers.js?v=39.16.18" defer></script>
<script src="js/item_update_policy.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_support.js?v=39.16.18" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.167" defer></script> <script src="js/item_update_policy.js?v=39.16.18" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.167" defer></script> <script src="js/fire_runtime_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.167" defer></script> <script src="js/robot_cleaner_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.167" defer></script> <script src="js/item_dynamic_tool_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.167" defer></script> <script src="js/item_dynamic_ball_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.167" defer></script> <script src="js/item_dynamic_duplicator_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.167" defer></script> <script src="js/item_dynamic_pin_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.167" defer></script> <script src="js/item_dynamic_zunchi_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.167" defer></script> <script src="js/item_dynamic_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_decay_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_growth_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_step_frame.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_step_decay.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_step_dynamic.js?v=39.16.18" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_step_growth.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.167" defer></script> <script src="js/update_step_pipeline_runner.js?v=39.16.18" defer></script>
<script src="js/item_runtime.js?v=39.15.167" defer></script> <script src="js/item_lifecycle_pipeline.js?v=39.16.18" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.167" defer></script> <script src="js/item_runtime.js?v=39.16.18" defer></script>
<script src="js/item_render_helpers.js?v=39.15.167" defer></script> <script src="js/structure_lifecycle.js?v=39.16.18" defer></script>
<script src="js/item_render_runtime.js?v=39.15.167" defer></script> <script src="js/item_render_helpers.js?v=39.16.18" defer></script>
<script src="js/burn_motion_util.js?v=39.15.167" defer></script> <script src="js/item_render_runtime.js?v=39.16.18" defer></script>
<script src="js/ants.js?v=39.15.167" defer></script> <script src="js/burn_motion_util.js?v=39.16.18" defer></script>
<script src="js/health.js?v=39.15.167" defer></script> <script src="js/ants.js?v=39.16.18" defer></script>
<script src="js/tarinai.js?v=39.15.167" defer></script> <script src="js/health.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.167" defer></script> <script src="js/tarinai.js?v=39.16.18" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.167" defer></script> <script src="js/tarinai_action_spec.js?v=39.16.18" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.167" defer></script> <script src="js/tarinai_behavior_state.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.167" defer></script> <script src="js/tarinai_identity_social.js?v=39.16.18" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.167" defer></script> <script src="js/tarinai_action_state.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.167" defer></script> <script src="js/tarinai_disease_nest.js?v=39.16.18" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.167" defer></script> <script src="js/tarinai_item_effects.js?v=39.16.18" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.167" defer></script> <script src="js/tarinai_needs_core.js?v=39.16.18" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.167" defer></script> <script src="js/tarinai_behavior_text.js?v=39.16.18" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.167" defer></script> <script src="js/tarinai_forced_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.167" defer></script> <script src="js/tarinai_nest_sleep_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.167" defer></script> <script src="js/tarinai_item_targeting.js?v=39.16.18" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.167" defer></script> <script src="js/tarinai_consumable_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.167" defer></script> <script src="js/tarinai_social_action_runtime.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.167" defer></script> <script src="js/tarinai_building_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.167" defer></script> <script src="js/tarinai_action_definitions.js?v=39.16.18" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.167" defer></script> <script src="js/tarinai_needs_items.js?v=39.16.18" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.167" defer></script> <script src="js/tarinai_food_prototype_mixin.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.167" defer></script> <script src="js/tarinai_need_planner_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.167" defer></script> <script src="js/tarinai_item_interaction_context.js?v=39.16.18" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.167" defer></script> <script src="js/tarinai_food_interaction_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.167" defer></script> <script src="js/tarinai_contact_item_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.167" defer></script> <script src="js/tarinai_item_interaction_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.167" defer></script> <script src="js/tarinai_sunbath_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.167" defer></script> <script src="js/tarinai_cursor_care_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.167" defer></script> <script src="js/tarinai_local_environment_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.167" defer></script> <script src="js/tarinai_social_move_life.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.167" defer></script> <script src="js/tarinai_update_step_frame.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.167" defer></script> <script src="js/tarinai_update_step_ai.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.167" defer></script> <script src="js/tarinai_update_step_environment.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.167" defer></script> <script src="js/tarinai_update_step_movement.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.167" defer></script> <script src="js/tarinai_update_step_health.js?v=39.16.18" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.167" defer></script> <script src="js/tarinai_update_pipeline.js?v=39.16.18" defer></script>
<script src="js/tarinai_render.js?v=39.15.167" defer></script> <script src="js/tarinai_runtime.js?v=39.16.18" defer></script>
<script src="js/world.js?v=39.15.167" defer></script> <script src="js/tarinai_render.js?v=39.16.18" defer></script>
<script src="js/world_view.js?v=39.15.167" defer></script> <script src="js/world.js?v=39.16.18" defer></script>
<script src="js/family_graph.js?v=39.15.167" defer></script> <script src="js/world_view.js?v=39.16.18" defer></script>
<script src="js/world_reset_presets.js?v=39.15.167" defer></script> <script src="js/family_graph.js?v=39.16.18" defer></script>
<script src="js/world_family_social.js?v=39.15.167" defer></script> <script src="js/world_reset_presets.js?v=39.16.18" defer></script>
<script src="js/impact_core_system.js?v=39.15.167" defer></script> <script src="js/world_family_social.js?v=39.16.18" defer></script>
<script src="js/impact_response_system.js?v=39.15.167" defer></script> <script src="js/impact_core_system.js?v=39.16.18" defer></script>
<script src="js/collision_response_system.js?v=39.15.167" defer></script> <script src="js/impact_response_system.js?v=39.16.18" defer></script>
<script src="js/world_combat_effects.js?v=39.15.167" defer></script> <script src="js/collision_response_system.js?v=39.16.18" defer></script>
<script src="js/world_environment.js?v=39.15.167" defer></script> <script src="js/world_combat_effects.js?v=39.16.18" defer></script>
<script src="js/world_temperature_system.js?v=39.15.167" defer></script> <script src="js/world_environment.js?v=39.16.18" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.167" defer></script> <script src="js/world_temperature_system.js?v=39.16.18" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.167" defer></script> <script src="js/world_pathfinding_system.js?v=39.16.18" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.167" defer></script> <script src="js/world_grass_placement_system.js?v=39.16.18" defer></script>
<script src="js/world_ants_system.js?v=39.15.167" defer></script> <script src="js/world_spatial_budget.js?v=39.16.18" defer></script>
<script src="js/weather_system.js?v=39.15.167" defer></script> <script src="js/world_ants_system.js?v=39.16.18" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.167" defer></script> <script src="js/weather_system.js?v=39.16.18" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.167" defer></script> <script src="js/item_update_scheduler.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.167" defer></script> <script src="js/simulation_runtime_helpers.js?v=39.16.18" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.167" defer></script> <script src="js/tarinai_update_policy.js?v=39.16.18" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.167" defer></script> <script src="js/simulation_environment_system.js?v=39.16.18" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.167" defer></script> <script src="js/simulation_item_ant_system.js?v=39.16.18" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.167" defer></script> <script src="js/simulation_effects_system.js?v=39.16.18" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.167" defer></script> <script src="js/simulation_creature_system.js?v=39.16.18" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.167" defer></script> <script src="js/simulation_maintenance_system.js?v=39.16.18" defer></script>
<script src="js/simulation_systems.js?v=39.15.167" defer></script> <script src="js/simulation_ambient_system.js?v=39.16.18" defer></script>
<script src="js/system_order.js?v=39.15.167" defer></script> <script src="js/simulation_systems.js?v=39.16.18" defer></script>
<script src="js/simulation.js?v=39.15.167" defer></script> <script src="js/system_order.js?v=39.16.18" defer></script>
<script src="js/colony_situation_system.js?v=39.15.167" defer></script> <script src="js/simulation.js?v=39.16.18" defer></script>
<script src="js/world_update.js?v=39.15.167" defer></script> <script src="js/colony_situation_system.js?v=39.16.18" defer></script>
<script src="js/world_tool_actions.js?v=39.15.167" defer></script> <script src="js/world_update.js?v=39.16.18" defer></script>
<script src="js/world_placement_log.js?v=39.15.167" defer></script> <script src="js/world_tool_actions.js?v=39.16.18" defer></script>
<script src="js/world_event_effects.js?v=39.15.167" defer></script> <script src="js/world_placement_log.js?v=39.16.18" defer></script>
<script src="js/command_dispatcher.js?v=39.15.167" defer></script> <script src="js/world_event_effects.js?v=39.16.18" defer></script>
<script src="js/text_catalog.js?v=39.15.167" defer></script> <script src="js/command_dispatcher.js?v=39.16.18" defer></script>
<script src="js/ui.js?v=39.15.167" defer></script> <script src="js/text_catalog.js?v=39.16.18" defer></script>
<script src="js/ui_log.js?v=39.15.167" defer></script> <script src="js/ui.js?v=39.16.18" defer></script>
<script src="js/ui_helpers.js?v=39.15.167" defer></script> <script src="js/ui_log.js?v=39.16.18" defer></script>
<script src="js/ui_selected.js?v=39.15.167" defer></script> <script src="js/ui_helpers.js?v=39.16.18" defer></script>
<script src="js/save_schema.js?v=39.15.167" defer></script> <script src="js/ui_selected.js?v=39.16.18" defer></script>
<script src="js/snapshot_system.js?v=39.15.167" defer></script> <script src="js/save_schema.js?v=39.16.18" defer></script>
<script src="js/restore_coordinator.js?v=39.15.167" defer></script> <script src="js/snapshot_system.js?v=39.16.18" defer></script>
<script src="js/history_system.js?v=39.15.167" defer></script> <script src="js/restore_coordinator.js?v=39.16.18" defer></script>
<script src="js/save_codec.js?v=39.15.167" defer></script> <script src="js/history_system.js?v=39.16.18" defer></script>
<script src="js/save_storage.js?v=39.15.167" defer></script> <script src="js/save_codec.js?v=39.16.18" defer></script>
<script src="js/save_system.js?v=39.15.167" defer></script> <script src="js/save_storage.js?v=39.16.18" defer></script>
<script src="js/ui_tooltips.js?v=39.15.167" defer></script> <script src="js/save_system.js?v=39.16.18" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.167" defer></script> <script src="js/ui_tooltips.js?v=39.16.18" defer></script>
<script src="js/ui_ground.js?v=39.15.167" defer></script> <script src="js/ui_layout_dialogs.js?v=39.16.18" defer></script>
<script src="js/ui_tools.js?v=39.15.167" defer></script> <script src="js/ui_ground.js?v=39.16.18" defer></script>
<script src="js/ui_input_shared.js?v=39.15.167" defer></script> <script src="js/ui_tools.js?v=39.16.18" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.167" defer></script> <script src="js/ui_input_shared.js?v=39.16.18" defer></script>
<script src="js/ui_input_touch.js?v=39.15.167" defer></script> <script src="js/ui_pointer_action_system.js?v=39.16.18" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.167" defer></script> <script src="js/ui_input_touch.js?v=39.16.18" defer></script>
<script src="js/ui_bind.js?v=39.15.167" defer></script> <script src="js/ui_input_mouse.js?v=39.16.18" defer></script>
<script src="js/ui_charts.js?v=39.15.167" defer></script> <script src="js/ui_bind.js?v=39.16.18" defer></script>
<script src="js/ui_family_data.js?v=39.15.167" defer></script> <script src="js/ui_charts.js?v=39.16.18" defer></script>
<script src="js/ui_family_async.js?v=39.15.167" defer></script> <script src="js/ui_family_data.js?v=39.16.18" defer></script>
<script src="js/ui_family_layout.js?v=39.15.167" defer></script> <script src="js/ui_family_async.js?v=39.16.18" defer></script>
<script src="js/ui_family_paths.js?v=39.15.167" defer></script> <script src="js/ui_family_layout.js?v=39.16.18" defer></script>
<script src="js/ui_family_render.js?v=39.15.167" defer></script> <script src="js/ui_family_paths.js?v=39.16.18" defer></script>
<script src="js/main.js?v=39.15.167" defer></script> <script src="js/ui_family_render.js?v=39.16.18" defer></script>
<script src="js/debug_tools.js?v=39.15.167" 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> </body>
</html> </html>

View file

@ -259,7 +259,20 @@ class AntActor {
if (this.dead) return; if (this.dead) return;
const hpBefore = this.hp; const hpBefore = this.hp;
this.age += dt; 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); this.updateBurning(dt);
if (this.hp <= 0) this.dieAsCorpse(this.homeNest?.() || null); if (this.hp <= 0) this.dieAsCorpse(this.homeNest?.() || null);
} else if (this.kind === "queen") this.updateQueen(dt); } else if (this.kind === "queen") this.updateQueen(dt);

View file

@ -3,6 +3,7 @@ const images = new Map();
const imageMetrics = new Map(); const imageMetrics = new Map();
const imagePromises = new Map(); const imagePromises = new Map();
const imageAssetIndex = new Map(); const imageAssetIndex = new Map();
const imageLastUsedAt = new Map();
const DECOR_ASSETS = [ const DECOR_ASSETS = [
{ id: "zunchi", path: "assets/objects/zunchi.webp" }, { id: "zunchi", path: "assets/objects/zunchi.webp" },
@ -69,6 +70,7 @@ const scaledSpriteCache = new Map();
const stainOverlayCache = new Map(); const stainOverlayCache = new Map();
const SPRITE_CACHE_LIMIT = 128; const SPRITE_CACHE_LIMIT = 128;
const STAIN_CACHE_LIMIT = 64; const STAIN_CACHE_LIMIT = 64;
const IMAGE_KEEP_IDS = new Set(["smile"]);
const SUNBATH_IMAGE_IDS = new Set(SPRITES const SUNBATH_IMAGE_IDS = new Set(SPRITES
.map(asset => asset?.id || "") .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) { function isImageReady(id) {
const img = images.get(id); const img = images.get(id);
return !!(img && img.complete && (img.naturalWidth || img.width)); return !!(img && img.complete && (img.naturalWidth || img.width));
@ -191,16 +220,19 @@ function loadImageAsset(asset, priority = "auto") {
if (!asset) return Promise.resolve(null); if (!asset) return Promise.resolve(null);
setupAssetIndex(); setupAssetIndex();
primeImageMetrics(asset); 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); if (imagePromises.has(asset.id)) return imagePromises.get(asset.id);
const img = images.get(asset.id) || new Image(); const img = images.get(asset.id) || new Image();
img.decoding = "async"; img.decoding = "async";
if ("fetchPriority" in img) img.fetchPriority = priority; if ("fetchPriority" in img) img.fetchPriority = priority;
img.alphaBounds = ALPHA_BOUNDS[asset.id] || null; img.alphaBounds = ALPHA_BOUNDS[asset.id] || null;
images.set(asset.id, img); images.set(asset.id, img);
const promise = new Promise((resolve) => { let promise;
img.onload = () => { markImageLoaded(asset, img); resolve(img); }; promise = new Promise((resolve) => {
img.onload = () => { markImageLoaded(asset, img); touchImage(asset.id); resolve(img); };
img.onerror = () => { resolve(null); }; img.onerror = () => { resolve(null); };
}).finally(() => {
if (imagePromises.get(asset.id) === promise) imagePromises.delete(asset.id);
}); });
imagePromises.set(asset.id, promise); imagePromises.set(asset.id, promise);
if (!img.src) img.src = window.TARINAI_APP?.withVersion ? window.TARINAI_APP.withVersion(asset.path) : asset.path; 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); const asset = imageAssetIndex.get(id);
if (!asset) return null; if (!asset) return null;
primeImageMetrics(asset); primeImageMetrics(asset);
touchImage(id);
const existing = images.get(id); const existing = images.get(id);
if (!existing || !existing.src) loadImageAsset(asset, "low"); if (!existing || !existing.src) loadImageAsset(asset, "low");
return images.get(id) || null; return images.get(id) || null;
@ -219,9 +252,9 @@ function ensureImage(id) {
function getRenderableImage(id, fallbackId = "smile") { function getRenderableImage(id, fallbackId = "smile") {
if (id) ensureImage(id); 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 (fallbackId && fallbackId !== id) ensureImage(fallbackId);
if (isImageReady(fallbackId)) return images.get(fallbackId); if (isImageReady(fallbackId)) { touchImage(fallbackId); return images.get(fallbackId); }
return null; return null;
} }
@ -239,19 +272,44 @@ function loadImages(onProgress = null) {
return Promise.all(tasks).then(() => undefined); return Promise.all(tasks).then(() => undefined);
} }
function trimImageMemory(aggressive = false) { function trimImageMemory(mode = "normal") {
trimCanvasCache(scaledSpriteCache, aggressive ? 48 : SPRITE_CACHE_LIMIT); const aggressive = mode === true || mode === "aggressive" || mode === "hidden";
trimCanvasCache(stainOverlayCache, aggressive ? 16 : STAIN_CACHE_LIMIT); 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") { if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.hidden) trimImageMemory(true); if (document.hidden) trimImageMemory("hidden");
}); });
} }
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.setInterval(() => trimImageMemory(false), 45000); window.setInterval(() => {
const profile = window.TarinaiPerf?.simulationOptimizationTier?.();
trimImageMemory(profile === "aggressive" ? "aggressive" : "normal");
}, 30000);
} }
function startBackgroundImageLoading() { function startBackgroundImageLoading() {
@ -265,10 +323,17 @@ function startBackgroundImageLoading() {
const schedule = window.requestIdleCallback const schedule = window.requestIdleCallback
? (fn, timeout = 1200) => window.requestIdleCallback(fn, { timeout }) ? (fn, timeout = 1200) => window.requestIdleCallback(fn, { timeout })
: (fn) => setTimeout(fn, 60); : (fn) => setTimeout(fn, 60);
schedule(() => loadGroup(EARLY_IMAGE_IDS, "auto"), 450);
schedule(() => { schedule(() => {
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"); for (const asset of [...SPRITES, ...DECOR_ASSETS]) loadImageAsset(asset, "low");
trimImageMemory(false); }
trimImageMemory(profile === "aggressive" ? "aggressive" : "normal");
}, 2200); }, 2200);
} }
@ -298,3 +363,13 @@ function getImageMetrics(idOrImg) {
setupAssetIndex(); 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

@ -91,8 +91,8 @@
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) { function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true; if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]); const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_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 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; if (!important.has(item.type) && !isPhysicsItem) return false;
const itemRects = placementRectsFor(worldRef, item); const itemRects = placementRectsFor(worldRef, item);
const itemRadius = itemFootprintRadius(worldRef, item, itemRects); const itemRadius = itemFootprintRadius(worldRef, item, itemRects);

View file

@ -26,6 +26,7 @@
const now = worldRef.time || 0; const now = worldRef.time || 0;
if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map(); if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map();
const balls = ballLikeItems(worldRef); const balls = ballLikeItems(worldRef);
let solved = 0;
for (const a of balls) { for (const a of balls) {
if (!a || a.dead || !isBallLikeType(a.type)) continue; if (!a || a.dead || !isBallLikeType(a.type)) continue;
const ar = a.r || 18; const ar = a.r || 18;
@ -88,6 +89,7 @@
const relNormal = rvx * nx + rvy * ny; const relNormal = rvx * nx + rvy * ny;
if (relNormal > 0 && d >= hitRadius - 0.5) continue; if (relNormal > 0 && d >= hitRadius - 0.5) continue;
worldRef.ballCollisionMemo.set(key, now); worldRef.ballCollisionMemo.set(key, now);
solved += 1;
const restitution = (a.type === "balloon" || b.type === "balloon") ? 0.58 : 0.86; const restitution = (a.type === "balloon" || b.type === "balloon") ? 0.58 : 0.86;
const impulse = -(1 + restitution) * relNormal / 2; const impulse = -(1 + restitution) * relNormal / 2;
if (Number.isFinite(impulse)) { 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)" })); 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;
} }

View file

@ -16,22 +16,17 @@
return bodyApi()?.ensureConstraint?.(item, worldRef || item?.world || null, { syncFromLegacy: opts.syncFromLegacy === true }) || item?.physicsConstraint || null; 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 applyConstraintState(item) { return bodyApi()?.applyConstraintState?.(item, item?.physicsConstraint) || false; }
function normalizeConstraintState(item) { return bodyApi()?.normalizeConstraintState?.(item, item?.physicsConstraint) || item?.physicsConstraint || null; }
function commitConstraint(item) { 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 }); const c = ensureConstraint(item, item.world || null, { syncFromLegacy: false });
if (!c) return null; if (!c) return null;
normalizeConstraintState(item);
return c; return c;
} }
function commitMechanicalBody(item, reason = "constraint-moved-body") { function commitMechanicalBody(item) {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false; if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
// Constraint pulls sometimes mutate item.x/y as a scratch pose. Preserve // Constraint pulls mutate item.x/y as a scratch pose; preserve that pose in
// that pose in physicsBody before normalizing, otherwise normalizeBody() // the authoritative physics body before the next frame begins.
// can restore the old body pose and silently cancel the correction.
bodyApi()?.syncPoseFromItem?.(item); bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
return true; return true;
} }
@ -58,28 +53,12 @@
function markConstraintSpatialDirty(worldRef, item, reason = "constraint-projection", threshold = 0.35, minInterval = 0.065) { function markConstraintSpatialDirty(worldRef, item, reason = "constraint-projection", threshold = 0.35, minInterval = 0.065) {
const world = worldRef || item?.world || null; const world = worldRef || item?.world || null;
if (!world || !item) return false; 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; const marked = global.TarinaiPhysicsProjectionSystem.markSpatialDirty(world, item, reason, threshold, minInterval) ?? false;
if (!marked) world.markSpatialDirty?.(reason); if (world.constraintDirtyStatsThisFrame) {
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.marked = (world.constraintDirtyStatsThisFrame.marked || 0) + 1; const key = marked ? "marked" : "coalesced";
return true; world.constraintDirtyStatsThisFrame[key] = (world.constraintDirtyStatsThisFrame[key] || 0) + 1;
} }
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.coalesced = (world.constraintDirtyStatsThisFrame.coalesced || 0) + 1; return marked;
return false;
} }
function mechanicalPowered(item) { function mechanicalPowered(item) {
@ -89,8 +68,6 @@
function commitConstraintMovedMechanical(item, reason = "constraint-projection") { function commitConstraintMovedMechanical(item, reason = "constraint-projection") {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false; if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
bodyApi()?.syncPoseFromItem?.(item); bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
markConstraintSpatialDirty(item.world, item, reason, 0.22, 0.045); markConstraintSpatialDirty(item.world, item, reason, 0.22, 0.045);
item.world && (item.world.drawListDirty = true); item.world && (item.world.drawListDirty = true);
return true; return true;
@ -115,7 +92,21 @@
if (!endpoint || !worldRef) return null; if (!endpoint || !worldRef) return null;
const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.fuzzyFallback === true; const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.fuzzyFallback === true;
const cached = endpoint._ref; 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") { if (endpoint.kind === "tarinai") {
let found = null; let found = null;
if (worldRef.liveTarinaiById) found = worldRef.liveTarinaiById(endpoint.id, endpoint.liveToken ?? null) || null; if (worldRef.liveTarinaiById) found = worldRef.liveTarinaiById(endpoint.id, endpoint.liveToken ?? null) || null;
@ -269,7 +260,7 @@
return localDelta > 0.001 || worldDelta > 0.001; return localDelta > 0.001 || worldDelta > 0.001;
}; };
for (const item of worldRef.items || []) { 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 a = remap(ep(item, 0));
const b = remap(ep(item, 1)); const b = remap(ep(item, 1));
if (a || b) { if (a || b) {
@ -287,9 +278,9 @@
function endpointWorld(endpoint, worldRef, depth = 0, seen = null) { function endpointWorld(endpoint, worldRef, depth = 0, seen = null) {
const obj = resolveEndpointObject(endpoint, worldRef); 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) 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 }; if (depth > 6) return { x: obj.x || 0, y: obj.y || 0, obj };
const guard = seen || new Set(); const guard = seen || new Set();
if (guard.has(obj.id)) return { x: obj.x || 0, y: obj.y || 0, obj }; 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 === "balloon") return 0.22;
if (obj.type === "ball") return 0.7; if (obj.type === "ball") return 0.7;
if (obj.type === "zunchi") return 0.55; 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 (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) {
if (obj.type === "rotator") return Infinity; // anchored pivot: links may torque it, not translate it if (obj.type === "rotator") return Infinity; // anchored pivot: links may torque it, not translate it
if (obj.type === "reciprocator") return mechanicalPowered(obj) ? 18.0 : 5.8; if (obj.type === "reciprocator") return mechanicalPowered(obj) ? 18.0 : 5.8;
@ -344,7 +335,7 @@
const moveX = dx * strength; const moveX = dx * strength;
const moveY = dy * strength; const moveY = dy * strength;
if (Math.hypot(moveX, moveY) < 0.001) return false; 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); const t = clamp(Number(endpoint.attachT || 0), 0, 1);
let changed = false; let changed = false;
const wA = 1 - t; const wA = 1 - t;
@ -418,7 +409,7 @@
const delta = clamp(torque * 0.00018, -0.11, 0.11); const delta = clamp(torque * 0.00018, -0.11, 0.11);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0015) return false; 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"); 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; return true;
} }
if (obj.type && obj.type.includes("fence")) return false; if (obj.type && obj.type.includes("fence")) return false;
@ -855,13 +846,126 @@
return changed; 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) { 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; item.world = worldRef || item.world || null;
applyConstraintState(item);
let changed = false; let changed = false;
if (item.type === "rope") changed = updateFlexibleLink(item, dt, worldRef); if (item.type === "rope") changed = updateFlexibleLink(item, dt, worldRef);
else if (item.type === "rod") changed = updateRigidLink(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); commitConstraint(item);
return changed; return changed;
} }
@ -921,6 +1025,7 @@
} }
} }
} }
if (item.type === "spring") return true;
const interval = item.type === "rope" ? 0.32 : 0.55; const interval = item.type === "rope" ? 0.32 : 0.55;
if ((Number(item._nextPassiveLinkUpdateAt || 0) || 0) <= now) { if ((Number(item._nextPassiveLinkUpdateAt || 0) || 0) <= now) {
item._nextPassiveLinkUpdateAt = now + interval; item._nextPassiveLinkUpdateAt = now + interval;
@ -937,7 +1042,7 @@
function linkScheduleScore(item, worldRef) { function linkScheduleScore(item, worldRef) {
const now = Number(worldRef?.time || 0) || 0; 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 (ls(item, "awakeUntil", 0) > now) score += 4.0;
if (item?._linkLastStamp && item._linkPendingStamp && item._linkLastStamp !== item._linkPendingStamp) score += 2.5; 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); score += Math.min(3, Math.hypot(ls(item, "midVx", 0), ls(item, "midVy", 0)) / 40);
@ -954,6 +1059,7 @@
const links = []; const links = [];
for (const item of worldRef.itemsOfType("rope") || []) if (item && !item.dead) links.push(item); 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("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) { if (!links.length) {
worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 }; worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 };
return 0; return 0;
@ -970,8 +1076,8 @@
let rodCorrections = 0; let rodCorrections = 0;
const pending = []; const pending = [];
for (const item of ordered) { for (const item of ordered) {
ensureConstraint(item, worldRef, { syncFromLegacy: false }); if (opts.statePrepared === true) ensureConstraint(item, worldRef, { syncFromLegacy: false });
applyConstraintState(item); else applyConstraintState(item);
visited += 1; visited += 1;
if (!linkNeedsUpdate(item, worldRef)) { if (!linkNeedsUpdate(item, worldRef)) {
skipped += 1; skipped += 1;
@ -1006,6 +1112,7 @@
const api = Object.freeze({ const api = Object.freeze({
updateFlexibleLink, updateFlexibleLink,
updateRigidLink, updateRigidLink,
updateSpringLink,
updateWorld, updateWorld,
}); });

View file

@ -103,6 +103,30 @@
return Object.entries(map || {}).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([k, v]) => `${k}:${v}`).join(", ") || "-"; 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) { function perfLines(perf) {
const self = (perf.topSelf || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 10); 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); const incl = (perf.topInclusive || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 6);
@ -124,6 +148,9 @@
if ((diag.creatures?.smooth || 0) > 0) hints.push(`smooth motion ${diag.creatures.smooth}`); 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?.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.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"); 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; 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 || "?"}`); if (m?.candidates > 250 || m?.pairs > 180) hints.push(`mechanical candidates ${m.candidates || "?"} pairs ${m.pairs || m.solved || "?"}`);
@ -145,19 +172,23 @@
const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?"; const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?";
const creature = diag.creatures || {}; const creature = diag.creatures || {};
const scheduler = diag.scheduler || {}; const scheduler = diag.scheduler || {};
const effects = diag.effects || {};
const effectRender = diag.effectRender || {};
const terrain = diag.terrain || {}; const terrain = diag.terrain || {};
const lines = [ const lines = [
`tarinai v${window.TARINAI_VERSION || "?"} debug profiler:${perf.enabled ? "on" : "off"}${Date.now() < copiedMessageUntil ? " copied" : " copy:button/select"}`, `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}`, `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"] ?? "?"}`, ...saveStatsLines(),
`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}`, `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)}`, `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 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)}`, `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}`, `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}`, `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)}`, `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)}`, `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}`, `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)}`, `hints ${diagnosticHints(diag, perf)}`,

View file

@ -18,10 +18,6 @@ function updateFirecracker(item, dt, worldRef) {
const FIRE_PANIC_INTERVAL = 1.20; const FIRE_PANIC_INTERVAL = 1.20;
const FIRE_WATER_INTERVAL = 0.16; const FIRE_WATER_INTERVAL = 0.16;
const FIRE_CLUSTER_SCAN_INTERVAL = 0.45; 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 GRASS_FIRE_EXTINGUISH_RATE = 0.56;
const NEST_BOX_FIRE_EVACUATE_AFTER = 10.0; const NEST_BOX_FIRE_EVACUATE_AFTER = 10.0;
const NEST_BOX_FIRE_MIN_LIFE = 14.0; const NEST_BOX_FIRE_MIN_LIFE = 14.0;
@ -40,7 +36,8 @@ function updateFirecracker(item, dt, worldRef) {
function pushFireEffect(worldRef, x, y, size = 12, seed = null) { function pushFireEffect(worldRef, x, y, size = 12, seed = null) {
if (!worldRef?.effects || typeof Effect !== "function") return; 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 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); const speed = global.deterministicRange(worldRef, "fire-effect-speed", 3, 15, seed || x, y);
worldRef.effects.push(new Effect("flame", x, y, { worldRef.effects.push(new Effect("flame", x, y, {
@ -55,10 +52,9 @@ function updateFirecracker(item, dt, worldRef) {
function fireInteractionBudget(worldRef) { function fireInteractionBudget(worldRef) {
const frame = Math.floor((Number(worldRef?.time || 0) || 0) * 60); const frame = Math.floor((Number(worldRef?.time || 0) || 0) * 60);
if (!worldRef._fireInteractionBudget || worldRef._fireInteractionBudget.frame !== frame) { if (!worldRef._fireInteractionBudget || worldRef._fireInteractionBudget.frame !== frame) {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
worldRef._fireInteractionBudget = { worldRef._fireInteractionBudget = {
frame, frame,
remaining: mobile ? FIRE_INTERACTION_BUDGET_MOBILE : FIRE_INTERACTION_BUDGET_DESKTOP, remaining: Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().fireInteractionBudget || 14)),
}; };
} }
return worldRef._fireInteractionBudget; return worldRef._fireInteractionBudget;
@ -442,8 +438,7 @@ function updateFirecracker(item, dt, worldRef) {
} }
function activeFireLimit() { function activeFireLimit() {
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760); return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().activeFireLimit || 60));
return mobile ? FIRE_MAX_ACTIVE_MOBILE : FIRE_MAX_ACTIVE_DESKTOP;
} }
function activeFires(worldRef) { function activeFires(worldRef) {

View file

@ -193,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); 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({ global.TarinaiGeometry = Object.freeze({
num, num,
clampValue, clampValue,
@ -212,5 +232,7 @@
pointSegmentDistance, pointSegmentDistance,
segmentAabbHit, segmentAabbHit,
segmentIntersectsOrientedRect, segmentIntersectsOrientedRect,
oneWayFenceAllowsMotion,
oneWayFenceAllows,
}); });
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -6,7 +6,7 @@ const GROUND_TYPES = Object.freeze({
soil: Object.freeze({ id: "soil", label: "砂", description: "普通の砂地。", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }), 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 }), 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 }), 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 }), 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 }), 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 }), 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 }),

View file

@ -27,11 +27,11 @@
const pb = global.TarinaiPhysicsBodySystem || null; const pb = global.TarinaiPhysicsBodySystem || null;
const links = []; const links = [];
if (worldRef.itemsOfType) { 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); for (const link of worldRef.itemsOfType(linkType) || []) if (link && !link.dead) links.push(link);
} }
} else { } 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) { for (const link of links) {
const endpoints = [pb?.endpoint?.(link, 0), pb?.endpoint?.(link, 1)]; 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 < 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; } 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); resolveObstacleCollisions(item, dt, worldRef);
global.TarinaiItemDynamicPinSystem.tryBallPickup(item, worldRef);
item.x = clamp(item.x, p, worldRef.w - p); item.x = clamp(item.x, p, worldRef.w - p);
item.y = clamp(item.y, p, worldRef.h - p); item.y = clamp(item.y, p, worldRef.h - p);
const groundFriction = Math.pow(0.72, dt); const groundFriction = Math.pow(0.72, dt);
@ -198,7 +197,7 @@
const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect; const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect;
if (typeof bounceCircleOffRect !== "function") return; if (typeof bounceCircleOffRect !== "function") return;
const hitRadius = (item.r || 18) + 2; 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 velocityDamp = rect.bounce ? 1.0 : (item.type === "balloon" ? 0.82 : 0.94);
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0); const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, {
@ -222,11 +221,7 @@
} }
function normalizeAngleLocal(angle = 0, fallback = 0) { function normalizeAngleLocal(angle = 0, fallback = 0) {
if (typeof normalizedItemAngle === "function") return normalizedItemAngle(angle, fallback); return normalizedItemAngle(angle, fallback);
const twoPi = Math.PI * 2;
const n = Number(angle);
const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0);
return ((base % twoPi) + twoPi) % twoPi;
} }
function fanWindAngleFor(item) { function fanWindAngleFor(item) {
@ -412,6 +407,10 @@
target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820); target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820);
if (type === "balloon") target.spinVelocity = clamp((target.spinVelocity || 0) + 3.2 * wind.falloff * dt, -12, 12); 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); else if (type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + 5.4 * wind.falloff * dt, -38, 38);
// 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"); worldRef?.markSpatialDirty?.("fan-wind-item");
} }

View file

@ -16,10 +16,10 @@
case "rotator": case "rotator":
case "poison_block": case "poison_block":
case "reciprocator": case "reciprocator":
case "gate_fence":
case "robot_cleaner": case "robot_cleaner":
case "rope": case "rope":
case "rod": case "rod":
case "spring":
return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false; return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false;
case "ball": case "ball":
case "balloon": case "balloon":

View file

@ -2,7 +2,7 @@
// Layer: entity-runtime/items/dynamic-system // Layer: entity-runtime/items/dynamic-system
// Owns dynamic tool behavior for rotator, reciprocator, firecracker, genkotsu, // 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. // direct Item.prototype calls; lifecycle dispatch also calls this boundary directly.
(function (global) { (function (global) {
function fireApi() { function fireApi() {
@ -166,20 +166,15 @@
function updateFlexibleLink(item, dt, worldRef) { function updateFlexibleLink(item, dt, worldRef) {
global.TarinaiPhysicsBodySystem?.applyConstraintState?.(item, item?.physicsConstraint);
return Boolean(global.TarinaiConstraintSystem.updateFlexibleLink(item, dt, worldRef)); return Boolean(global.TarinaiConstraintSystem.updateFlexibleLink(item, dt, worldRef));
} }
function updateRigidLink(item, dt, worldRef) { function updateRigidLink(item, dt, worldRef) {
global.TarinaiPhysicsBodySystem?.applyConstraintState?.(item, item?.physicsConstraint);
return Boolean(global.TarinaiConstraintSystem.updateRigidLink(item, dt, worldRef)); return Boolean(global.TarinaiConstraintSystem.updateRigidLink(item, dt, worldRef));
} }
function updateGateFence(item) {
if (item) item.amount = 999;
return Boolean(item);
}
function update(item, dt, worldRef) { function update(item, dt, worldRef) {
if (!item || item.dead) return false; if (!item || item.dead) return false;
if (item.type === "firecracker") return fireApi().updateFirecracker(item, dt, worldRef); if (item.type === "firecracker") return fireApi().updateFirecracker(item, dt, worldRef);
@ -191,10 +186,13 @@
if (item.type === "rotator") return updateRotator(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 === "poison_block") return updatePoisonBlock(item, dt, worldRef);
if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef); if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef);
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
if (item.type === "robot_cleaner") return robotApi().updateRobotCleaner(item, dt, worldRef); if (item.type === "robot_cleaner") return robotApi().updateRobotCleaner(item, dt, worldRef);
if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef); if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef);
if (item.type === "rod") return updateRigidLink(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; return false;
} }

View file

@ -1018,9 +1018,10 @@ draw(ctx, t, lighting = null) {
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.strokeText(timerText, 0, this.r * 0.02); ctx.strokeText(timerText, 0, this.r * 0.02);
ctx.fillText(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 bounce = this.type === "bounce_fence" || this.type === "bounce_fence_v";
const gate = this.type === "gate_fence"; const gate = this.type === "gate_fence";
const oneWay = this.type === "one_way_fence";
const glassWall = this.type === "glass_wall"; const glassWall = this.type === "glass_wall";
const angle = itemAngleFor(this); const angle = itemAngleFor(this);
const len = Math.max(112, this.r * 3.55); const len = Math.max(112, this.r * 3.55);
@ -1028,9 +1029,9 @@ draw(ctx, t, lighting = null) {
const open = gate && this.gateOpen; const open = gate && this.gateOpen;
ctx.save(); ctx.save();
ctx.rotate(angle); ctx.rotate(angle);
const wood = bounce ? (styleNight ? "#4e6f9f" : "#5aa2e0") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31")); 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") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25"); 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)") : 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 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) => { const drawRail = (x0, y0, w, h, alpha = 1) => {
ctx.save(); ctx.save();
ctx.globalAlpha *= alpha; ctx.globalAlpha *= alpha;
@ -1118,12 +1119,32 @@ draw(ctx, t, lighting = null) {
ctx.fill(); 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(); ctx.restore();
} else if (this.type === "rotator") { } else if (this.type === "rotator") {
const angle = itemAngleFor(this); const angle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem; 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 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 segments = (pb?.segments?.(this) || [[-78, 0, 78, 0], [0, -52, 0, 52]]);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12)); const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
@ -1171,7 +1192,7 @@ draw(ctx, t, lighting = null) {
const bodyAngle = itemAngleFor(this); const bodyAngle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem; const pb = window.TarinaiPhysicsBodySystem;
const axisAngle = window.TarinaiMechanicalSystem.railAxisAngle(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle; const axisAngle = window.TarinaiMechanicalSystem.railAxisAngle(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle;
const powered = pb?.scalar?.(this, "railOn", true) !== false; const 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 thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
const tier = mechanicalRenderTier(); const tier = mechanicalRenderTier();
const travel = Math.max(24, Number(pb?.scalar?.(this, "railTravel", 150) || 150) || 150); const travel = Math.max(24, Number(pb?.scalar?.(this, "railTravel", 150) || 150) || 150);
@ -1238,7 +1259,7 @@ draw(ctx, t, lighting = null) {
} else { } else {
ctx.fillText("\u25cb", 0, 0); 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 runtime = window.TarinaiLinkRuntime;
const pb = window.TarinaiPhysicsBodySystem; const pb = window.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(this, 0), world); const a = runtime?.endpointWorld?.(pb?.endpoint?.(this, 0), world);
@ -1249,7 +1270,16 @@ draw(ctx, t, lighting = null) {
ctx.save(); ctx.save();
ctx.lineCap = "round"; ctx.lineCap = "round";
ctx.lineJoin = "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.strokeStyle = styleNight ? "rgba(176,166,150,0.96)" : "rgba(92,82,68,0.96)";
ctx.lineWidth = 7.2; ctx.lineWidth = 7.2;
ctx.beginPath(); ctx.beginPath();
@ -1275,15 +1305,15 @@ draw(ctx, t, lighting = null) {
drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by); drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by);
ctx.stroke(); 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]]) { 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(); ctx.restore();
} }
} else if (this.type === "zunchi") { } else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "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); 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.globalAlpha *= stageAlpha;
if (img) { if (img) {

View file

@ -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" }, 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" }, 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" }, 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" }, 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" }, 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" }, 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" },
@ -61,9 +64,11 @@ const TOOL_DEFINITIONS = Object.freeze({
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: { 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" }, 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" }, 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" }, 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: 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" }, 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" }, 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" }, 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 }, 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 }, splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },

View file

@ -3,19 +3,19 @@
// Static item/tool metadata shared by tool definitions and UI helpers. // Static item/tool metadata shared by tool definitions and UI helpers.
(function (global) { (function (global) {
const ITEM_TRAITS = Object.freeze({ 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"]), 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", "sticky_bomb", "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"]), pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]), kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]), sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]),
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", "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 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 ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"]));
const PIN_BEHAVIORS = Object.freeze({ const PIN_BEHAVIORS = Object.freeze({
@ -35,6 +35,18 @@
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }); 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_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({ global.TarinaiItemToolMetadata = Object.freeze({
ITEM_TRAITS, ITEM_TRAITS,
@ -47,5 +59,6 @@
TOOL_SIZE_ORDER, TOOL_SIZE_ORDER,
TOOL_SIZE_LABELS, TOOL_SIZE_LABELS,
TOOL_SIZE_SCALES, TOOL_SIZE_SCALES,
TOOL_SHORTCUTS,
}); });
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -2,8 +2,7 @@
// Layer: data/item-type-catalog // Layer: data/item-type-catalog
// Builds the current save item-type table from live tool, structure, and link definitions. // Builds the current save item-type table from live tool, structure, and link definitions.
// Save compatibility is intentionally not preserved; current encode/decode only need to agree // The item-type table remains build-defined. Schema 36 includes the current mechanical and signal tool ordering.
// within the same app build.
(function (global) { (function (global) {
const DomainIds = global.TarinaiDomainIds; const DomainIds = global.TarinaiDomainIds;
if (!DomainIds) throw new Error("TarinaiDomainIds must be loaded before item_type_catalog.js"); if (!DomainIds) throw new Error("TarinaiDomainIds must be loaded before item_type_catalog.js");

View file

@ -201,13 +201,13 @@ function initializeItemTypeState(item, type, x, y) {
}; };
item.amount = 999; item.amount = 999;
} }
if (type === "rope" || type === "rod") { if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(type)) {
item.amount = 999; item.amount = 999;
item.r = 48; item.r = 48;
item.deletable = true; item.deletable = true;
item.physicsConstraint = { item.physicsConstraint = {
type, 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], endpoints: [null, null],
length: 80, length: 80,
mid: { x, y: y + 10, vx: 0, vy: 0 }, mid: { x, y: y + 10, vx: 0, vy: 0 },
@ -215,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") { if (type === "reciprocator") {
item.physicsBody = { item.physicsBody = {
type: "reciprocator", type: "reciprocator",
@ -232,7 +242,6 @@ function initializeItemTypeState(item, type, x, y) {
} }
if (globalThis.TarinaiPhysicsBodySystem.isPhysicsType(type)) { if (globalThis.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
globalThis.TarinaiPhysicsBodySystem.purgeLegacyPhysicsStorage(item);
globalThis.TarinaiPhysicsBodySystem.invalidateItem(item, "item-initialized"); globalThis.TarinaiPhysicsBodySystem.invalidateItem(item, "item-initialized");
} }

View file

@ -11,49 +11,88 @@
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "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", "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", "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) { function itemUpdateInterval(item) {
if (!item || item.dead) return Infinity; if (!item || item.dead) return Infinity;
const mobile = mobilePerfHint();
// Mechanical bodies and links are owned by the central physics step. // Mechanical bodies and links are owned by the central physics step.
// Keeping them out of the per-item scheduler prevents N independent 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;
// collision passes and makes future physics-format changes cheaper. if (REALTIME_ITEM_TYPES.has(item.type)) return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (item.type === "rotator" || item.type === "reciprocator" || item.type === "poison_block" || item.type === "rope" || item.type === "rod") return Infinity; if (PIN_ITEM_TYPES.has(item.type)) return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (REALTIME_ITEM_TYPES.has(item.type)) return 0; if (item.isStructure && item.type === "plushie") return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (PIN_ITEM_TYPES.has(item.type)) return 0; if (item.isStructure && item.type === "grass_bed") return adjustedInterval(3.0);
if (item.isStructure && item.type === "plushie") return 0; if (item.type === "fire") return adjustedInterval(0.18);
if (item.isStructure && item.type === "grass_bed") return 3.0; if (item.type === "nest_box" && global.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(item)) return adjustedInterval(0.18);
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 === "nest_box" && global.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(item)) return mobile ? 0.30 : 0.18;
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0; if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi" && (item.burning || (item.burnTimer || 0) > 0.02)) return mobile ? 0.30 : 0.18; if (item.type === "zunchi" && (item.burning || (item.burnTimer || 0) > 0.02)) return adjustedInterval(0.18);
if (item.type === "zunchi") { if (item.type === "zunchi") {
const groundId = item.world?.groundType || (typeof world !== "undefined" ? world?.groundType : "") || "soil"; const groundId = item.world?.groundType || (typeof world !== "undefined" ? world?.groundType : "") || "soil";
const decayMul = Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1; const decayMul = Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1;
// High-decay grounds need a shorter update cadence; otherwise the amount if (decayMul >= 100) return adjustedInterval(0.12);
// changes only once every few seconds and the laboratory effect looks idle. if (decayMul > 1.01) return adjustedInterval(0.28);
if (decayMul >= 100) return mobile ? 0.18 : 0.12; return adjustedInterval(2.4);
if (decayMul > 1.01) return mobile ? 0.42 : 0.28;
return 2.4;
} }
if (item.type === "grass") return Infinity; if (item.type === "grass") return Infinity;
if (item.type === "duplicator") return mobile ? 0.8 : 0.5; if (item.type === "duplicator") return adjustedInterval(0.5);
if (item.type === "water") return mobile ? 2.4 : 1.8; if (item.type === "water") return adjustedInterval(1.8);
if (item.type === "trace" || item.type === "splat") return mobile ? 1.8 : 1.2; if (item.type === "trace" || item.type === "splat") return adjustedInterval(1.2);
if (item.type === "ant_corpse") return 3.5; if (item.type === "ant_corpse") return adjustedInterval(3.5);
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return 5.0; if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return adjustedInterval(5.0);
if (isServingFoodType(item.type)) return 5.0; if (isServingFoodType(item.type)) return adjustedInterval(5.0);
return Infinity; return Infinity;
} }
@ -93,6 +132,7 @@
global.TarinaiItemUpdatePolicy = Object.freeze({ global.TarinaiItemUpdatePolicy = Object.freeze({
itemUpdateInterval, itemUpdateInterval,
itemRealtimeActive,
forScheduledItems, forScheduledItems,
ensureBuckets, ensureBuckets,
candidateItems, candidateItems,

View file

@ -52,6 +52,7 @@
(worldRef.items || []).length, (worldRef.items || []).length,
worldRef.itemBucketRebuildsTotal || 0, worldRef.itemBucketRebuildsTotal || 0,
worldRef.itemBucketsDirty ? 1 : 0, worldRef.itemBucketsDirty ? 1 : 0,
global.TarinaiPerf?.simulationOptimizationTier?.() || "balanced",
].join(":"); ].join(":");
} }
@ -88,8 +89,9 @@
reason, reason,
}; };
for (const item of candidateItems(worldRef)) { for (const item of candidateItems(worldRef)) {
item._schedulerLastAt = Number.isFinite(item._schedulerLastAt) ? item._schedulerLastAt : now; const interval = itemUpdateInterval(item);
scheduleItem(state, item, now, itemUpdateInterval(item)); if (interval > 0 && !Number.isFinite(item._schedulerLastAt)) item._schedulerLastAt = now;
scheduleItem(state, item, now, interval);
} }
worldRef._itemUpdateScheduler = state; worldRef._itemUpdateScheduler = state;
worldRef._itemUpdateRealtime = state.realtime; worldRef._itemUpdateRealtime = state.realtime;
@ -102,6 +104,10 @@
return worldRef._itemUpdateScheduler; return worldRef._itemUpdateScheduler;
} }
function scheduledBudgetFor() {
return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().scheduledItemBudget || 30));
}
function runRealtime(worldRef, state, dt) { function runRealtime(worldRef, state, dt) {
let ran = 0; let ran = 0;
const now = worldRef.time || 0; const now = worldRef.time || 0;
@ -128,10 +134,10 @@
return ran; return ran;
} }
function runDue(worldRef, state) { function runDue(worldRef, state, budget = scheduledBudgetFor(worldRef, state)) {
let ran = 0; let ran = 0;
const now = worldRef.time || 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; let dueRuns = 0;
while (state.heap.length && state.heap[0].time <= now + 0.0001 && dueRuns < maxDue) { while (state.heap.length && state.heap[0].time <= now + 0.0001 && dueRuns < maxDue) {
const entry = heapPop(state.heap); const entry = heapPop(state.heap);
@ -150,16 +156,37 @@
dueRuns += 1; dueRuns += 1;
scheduleItem(state, item, now, itemUpdateInterval(item)); 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; return ran;
} }
function physicsBudgetFor(worldRef) { function physicsBudgetFor() {
const itemCount = (worldRef?.items || []).length || 0; return { ...(global.TarinaiPerf?.performanceProfile?.().physicsBudget || {}) };
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 function poisonContactBudgetFor(worldRef) {
? { maxPairs: 240, maxLinks: 170, postMaxPairs: 78, maxSubsteps: 4 } const possibleTargets =
: { maxPairs: 300, maxLinks: 210, postMaxPairs: 100, maxSubsteps: 5 }; (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) { function run(worldRef, dt) {
@ -171,16 +198,19 @@
let ran = realtimeRan; let ran = realtimeRan;
if (realtimeEnd) realtimeEnd(); if (realtimeEnd) realtimeEnd();
const dueEnd = global.TarinaiPerf.begin("update.items.scheduled"); 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; ran += dueRan;
if (dueEnd) dueEnd(); if (dueEnd) dueEnd();
global.TarinaiSignalSystem?.updateWorld?.(worldRef, dt);
const physicsEnd = global.TarinaiPerf.begin("update.physicsWorld"); const physicsEnd = global.TarinaiPerf.begin("update.physicsWorld");
const physicsBudget = physicsBudgetFor(worldRef); const physicsBudget = physicsBudgetFor(worldRef);
const physicsWorld = global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget); const physicsWorld = global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget);
const mechanicalWorld = physicsWorld.mechanical || null; const mechanicalWorld = physicsWorld.mechanical || null;
const constraintWorld = physicsWorld.constraintRan || 0; const constraintWorld = physicsWorld.constraintRan || 0;
const postConstraintPairs = physicsWorld.postConstraintPairs || 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(); if (physicsEnd) physicsEnd();
state.signature = schedulerSignature(worldRef); state.signature = schedulerSignature(worldRef);
worldRef._itemUpdateSchedulerStats = { worldRef._itemUpdateSchedulerStats = {
@ -191,16 +221,20 @@
lastRan: ran, lastRan: ran,
realtimeRan, realtimeRan,
dueRan, dueRan,
budgetDue,
dueSkipped: state.lastDueSkipped || 0,
sleeping: state.heap.length, sleeping: state.heap.length,
activeRealtime: state.realtime.length, activeRealtime: state.realtime.length,
physicsBudget, physicsBudget,
poisonContactBudget,
poisonContactRemoved,
physics: physicsWorld || mechanicalWorld, physics: physicsWorld || mechanicalWorld,
constraints: constraintWorld, constraints: constraintWorld,
postConstraintPairs, postConstraintPairs,
mechanicalStats: worldRef._mechanicalWorldStats || null, mechanicalStats: worldRef._mechanicalWorldStats || null,
constraintStats: worldRef._constraintWorldStats || null, constraintStats: worldRef._constraintWorldStats || null,
}; };
return ran + (physicsWorld.ran || mechanicalWorld?.ran || 0) + constraintWorld + poisonContactRemoved; return ran + (physicsWorld.ran || mechanicalWorld?.ran || 0) + poisonContactRemoved;
} finally { } finally {
if (end) end(); if (end) end();
} }
@ -210,5 +244,5 @@
return worldRef?._itemUpdateRealtime || []; return worldRef?._itemUpdateRealtime || [];
} }
global.TarinaiItemUpdateScheduler = Object.freeze({ run, trackedDynamicItems }); global.TarinaiItemUpdateScheduler = Object.freeze({ run, trackedDynamicItems, wakeItem });
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -546,6 +546,9 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
pinState: "loose", pinState: "loose",
spin: type === "pushpin" || type === "oshibyo" ? -0.55 : 0, spin: type === "pushpin" || type === "oshibyo" ? -0.55 : 0,
zunchiVariant: "zunchi", 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, antCount: typeof ANT_NEST_START_COUNT !== "undefined" ? ANT_NEST_START_COUNT : 6,
fuseTimer: 5, fuseTimer: 5,
fuseMax: 5, fuseMax: 5,

View file

@ -16,8 +16,6 @@ function ensureWorldBootstrap() {
let world = ensureWorldBootstrap(); let world = ensureWorldBootstrap();
let last = nowSec(); let last = nowSec();
let statsTimer = 0; let statsTimer = 0;
let fpsTimer = 0;
let fpsFrames = 0;
window.__tarinaiFps = 0; window.__tarinaiFps = 0;
const LOADING_SPRITES = SPRITES const LOADING_SPRITES = SPRITES
@ -74,6 +72,7 @@ function setLoadingProgress(text = "", ratio = 0) {
function hideLoadingScreen() { function hideLoadingScreen() {
if (loadingSpriteTimer) { clearInterval(loadingSpriteTimer); loadingSpriteTimer = 0; } if (loadingSpriteTimer) { clearInterval(loadingSpriteTimer); loadingSpriteTimer = 0; }
document.getElementById("loadingSpriteStrip")?.replaceChildren();
setLoadingProgress("\u8d77\u52d5\u5b8c\u4e86", 1); setLoadingProgress("\u8d77\u52d5\u5b8c\u4e86", 1);
const screen = document.getElementById("loadingScreen"); const screen = document.getElementById("loadingScreen");
if (!screen) return; if (!screen) return;
@ -84,14 +83,8 @@ function loop() {
const rawDt = t - last; const rawDt = t - last;
const dt = clamp(rawDt, 0, 0.05); const dt = clamp(rawDt, 0, 0.05);
last = t; last = t;
window.TarinaiPerf.observeFrameDelta(rawDt);
window.TarinaiPerf.beginFrame(); 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"); const endUpdate = window.TarinaiPerf.begin("update.total");
world.update(dt); world.update(dt);
if (endUpdate) endUpdate(); if (endUpdate) endUpdate();

View file

@ -40,14 +40,14 @@
} }
function bodyOf(item) { return ensureBody(item, { syncFromLegacy: false }); } function bodyOf(item) { return ensureBody(item, { syncFromLegacy: false }); }
function usingStepScratch(item) { return item?._physicsStepScratch === true; } function usingStepScratch(item) { return item?._physicsStepScratch === true; }
function runMechanicalStep(item, commitReason, fn) { function runMechanicalStep(item, fn) {
const prevScratch = item._physicsStepScratch === true; const prevScratch = item._physicsStepScratch === true;
item._physicsStepScratch = true; item._physicsStepScratch = true;
try { try {
return fn(); return fn();
} finally { } finally {
item._physicsStepScratch = prevScratch; item._physicsStepScratch = prevScratch;
commitBody(item, commitReason); commitBody(item);
} }
} }
function bodyPose(item) { return usingStepScratch(item) ? null : (bodyOf(item)?.pose || null); } function bodyPose(item) { return usingStepScratch(item) ? null : (bodyOf(item)?.pose || null); }
@ -56,11 +56,9 @@
function bodyRail(item) { return bodyOf(item)?.rail || null; } function bodyRail(item) { return bodyOf(item)?.rail || null; }
function bodyShape(item) { return bodyOf(item)?.shape || null; } function bodyShape(item) { return bodyOf(item)?.shape || null; }
function applyBodyState(item, opts = {}) { return bodySystem()?.applyBodyState?.(item, item?.physicsBody, opts) || false; } 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) {
function commitBody(item, reason = "mechanical-body-mutated") {
if (!item || !isMechanicalType(item)) return null; if (!item || !isMechanicalType(item)) return null;
// Capture before ensureBody()/normalizeBodyState() can restore the old body // Capture the scratch pose before writing it back to the authoritative body.
// pose to item.x/y/angle.
const sx = num(item.x); const sx = num(item.x);
const sy = num(item.y); const sy = num(item.y);
const sa = num(item.angle); const sa = num(item.angle);
@ -74,11 +72,11 @@
item.x = sx; item.x = sx;
item.y = sy; item.y = sy;
item.angle = sa; item.angle = sa;
normalizeBodyState(item);
bodySystem()?.markBodyChanged?.(item, reason);
return body; return body;
} }
function isPowered(item) { function isPowered(item) {
const signalOverride = global.TarinaiSignalSystem?.powerOverride?.(item);
if (signalOverride != null) return Boolean(signalOverride);
const motor = bodyMotor(item); const motor = bodyMotor(item);
if (motor) return item?.type === "poison_block" ? false : motor.powered !== false; 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); return item?.type === "rotator" ? ps(item, "motorOn", true) !== false : (item?.type === "reciprocator" ? ps(item, "railOn", true) !== false : false);
@ -106,7 +104,6 @@
if (body) { if (body) {
body.shape = body.shape || {}; body.shape = body.shape || {};
body.shape.version = nextVersion; body.shape.version = nextVersion;
bodySystem()?.markBodyChanged?.(item, "mechanical-geometry-edited");
} }
item._mechanicalShapeVersion = nextVersion; item._mechanicalShapeVersion = nextVersion;
item._mechanicalGeomCache = null; item._mechanicalGeomCache = null;
@ -124,7 +121,6 @@
if (body) { if (body) {
body.sleep = body.sleep || {}; body.sleep = body.sleep || {};
body.sleep.awakeUntil = Math.max(num(body.sleep.awakeUntil), awakeUntil); body.sleep.awakeUntil = Math.max(num(body.sleep.awakeUntil), awakeUntil);
bodySystem()?.markBodyChanged?.(item, reason);
} }
if (item.world) { if (item.world) {
item.world._itemUpdateScheduler = null; item.world._itemUpdateScheduler = null;
@ -509,7 +505,7 @@
const inertia = Math.max(1200, ps(item, "inertia", 3200)); 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); 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; 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; return changed;
} }
if (item.type === "rotator") { if (item.type === "rotator") {
@ -521,7 +517,7 @@
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0008) return false; if (!Number.isFinite(delta) || Math.abs(delta) < 0.0008) return false;
const limit = powered ? 3.2 : 2.25; const limit = powered ? 3.2 : 2.25;
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) + delta, -limit, limit), "rotator-torque"); 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; return true;
} }
const a = axis(item); const a = axis(item);
@ -544,7 +540,7 @@
if (!Number.isFinite(delta) || Math.abs(delta) < 0.05) return false; 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"); 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; return true;
} }
@ -559,7 +555,7 @@
const delta = clamp((axisDrive * 0.030 + normalDrive * 0.080) * scale, -24, 24); const delta = clamp((axisDrive * 0.030 + normalDrive * 0.080) * scale, -24, 24);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.08) return false; 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"); 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; return true;
} }
@ -573,7 +569,7 @@
const delta = clamp((tangential * 0.14 + normalTorque * incomingNormal * 0.0016) * scale, -0.18, 0.18); 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; 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"); 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; return true;
} }
@ -646,7 +642,7 @@
item.x = p.x; item.x = p.x;
item.y = p.y; item.y = p.y;
const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001; const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001;
if (changed) commitBody(item, "rail-correction"); if (changed) commitBody(item);
return changed; return changed;
} }
@ -684,7 +680,7 @@
pset(item, "xv", vx - nx * inward * 0.58, `${reason}-normal-damp`); pset(item, "xv", vx - nx * inward * 0.58, `${reason}-normal-damp`);
pset(item, "yv", vy - ny * 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); wakeItem(item, reason);
return true; return true;
} }
@ -704,7 +700,7 @@
const current = ps(item, "slideSpeed", 0); const current = ps(item, "slideSpeed", 0);
if (current * along < 0) pset(item, "slideSpeed", current * 0.38, `${reason}-slide-damp`); if (current * along < 0) pset(item, "slideSpeed", current * 0.38, `${reason}-slide-damp`);
const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001; const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001;
if (changed) commitBody(item, reason); if (changed) commitBody(item);
return changed; return changed;
} }
return false; return false;
@ -770,7 +766,7 @@
pset(item, "slideSpeed", -Math.abs(ps(item, "slideSpeed", 0)) * before * 0.20, "slide-reverse-passive"); pset(item, "slideSpeed", -Math.abs(ps(item, "slideSpeed", 0)) * before * 0.20, "slide-reverse-passive");
item._reciprocatorBlockedTimer = 0; item._reciprocatorBlockedTimer = 0;
item._reciprocatorAutoReverseAt = now; item._reciprocatorAutoReverseAt = now;
commitBody(item, `reciprocator-auto-reverse:${reason}`); commitBody(item);
worldRef?.markSpatialDirty?.("reciprocator-auto-reverse"); worldRef?.markSpatialDirty?.("reciprocator-auto-reverse");
worldRef && (worldRef.drawListDirty = true); worldRef && (worldRef.drawListDirty = true);
return true; return true;
@ -787,9 +783,9 @@
const driveX = a.x * dir; const driveX = a.x * dir;
const driveY = a.y * dir; const driveY = a.y * dir;
const ahead = driveX * num(nx) + driveY * num(ny); const ahead = driveX * num(nx) + driveY * num(ny);
// Side brushes are allowed to slide. A direct or moderately diagonal // Side brushes and contacts behind the motor are allowed to slide. A
// contact means the powered block has hit a physical object in its path. // direct or moderately diagonal contact in the travel direction reverses.
if (ahead < -0.18) return false; if (ahead < 0.18) return false;
pset(item, "railDir", -dir, "slide-contact-reverse"); pset(item, "railDir", -dir, "slide-contact-reverse");
pset(item, "slideSpeed", 0, "slide-contact-clear-slide"); pset(item, "slideSpeed", 0, "slide-contact-clear-slide");
const body = ensureBody(item, { syncFromLegacy: false }); const body = ensureBody(item, { syncFromLegacy: false });
@ -802,7 +798,7 @@
item._reciprocatorBlockedTimer = 0; item._reciprocatorBlockedTimer = 0;
item._reciprocatorContactReverseAt = now; item._reciprocatorContactReverseAt = now;
item._reciprocatorAutoReverseAt = now; item._reciprocatorAutoReverseAt = now;
commitBody(item, `reciprocator-contact-reverse:${reason}`); commitBody(item);
if (worldRef) { if (worldRef) {
worldRef.drawListDirty = true; worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("reciprocator-contact-reverse"); worldRef.markSpatialDirty?.("reciprocator-contact-reverse");
@ -814,7 +810,7 @@
if (!item || item.dead || item.type !== "rotator" || !isPowered(item)) return false; if (!item || item.dead || item.type !== "rotator" || !isPowered(item)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0; const now = Number(worldRef?.time || item.world?.time || 0) || 0;
if ((item._rotatorContactBrakeAt || -999) + 0.045 > now) return false; 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; if (motorSpeed <= 0) return false;
const drive = signedDrive(item); const drive = signedDrive(item);
const sign = Math.sign(drive || motorSpeed) || 1; const sign = Math.sign(drive || motorSpeed) || 1;
@ -825,7 +821,7 @@
const brake = Math.max(0.32, motorSpeed * 0.82); const brake = Math.max(0.32, motorSpeed * 0.82);
pset(item, "spin", clamp(current - sign * brake, -3.4, 3.4), "rotator-contact-brake"); pset(item, "spin", clamp(current - sign * brake, -3.4, 3.4), "rotator-contact-brake");
item._rotatorContactBrakeAt = now; item._rotatorContactBrakeAt = now;
commitBody(item, `rotator-contact-brake:${reason}`); commitBody(item);
if (worldRef) { if (worldRef) {
worldRef.drawListDirty = true; worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("rotator-contact-brake"); worldRef.markSpatialDirty?.("rotator-contact-brake");
@ -942,7 +938,9 @@
other.lastKickerId = item.id || "mechanical"; other.lastKickerId = item.id || "mechanical";
} }
if (item.type === "reciprocator") { 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 { } 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); 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"); if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "physical-circle");
@ -952,7 +950,7 @@
if (contacts >= 18) break; if (contacts >= 18) break;
} }
if (changed) { if (changed) {
commitBody(item, "mechanical-circle-contact"); commitBody(item);
worldRef.markSpatialDirty?.("mechanical-circle-contact"); worldRef.markSpatialDirty?.("mechanical-circle-contact");
worldRef.drawListDirty = true; worldRef.drawListDirty = true;
} }
@ -991,6 +989,10 @@
let contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75); let contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75);
if (!contact) return false; if (!contact) return false;
let info = contact.info; 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); const separated = applyMechanicalPairSeparation(a, b, info, dt, worldRef);
if (separated) { if (separated) {
// Position projection may fully clear the overlap. Re-query before adding // Position projection may fully clear the overlap. Re-query before adding
@ -1013,8 +1015,6 @@
applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, separated ? 0.78 : 1.0); 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(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); 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 (a.type === "rotator") brakeRotatorOnContact(a, dt, worldRef, "mechanical");
if (b.type === "rotator") brakeRotatorOnContact(b, dt, worldRef, "mechanical"); if (b.type === "rotator") brakeRotatorOnContact(b, dt, worldRef, "mechanical");
@ -1052,8 +1052,13 @@
let changed = false; let changed = false;
for (const mechRect of rects) { for (const mechRect of rects) {
for (const fenceRect of fences) { 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); let info = rectOverlapInfo(mechRect, fenceRect, item.type === "rotator" ? 1.35 : 0.5);
if (!info) continue; if (!info) continue;
if (item.type === "reciprocator") reverseReciprocatorOnContact(item, info.nx, info.ny, dt, worldRef, "solid-obstacle");
const separated = applyMechanicalFenceSeparation(item, info, dt, worldRef); const separated = applyMechanicalFenceSeparation(item, info, dt, worldRef);
if (separated) { if (separated) {
const post = strongestRectContact(obstacleRects(item), [fenceRect], 0.2); const post = strongestRectContact(obstacleRects(item), [fenceRect], 0.2);
@ -1066,7 +1071,6 @@
applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.92 : 1.10); 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); applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.82);
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "fence"); 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; changed = true;
} }
} }
@ -1102,8 +1106,8 @@
function updateRotator(item, dt, worldRef, opts = {}) { function updateRotator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "rotator") return false; if (!item || item.dead || item.type !== "rotator") return false;
applyBodyState(item); if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, "rotator-step", () => { return runMechanicalStep(item, () => {
item.amount = 999; item.amount = 999;
if (item.playerHeld || item._heldByPlayer) return false; if (item.playerHeld || item._heldByPlayer) return false;
const powered = isPowered(item); const powered = isPowered(item);
@ -1199,7 +1203,7 @@
const p = positionFromPhase(item); const p = positionFromPhase(item);
item.x = p.x; item.x = p.x;
item.y = p.y; item.y = p.y;
commitBody(item, "reciprocator-stall-repair"); commitBody(item);
worldRef?.markSpatialDirty?.("reciprocator-stall-repair"); worldRef?.markSpatialDirty?.("reciprocator-stall-repair");
if (worldRef) worldRef.drawListDirty = true; if (worldRef) worldRef.drawListDirty = true;
return true; return true;
@ -1207,8 +1211,8 @@
function updateReciprocator(item, dt, worldRef, opts = {}) { function updateReciprocator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "reciprocator") return false; if (!item || item.dead || item.type !== "reciprocator") return false;
applyBodyState(item); if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, "reciprocator-step", () => { return runMechanicalStep(item, () => {
item.amount = 999; item.amount = 999;
resetAnchor(item); resetAnchor(item);
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; } if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
@ -1282,10 +1286,8 @@
const beforeX = num(t.x); const beforeX = num(t.x);
const beforeY = num(t.y); const beforeY = num(t.y);
const pushed = worldRef.pushTarinaiOutOfRect?.(t, rect, rr, { const pushed = worldRef.pushTarinaiOutOfRect?.(t, rect, rr, {
maxPasses: 1,
maxPush: Math.max(14, rr * 1.65), maxPush: Math.max(14, rr * 1.65),
slop: 0.45, slop: 0.45,
reason: "poison-block-direct-tarinai-contact",
}); });
if (!pushed) continue; if (!pushed) continue;
contacts += 1; contacts += 1;
@ -1310,7 +1312,7 @@
if (contacts >= 10) break; if (contacts >= 10) break;
} }
if (changed) { if (changed) {
commitBody(item, "poison-block-tarinai-contact"); commitBody(item);
worldRef.markSpatialDirty?.("poison-block-tarinai-contact"); worldRef.markSpatialDirty?.("poison-block-tarinai-contact");
worldRef.drawListDirty = true; worldRef.drawListDirty = true;
} }
@ -1319,8 +1321,8 @@
function updatePoisonBlock(item, dt, worldRef, opts = {}) { function updatePoisonBlock(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "poison_block") return false; if (!item || item.dead || item.type !== "poison_block") return false;
applyBodyState(item); if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, "poison-block-step", () => { return runMechanicalStep(item, () => {
item.amount = 999; item.amount = 999;
item.r = Math.max(item.r || 64, Math.min(460, extent(item))); item.r = Math.max(item.r || 64, Math.min(460, extent(item)));
if (item.playerHeld || item._heldByPlayer) { if (item.playerHeld || item._heldByPlayer) {
@ -1390,9 +1392,14 @@
function collectMechanicalBodies(worldRef, opts = {}) { function collectMechanicalBodies(worldRef, opts = {}) {
if (!worldRef?.itemsOfType) return []; if (!worldRef?.itemsOfType) return [];
worldRef.ensureItemBuckets?.("mechanical-world"); 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 out = [];
const seen = new Set(); const seen = new Set();
const maxItems = Math.max(24, Number(opts.maxItems || 220) || 220);
const add = (item) => { const add = (item) => {
if (!item || item.dead || seen.has(item)) return false; if (!item || item.dead || seen.has(item)) return false;
item.world = worldRef; item.world = worldRef;
@ -1413,10 +1420,12 @@
for (const type of ["rotator", "poison_block", "reciprocator"]) { for (const type of ["rotator", "poison_block", "reciprocator"]) {
for (const item of worldRef.itemsOfType(type) || []) { for (const item of worldRef.itemsOfType(type) || []) {
if (!item || item.dead || seen.has(item)) continue; if (!item || item.dead || seen.has(item)) continue;
if (out.length >= maxItems) return out; if (out.length >= maxItems) break;
add(item); add(item);
} }
if (out.length >= maxItems) break;
} }
worldRef._mechanicalBodiesCache = { key: cacheKey, items: out.slice() };
return out; return out;
} }
@ -1602,7 +1611,7 @@
const grid = gridInfo.grid; const grid = gridInfo.grid;
const seen = Object.create(null); const seen = Object.create(null);
const maxPairs = Math.max(24, Number(opts.maxPairs || 240) || 240); 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 = []; const pairCandidates = [];
let pairs = 0; let pairs = 0;
for (const bucket of grid.values()) { for (const bucket of grid.values()) {
@ -1650,9 +1659,8 @@
// Fences are static in this runtime. A sleeping poison block or passive body // 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. // cannot create a new fence contact by itself, so skip it before expensive geometry work.
if (activeSet && !activeSet.has(item)) continue; if (activeSet && !activeSet.has(item)) continue;
// Reciprocators are rail-driven and should only reverse/stop against // Rail-driven reciprocators also participate here. A solid obstacle in
// other physical items, never against static fences. // their travel path is a collision and reverses the motor direction.
if (item.type === "reciprocator") continue;
if (!shouldCollideMechanical(item)) continue; if (!shouldCollideMechanical(item)) continue;
if (resolveFenceContacts(item, dt, worldRef, reach(item) + 80)) solved += 1; if (resolveFenceContacts(item, dt, worldRef, reach(item) + 80)) solved += 1;
count += 1; count += 1;
@ -1689,6 +1697,9 @@
const clampedDt = Math.max(0.001, Math.min(0.05, rawDt)); const clampedDt = Math.max(0.001, Math.min(0.05, rawDt));
const bodies = collectMechanicalBodies(worldRef, opts); const bodies = collectMechanicalBodies(worldRef, opts);
if (!bodies.length) return { ran: 0, active: 0, solved: 0, fenceSolved: 0, links: 0, candidates: 0 }; 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 = []; const active = [];
for (const item of bodies) if (isMechanicallyActive(item)) active.push(item); for (const item of bodies) if (isMechanicallyActive(item)) active.push(item);
const movingWork = active.length; const movingWork = active.length;
@ -1696,7 +1707,7 @@
for (const item of active) maxMotion = Math.max(maxMotion, estimatedBodyMotion(item, clampedDt)); for (const item of active) maxMotion = Math.max(maxMotion, estimatedBodyMotion(item, clampedDt));
let fenceSensitiveMotion = false; let fenceSensitiveMotion = false;
for (const item of active) { 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 requestedMaxSubsteps = Number(opts.maxSubsteps || 4) || 4;
const substepCap = fenceSensitiveMotion ? Math.min(6, Math.max(requestedMaxSubsteps, requestedMaxSubsteps + 2)) : requestedMaxSubsteps; const substepCap = fenceSensitiveMotion ? Math.min(6, Math.max(requestedMaxSubsteps, requestedMaxSubsteps + 2)) : requestedMaxSubsteps;
@ -1717,9 +1728,9 @@
for (const item of active) { for (const item of active) {
if (!item || item.dead || item.playerHeld || item._heldByPlayer) continue; if (!item || item.dead || item.playerHeld || item._heldByPlayer) continue;
let changed = false; let changed = false;
if (item.type === "rotator") changed = updateRotator(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 }); 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 }); 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 (changed) { moved = true; subMoved = true; ran += 1; }
} }
if (subMoved) { if (subMoved) {
@ -1787,7 +1798,7 @@
const pose = bodyPose(item); const pose = bodyPose(item);
const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || (stableUnit(item.id || item.seed || "rotator", "poke-side") < 0.5 ? -1 : 1); const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || (stableUnit(item.id || item.seed || "rotator", "poke-side") < 0.5 ? -1 : 1);
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 + side * 0.32, -1.6, 1.6), "pair-spin-response"); 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"); worldRef?.markSpatialDirty?.("poke-passive-rotator");
return true; return true;
} }
@ -1796,7 +1807,7 @@
const pose = bodyPose(item); const pose = bodyPose(item);
const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || (stableUnit(item.id || item.seed || "reciprocator", "poke-side") < 0.5 ? -1 : 1); const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || (stableUnit(item.id || item.seed || "reciprocator", "poke-side") < 0.5 ? -1 : 1);
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 + side * 34, -115, 115), "pair-slide-response"); 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"); worldRef?.markSpatialDirty?.("poke-passive-reciprocator");
return true; return true;
} }

View file

@ -17,15 +17,109 @@
let frameOpen = null; let frameOpen = null;
let frameCount = 0; let frameCount = 0;
let lastSmoothAt = 0; let lastSmoothAt = 0;
const storageKey = "tarinai_visual_settings_v1"; const storageKey = "tarinai_visual_settings_v2";
const legacyStorageKey = "tarinai_visual_settings_v1";
const allowed = { const allowed = {
effects: new Set(["off", "high"]), effects: new Set(["off", "high"]),
shadows: new Set(["off", "high"]), shadows: new Set(["off", "high"]),
details: new Set(["low", "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 visualSettings = loadVisualSettings();
let resizeRequested = false; let resizeRequested = false;
let autoProfileId = "full";
let fpsAccumSeconds = 0;
let fpsAccumFrames = 0;
let smoothedFps = 0;
let pendingProfileId = "";
let pendingProfileSamples = 0;
function bucketFor(label) { function bucketFor(label) {
let b = buckets.get(label); let b = buckets.get(label);
@ -43,7 +137,6 @@
return () => { return () => {
const top = stack.pop(); const top = stack.pop();
const active = top === frame ? frame : top || frame; const active = top === frame ? frame : top || frame;
// If calls ended out of order, keep the profiler alive rather than throwing.
if (top !== frame) { if (top !== frame) {
const idx = stack.lastIndexOf(frame); const idx = stack.lastIndexOf(frame);
if (idx >= 0) stack.splice(idx, 1); if (idx >= 0) stack.splice(idx, 1);
@ -86,8 +179,8 @@
if (raw === "on" || raw === "low" || raw === "mid" || raw === "high") return "high"; if (raw === "on" || raw === "low" || raw === "mid" || raw === "high") return "high";
} }
if (key === "details") { if (key === "details") {
if (raw === "low") return "low"; if (raw === "auto" || raw === "low" || raw === "high") return raw;
if (raw === "mid" || raw === "high") return "high"; if (raw === "mid") return "auto";
} }
return defaults[key]; return defaults[key];
} }
@ -104,8 +197,15 @@
function loadVisualSettings() { function loadVisualSettings() {
try { try {
const raw = global.localStorage?.getItem(storageKey); const current = global.localStorage?.getItem(storageKey);
return sanitizeVisualSettings(raw ? JSON.parse(raw) : null); 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 (_) { } catch (_) {
return { ...defaults }; return { ...defaults };
} }
@ -117,9 +217,63 @@
} catch (_) {} } catch (_) {}
} }
function dprScaleForDetails(details) { function activeProfile() {
if (details === "low") return 0.72; if (visualSettings.details === "high") return PROFILE_FULL;
return 1; 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() { function endFrame() {
@ -144,6 +298,12 @@
} }
function visualLevel(key, fallback = "high") { 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; return visualSettings[key] || fallback;
} }
@ -156,15 +316,23 @@
const nextValue = normalizeVisualSetting(key, value); const nextValue = normalizeVisualSetting(key, value);
if (!allowed[key]?.has(nextValue)) return false; if (!allowed[key]?.has(nextValue)) return false;
if (visualSettings[key] === nextValue) return true; if (visualSettings[key] === nextValue) return true;
const before = activeProfile();
visualSettings = { ...visualSettings, [key]: nextValue }; visualSettings = { ...visualSettings, [key]: nextValue };
saveVisualSettings(); 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; return true;
} }
function renderQualityTier() { return visualSettings.details; } function renderQualityTier() { return activeProfile().renderTier; }
function shadowQualityTier() { return visualSettings.shadows; } function shadowQualityTier() {
function dprScaleValue() { return dprScaleForDetails(visualSettings.details); } 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() { function consumeResizeRequest() {
const v = resizeRequested; const v = resizeRequested;
resizeRequested = false; resizeRequested = false;
@ -191,9 +359,13 @@
})); }));
const topInclusive = entries.slice().sort((a, b) => b.avg - a.avg).slice(0, 12); 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 topSelf = entries.slice().sort((a, b) => b.selfAvg - a.selfAvg).slice(0, 12);
const profile = activeProfile();
return { return {
enabled: debugEnabled, enabled: debugEnabled,
tier: visualSettings.details, tier: profile.renderTier,
profile: profile.id,
autoProfile: autoProfileId,
fps: Number(smoothedFps.toFixed(1)),
visual: visualSettingsValue(), visual: visualSettingsValue(),
dprScale: dprScaleValue(), dprScale: dprScaleValue(),
frameCount, frameCount,
@ -209,12 +381,15 @@
begin, begin,
beginFrame, beginFrame,
endFrame, endFrame,
observeFrameDelta,
renderQualityTier, renderQualityTier,
shadowQualityTier, shadowQualityTier,
visualLevel, visualLevel,
visualSettings: visualSettingsValue, visualSettings: visualSettingsValue,
setVisualSetting, setVisualSetting,
dprScale: dprScaleValue, dprScale: dprScaleValue,
performanceProfile,
simulationOptimizationTier,
consumeResizeRequest, consumeResizeRequest,
setMetric, setMetric,
snapshot, snapshot,

View file

@ -4,6 +4,7 @@
const Geometry = global.TarinaiGeometry; const Geometry = global.TarinaiGeometry;
function circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts = {}) { 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 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 minBounceSpeed = Math.max(0, Number(opts.minBounceSpeed ?? rect.minBounceSpeed ?? 0) || 0);
const local = Geometry.rectLocalPoint(rect, obj.x, obj.y); const local = Geometry.rectLocalPoint(rect, obj.x, obj.y);
@ -55,22 +56,7 @@
obj.y = (rect.cy || 0) + worldClosest.y + ny * (hitRadius + 0.5); obj.y = (rect.cy || 0) + worldClosest.y + ny * (hitRadius + 0.5);
const vx = obj.vx || 0; const vx = obj.vx || 0;
const vy = obj.vy || 0; const vy = obj.vy || 0;
const toward = vx * nx + vy * ny; applyNormalBounce(obj, nx, ny, vx, vy, restitution, 36, minBounceSpeed, rect.mechanical ? 0 : 7.0, 46);
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;
}
}
if (rect.mechanical) { if (rect.mechanical) {
global.TarinaiMechanicalSystem.applySurfaceVelocityToCircle(obj, rect, nx, ny, vx, vy, { global.TarinaiMechanicalSystem.applySurfaceVelocityToCircle(obj, rect, nx, ny, vx, vy, {
damping: 1, damping: 1,
@ -80,7 +66,7 @@
passiveImpulseScale: 1.0, passiveImpulseScale: 1.0,
spinKick: 7.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; return true;
} }
@ -121,7 +107,7 @@
} }
function bounceCircleOffRect(obj, rect, radius, restitution = 2.40, worldRef = null, opts = {}) { function bounceCircleOffRect(obj, rect, radius, restitution = 4.20, worldRef = null, opts = {}) {
if (!obj || !rect) return false; if (!obj || !rect) return false;
if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts); if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts);
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12); const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
@ -194,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 }) || []; 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) { for (const rect of rects) {
if (!rect?.bounce) continue; if (!rect?.bounce) continue;
if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 2.40), worldRef, opts)) { if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 4.20), worldRef, opts)) {
bounced = true; bounced = true;
const maxSpeed = opts.bounceFenceMaxSpeed ?? 1180; const maxSpeed = opts.bounceFenceMaxSpeed ?? 1680;
item.vx = clamp(item.vx || 0, -maxSpeed, maxSpeed); item.vx = clamp(item.vx || 0, -maxSpeed, maxSpeed);
item.vy = clamp(item.vy || 0, -maxSpeed, maxSpeed); item.vy = clamp(item.vy || 0, -maxSpeed, maxSpeed);
const now = worldRef.time || 0; const now = worldRef.time || 0;
@ -247,5 +233,14 @@
return speed; 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); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -5,7 +5,7 @@
// Physical state lives only in item.physicsBody / item.physicsConstraint. // Physical state lives only in item.physicsBody / item.physicsConstraint.
(function (global) { (function (global) {
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]); 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) { function num(value, fallback = 0) {
const n = Number(value); const n = Number(value);
@ -96,7 +96,7 @@
out.velocity.angular = num(out.velocity.angular); out.velocity.angular = num(out.velocity.angular);
out.velocity.linear = num(out.velocity.linear); out.velocity.linear = num(out.velocity.linear);
out.motor.powered = type === "poison_block" ? false : out.motor.powered !== false; 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; out.motor.direction = Math.sign(num(out.motor.direction, 1)) || 1;
if (out.rail) { if (out.rail) {
out.rail.axisAngle = num(out.rail.axisAngle, out.pose.angle); out.rail.axisAngle = num(out.rail.axisAngle, out.pose.angle);
@ -137,7 +137,6 @@
item.world = worldRef || item.world || null; item.world = worldRef || item.world || null;
if (!item.physicsBody || typeof item.physicsBody !== "object" || item.physicsBody.type !== item.type || opts.rebuild === true) { if (!item.physicsBody || typeof item.physicsBody !== "object" || item.physicsBody.type !== item.type || opts.rebuild === true) {
item.physicsBody = defaultBody(item); item.physicsBody = defaultBody(item);
markBodyChanged(item, opts.rebuild === true ? "rebuild" : "init");
} else if (itemPoseShouldFeedBody(item, opts)) { } else if (itemPoseShouldFeedBody(item, opts)) {
const sx = num(item.x); const sx = num(item.x);
const sy = num(item.y); const sy = num(item.y);
@ -148,13 +147,7 @@
item.physicsBody.pose.angle = sa; item.physicsBody.pose.angle = sa;
item._physicsExternalPoseDirty = false; item._physicsExternalPoseDirty = false;
} }
const body = normalizeBody(item.physicsBody, item); return item.physicsBody;
item.physicsBody = body;
return body;
}
function markBodyChanged(item, reason = "physics-body") {
if (!item) return;
} }
function scalar(item, key, fallback = 0) { function scalar(item, key, fallback = 0) {
@ -196,9 +189,9 @@
b.sleep = b.sleep || { awakeUntil: 0 }; b.sleep = b.sleep || { awakeUntil: 0 };
if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 }; if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 };
switch (key) { switch (key) {
case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 11 : 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 "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 "spin": b.velocity.angular = num(value); break;
case "xv": b.velocity.x = num(value); break; case "xv": b.velocity.x = num(value); break;
case "yv": b.velocity.y = num(value); break; case "yv": b.velocity.y = num(value); break;
@ -218,9 +211,6 @@
case "awakeUntil": b.sleep.awakeUntil = num(value); break; case "awakeUntil": b.sleep.awakeUntil = num(value); break;
default: return false; default: return false;
} }
normalizeBody(b, item);
markBodyChanged(item, reason);
if (key === "thickness" || key === "solid") global.TarinaiMechanicalSystem.invalidateGeometry(item);
return true; return true;
} }
@ -231,9 +221,6 @@
b.shape = b.shape || {}; b.shape = b.shape || {};
b.shape.model = "segments"; b.shape.model = "segments";
b.shape.segments = cloneSegments(next, defaultSegments(item.type)); 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; return true;
} }
function syncPoseFromItem(item) { function syncPoseFromItem(item) {
@ -244,7 +231,9 @@
const sx = num(item.x); const sx = num(item.x);
const sy = num(item.y); const sy = num(item.y);
const sa = num(item.angle); 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; if (!b) return null;
b.pose = b.pose || { x: sx, y: sy, angle: sa }; b.pose = b.pose || { x: sx, y: sy, angle: sa };
b.pose.x = sx; b.pose.x = sx;
@ -256,7 +245,9 @@
return b; return b;
} }
function applyPoseToItem(item) { 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; if (!b) return false;
const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`; 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); item.x = num(b.pose?.x, item.x);
@ -292,7 +283,7 @@
const x = num(item?.x), y = num(item?.y); const x = num(item?.x), y = num(item?.y);
return { return {
type: item?.type, 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], endpoints: [null, null],
length: 80, length: 80,
mid: { x, y: y + 10, vx: 0, vy: 0 }, 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 = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep;
out.sleep.awakeUntil = num(out.sleep.awakeUntil); out.sleep.awakeUntil = num(out.sleep.awakeUntil);
out.particles = Array.isArray(out.particles) ? out.particles : null; 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; return out;
} }
function ensureConstraint(item, worldRef = null, opts = {}) { 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) { if (!item.physicsConstraint || typeof item.physicsConstraint !== "object" || item.physicsConstraint.type !== item.type || opts.rebuild === true) {
item.physicsConstraint = defaultConstraint(item); item.physicsConstraint = defaultConstraint(item);
} }
item.physicsConstraint = normalizeConstraint(item.physicsConstraint, item);
return item.physicsConstraint; return item.physicsConstraint;
} }
function endpoint(item, index = 0) { return ensureConstraint(item, item?.world || null, { syncFromLegacy: false })?.endpoints?.[index] || null; } function endpoint(item, index = 0) { return ensureConstraint(item, item?.world || null, { syncFromLegacy: false })?.endpoints?.[index] || null; }
@ -377,36 +367,63 @@
return true; 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 bodies = [];
const constraints = []; const constraints = [];
if (!worldRef?.itemsOfType) return { bodies, constraints }; if (!worldRef?.itemsOfType) return { bodies, constraints };
worldRef.ensureItemBuckets?.("physics-world-system"); 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); const maxBodies = Math.max(1, Number(opts.maxItems || Infinity) || Infinity);
for (const type of LINK_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) constraints.push(item); 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 }; return { bodies, constraints };
} }
function prepareWorld(worldRef, opts = {}) { function prepareWorld(worldRef, opts = {}) {
const { bodies, constraints } = collectPhysicsItems(worldRef); const { bodies, constraints } = collectPhysicsItems(worldRef, opts);
for (const item of bodies) ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true }); for (const item of bodies) {
for (const item of constraints) ensureConstraint(item, worldRef, { syncFromLegacy: false }); ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true });
const frame = { time: num(worldRef?.time), bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length, sync: { bodyReads: 0, bodyWrites: 0, constraintReads: 0, constraintWrites: 0 } }; normalizeBodyState(item);
worldRef._physicsWorldFrame = frame;
return frame;
} }
function commitWorld(worldRef, frame = worldRef?._physicsWorldFrame, opts = {}) { for (const item of constraints) {
if (!frame) return { bodies: 0, constraints: 0, moved: 0 }; ensureConstraint(item, worldRef, { syncFromLegacy: false });
let moved = 0; normalizeConstraintState(item);
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;
} }
for (const item of frame.constraints || []) ensureConstraint(item, worldRef, { syncFromLegacy: false }); return { bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length };
if (opts.markDirty && moved) worldRef?.markSpatialDirty?.("physics-world-commit");
return { bodies: frame.bodies?.length || 0, constraints: frame.constraints?.length || 0, moved };
} }
function updateWorld(worldRef, dt = 0.016, opts = {}) { function updateWorld(worldRef, dt = 0.016, opts = {}) {
if (!worldRef?.itemsOfType) return { ran: 0, bodies: 0, constraints: 0 }; if (!worldRef?.itemsOfType) return { ran: 0, bodies: 0, constraints: 0 };
@ -414,11 +431,21 @@
const end = profiler.begin("update.physicsWorld.central") || null; const end = profiler.begin("update.physicsWorld.central") || null;
try { try {
const frame = prepareWorld(worldRef, opts); 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 mechanicalStats = global.TarinaiMechanicalSystem.updateWorld(worldRef, dt, {
const constraintRan = global.TarinaiConstraintSystem.updateWorld(worldRef, dt, { maxLinks: opts.maxLinks || 220 }) || 0; maxPairs: opts.maxPairs || 320,
const postConstraintPairs = constraintRan ? (global.TarinaiMechanicalSystem.resolveMechanicalPairs(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0; maxLinks: 0,
const committed = commitWorld(worldRef, frame); skipLinks: true,
const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, postConstraintPairs, committed, sync: frame.sync || null }; 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; return stats;
} finally { if (end) end(); } } finally { if (end) end(); }
} }
@ -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) }); 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 : []; 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; return true;
} }
function applyCompactExtra(item, extra, tarinaiList = [], itemList = []) { function applyCompactExtra(item, extra, tarinaiList = [], itemList = []) {
@ -500,7 +527,6 @@
if (isBodyType(item)) { if (isBodyType(item)) {
ensureBody(item, item.world || null, { rebuild: false, syncFromLegacy: false }); ensureBody(item, item.world || null, { rebuild: false, syncFromLegacy: false });
global.TarinaiMechanicalSystem.invalidateGeometry(item); global.TarinaiMechanicalSystem.invalidateGeometry(item);
markBodyChanged(item, reason);
return true; return true;
} }
if (isConstraintType(item)) { if (isConstraintType(item)) {
@ -510,17 +536,6 @@
} }
return false; 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. // Schema normalization entry points.
const normalizeBodyState = (item, body = item?.physicsBody) => { if (item && body) { item.physicsBody = normalizeBody(body, item); return item.physicsBody; } return null; }; const normalizeBodyState = (item, body = item?.physicsBody) => { if (item && body) { item.physicsBody = normalizeBody(body, item); return item.physicsBody; } return null; };
const applyBodyState = (item, body = item?.physicsBody, opts = {}) => { if (item && body) { item.physicsBody = normalizeBody(body, item); if (opts.invalidateShape === true) global.TarinaiMechanicalSystem.invalidateGeometry(item); return applyPoseToItem(item); } return false; }; const applyBodyState = (item, body = item?.physicsBody, opts = {}) => { if (item && body) { item.physicsBody = normalizeBody(body, item); if (opts.invalidateShape === true) global.TarinaiMechanicalSystem.invalidateGeometry(item); return applyPoseToItem(item); } return false; };
@ -530,8 +545,8 @@
const bodyApi = Object.freeze({ const bodyApi = Object.freeze({
isBodyType, isConstraintType, isPhysicsType, isBodyType, isConstraintType, isPhysicsType,
ensureBody, ensureConstraint, ensureBody, ensureConstraint,
normalizeBodyState, applyBodyState, markBodyChanged, normalizeConstraintState, applyConstraintState, normalizeBodyState, applyBodyState, normalizeConstraintState, applyConstraintState,
compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage, compactItemExtra, applyCompactExtra,
invalidateItem, invalidateItem,
scalar, setScalar, segments, setSegments, syncPoseFromItem, scalar, setScalar, segments, setSegments, syncPoseFromItem,
endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles, endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles,

View file

@ -27,10 +27,11 @@
function defaultAngle(type) { function defaultAngle(type) {
return global.defaultItemAngle(type); return global.defaultItemAngle(type);
} }
function rectOutside(worldRef, rect) { function rectOutside(worldRef, rect, itemType = "") {
if (!worldRef || !rect) return true; if (!worldRef || !rect) return true;
const pad = global.CONFIG?.worldPadding || 30; 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) { function circleOutside(worldRef, x, y, radius) {
if (!worldRef) return true; if (!worldRef) return true;
@ -50,6 +51,34 @@
function footprintRadius(worldRef, item, rects = null) { function footprintRadius(worldRef, item, rects = null) {
return global.TarinaiCollisionFootprints.itemFootprintRadius(worldRef, item, rects); 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) { function ensureMechanicalPreview(item, worldRef) {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return item; if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return item;
const pb = global.TarinaiPhysicsBodySystem; const pb = global.TarinaiPhysicsBodySystem;
@ -86,19 +115,17 @@
item.amount = item.foodServingsRemaining; item.amount = item.foodServingsRemaining;
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false); (typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
} }
if (isRotatable(type)) item.angle = opts.angle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type)); if (isRotatable(type)) applyToolOrientation(worldRef, item, opts.angle);
if (type === "reciprocator") {
const axis = item.angle ?? defaultAngle(type);
global.TarinaiPhysicsBodySystem.setScalar(item, "railAxis", axis, "preview-axis");
item.angle = defaultAngle(type);
}
ensureMechanicalPreview(item, worldRef); ensureMechanicalPreview(item, worldRef);
return item; return item;
} }
function boundsBlocked(worldRef, item, opts = {}) { function boundsBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true; if (!worldRef || !item || item.dead) return true;
const rects = opts.rects || rectsFor(worldRef, item); 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 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)); 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); return circleOutside(worldRef, item.x, item.y, radius);
@ -110,14 +137,16 @@
if (rects.length) { if (rects.length) {
let left = 0, right = 0, top = 0, bottom = 0; let left = 0, right = 0, top = 0, bottom = 0;
for (const rect of rects) { for (const rect of rects) {
const aabb = orientedAabb(rect); const aabb = global.isFenceItemType?.(item.type) ? rect : orientedAabb(rect);
if (!aabb) continue; if (!aabb) continue;
left = Math.max(left, num(item.x) - num(aabb.left)); left = Math.max(left, num(item.x) - num(aabb.left));
right = Math.max(right, num(aabb.right) - num(item.x)); right = Math.max(right, num(aabb.right) - num(item.x));
top = Math.max(top, num(item.y) - num(aabb.top)); top = Math.max(top, num(item.y) - num(aabb.top));
bottom = Math.max(bottom, num(aabb.bottom) - num(item.y)); 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)); 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)) }; 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) { function geometryFor(worldRef, item) {
if (!worldRef || !item) return null; if (!worldRef || !item) return null;
ensureMechanicalPreview(item, worldRef); 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: [] }; return { rect: worldRef.fenceRect(item), rects: [] };
} }
if ((item.type === "nest_box" || item.type === "pipe") && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] }; if ((item.type === "nest_box" || 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 }; return { type, x, y, r, rect: geometry.rect || null, rects: geometry.rects || [], influence: extra.influence || null, blocked, item: tmp };
} }
global.TarinaiPlacementPreviewSystem = Object.freeze({ global.TarinaiPlacementPreviewSystem = Object.freeze({
applyToolOrientation,
boundsBlocked, boundsBlocked,
clampPointFor, clampPointFor,
forItem: previewForItem, forItem: previewForItem,

View file

@ -229,7 +229,7 @@ function drawToolTargetHoverHighlight(ctx, world) {
if (isTarinaiTarget) { if (isTarinaiTarget) {
const r = Math.max(17, target.radius || 22); const r = Math.max(17, target.radius || 22);
strokeEllipse(target.x || 0, target.y || 0, r * 1.02, r * 0.80); 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 runtime = TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime;
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world); const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world);
@ -536,6 +536,37 @@ function drawPlacementPreview(ctx, world) {
ctx.stroke(); ctx.stroke();
} }
ctx.setLineDash([]); 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.globalAlpha = blocked ? 0.52 : 0.70;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x - 8, y); ctx.moveTo(x - 8, y);
@ -547,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) { function drawOperationToolPreview(ctx, world) {
const p = world?.pointer; const p = world?.pointer;
if (!p?.inside) return; if (!p?.inside) return;
@ -603,16 +685,16 @@ function drawOperationToolPreview(ctx, world) {
return; 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 runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(world.pendingLinkEndpoint, world) || world.pendingLinkEndpoint; const a = runtime?.endpointWorld?.(world.pendingLinkEndpoint, world) || world.pendingLinkEndpoint;
ctx.save(); ctx.save();
ctx.globalAlpha = 0.72; ctx.globalAlpha = 0.72;
ctx.lineWidth = 3.2; ctx.lineWidth = 3.2;
ctx.lineCap = "round"; 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([]); 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.beginPath();
ctx.moveTo(a.x, a.y); ctx.moveTo(a.x, a.y);
ctx.lineTo(p.x, p.y); ctx.lineTo(p.x, p.y);
@ -748,8 +830,41 @@ function isCachedTerrainItem(it) {
return false; 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) { 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) { function renderLayerSortY(entity) {
@ -941,7 +1056,9 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
const seen = new Set(); const seen = new Set();
const addItem = (it) => { const addItem = (it) => {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef); 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); seen.add(it);
if (isPinType(it.type) && it.pinState === "lodged") { if (isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it); stack.lodgedPins.push(it);
@ -973,6 +1090,13 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch); worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch); if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
for (const it of itemScratch) addItem(it); 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 = []); const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
tarinaiScratch.length = 0; tarinaiScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch); worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
@ -981,7 +1105,13 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
antScratch.length = 0; antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch); worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
for (const a of antScratch) addLayered(a); for (const a of antScratch) addLayered(a);
const plushieCandidates = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("plushie") : (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 || []) { for (const it of plushieCandidates || []) {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it); if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
} }
@ -1420,9 +1550,7 @@ function renderVisualLevel(kind, fallback = "high") {
function visualEffectLimit(level) { function visualEffectLimit(level) {
if (level === "off") return 0; if (level === "off") return 0;
if (level === "low") return 36; return Math.max(0, Number(TARINAI_RENDER_GLOBAL.TarinaiPerf?.performanceProfile?.().effectDrawBudget || 96));
if (level === "mid") return 96;
return Infinity;
} }
function fillScreenFallback(ctx, w, h, lighting) { function fillScreenFallback(ctx, w, h, lighting) {
@ -1527,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"); const endLayered = renderPerfBegin("render.draw.layered");
for (const entry of renderStack.layered) { for (const entry of renderStack.layered) {
entry.entity.draw(ctx, world.time, lighting); entry.entity.draw(ctx, world.time, lighting);
drawAttachedPinsForEntity(entry.entity); 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); renderPerfEnd(endLayered);
const endAttachments = renderPerfBegin("render.draw.attachments"); 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 || []) { for (const t of renderStack.champions || []) {
t.drawChampionCrown(ctx, world.time, lighting); t.drawChampionCrown(ctx, world.time, lighting);
} }
@ -1562,8 +1700,10 @@ function render() {
const maxEffects = visualEffectLimit(effectLevel); const maxEffects = visualEffectLimit(effectLevel);
const effects = world.effects || []; const effects = world.effects || [];
const totalEffects = effects.length; const totalEffects = effects.length;
let effectScanCap = 0;
if (totalEffects > 0 && maxEffects > 0) { if (totalEffects > 0 && maxEffects > 0) {
const scanCap = totalEffects > 180 ? Math.min(totalEffects, Math.max(maxEffects * 8, 160)) : totalEffects; 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; let cursor = totalEffects > 180 ? (Math.max(0, Math.floor(world._renderEffectCursor || 0)) % totalEffects) : 0;
for (let scanned = 0; scanned < scanCap && drawnEffects < maxEffects; scanned += 1) { for (let scanned = 0; scanned < scanCap && drawnEffects < maxEffects; scanned += 1) {
const ef = effects[cursor]; const ef = effects[cursor];
@ -1575,8 +1715,19 @@ function render() {
} }
if (totalEffects > 180) world._renderEffectCursor = cursor; 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.visibleEffects", drawnEffects);
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
renderPerfEnd(endEffects); renderPerfEnd(endEffects);
drawPressureSwitchRangeOverlay(ctx, world);
ctx.restore(); ctx.restore();
// Light affecting the whole visible scene, including characters and ground. // Light affecting the whole visible scene, including characters and ground.

View file

@ -309,6 +309,17 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
if (!item || item.dead || !worldRef) return false; if (!item || item.dead || !worldRef) return false;
normalizeRobotCleanerState(item); normalizeRobotCleanerState(item);
dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0)); 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.robotCleanerCleanFlash = Math.max(0, Number(item.robotCleanerCleanFlash || 0) - dt * 2.8);
item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt); item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt);
let target = currentRobotTarget(item, worldRef); let target = currentRobotTarget(item, worldRef);

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,15 @@
"use strict"; "use strict";
// Layer: persistence/schema // Layer: persistence/schema
// Current-format save identifiers only. Old save compatibility is intentionally not retained. // Current-format identifiers. Schema 35 removes warp manual power and expands gate channels to nine.
(function (global) { (function (global) {
const ids = global.TarinaiDomainIds; const ids = global.TarinaiDomainIds;
const itemCatalog = global.TarinaiItemTypeCatalog; const itemCatalog = global.TarinaiItemTypeCatalog;
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js"); 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"); if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
const SNAPSHOT_VERSION = 23; const SNAPSHOT_VERSION = 30;
const BINARY_SCHEMA_VERSION = 28; const BINARY_SCHEMA_VERSION = 36;
function moodValue(index, fallback = "relaxed") { function moodValue(index, fallback = "relaxed") {
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback)); return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));

View file

@ -52,6 +52,63 @@
return `${date} / ${fieldType} / ${day}\u65e5\u76ee / ${population}\u5339${hashLen}`; 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() { function ensureDialog() {
let dialog = document.getElementById("saveDialog"); let dialog = document.getElementById("saveDialog");
if (dialog) return dialog; if (dialog) return dialog;
@ -142,7 +199,7 @@
} }
if (loadBtn) { if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot); 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 { try {
await loadSlot(slot); await loadSlot(slot);
closeDialog(); closeDialog();
@ -165,19 +222,40 @@
} }
if (e.target.closest("#saveCloseBtn")) closeDialog(); if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) { if (e.target.closest("#saveExportBtn")) {
const exportBtn = dialog.querySelector("#saveExportBtn");
const area = dialog.querySelector("#saveHashText"); const area = dialog.querySelector("#saveHashText");
if (exportBtn?.dataset.busy === "1") return;
if (exportBtn) {
exportBtn.dataset.busy = "1";
exportBtn.disabled = true;
exportBtn.textContent = "\u751f\u6210\u4e2d...";
}
try {
const hash = await encodeSnapshot(createSnapshot(global.world));
if (!hash || typeof hash !== "string") throw new Error("save text was not generated");
if (area) { if (area) {
area.value = await encodeSnapshot(createSnapshot(global.world)); area.value = hash;
area.focus(); area.focus();
area.select(); area.select();
} }
global.showToast?.("\u546a\u6587\u3092\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002"); 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")) { if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText"); const area = dialog.querySelector("#saveHashText");
const text = area?.value || ""; 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 (!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 { try {
restoreSnapshot(await decodeSnapshot(text), global.world); restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog(); 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.beginPath();
ctx.arc(0, 0, s * 0.68 + (1 - alpha) * this.size * 0.65, 0, Math.PI * 2); ctx.arc(0, 0, s * 0.68 + (1 - alpha) * this.size * 0.65, 0, Math.PI * 2);
ctx.stroke(); 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") { } else if (this.type === "fight") {
ctx.lineCap = "round"; ctx.lineCap = "round";
ctx.lineJoin = "round"; ctx.lineJoin = "round";
@ -623,6 +658,9 @@ class Effect {
} }
} }
// Explicit export for systems implemented inside module-style IIFEs.
globalThis.TarinaiEffect = Effect;
class SpatialGrid { class SpatialGrid {
constructor(cellSize = 96) { constructor(cellSize = 96) {
this.cellSize = cellSize; this.cellSize = cellSize;
@ -746,7 +784,7 @@ class SpatialGrid {
if (!it || it.dead) return false; if (!it || it.dead) return false;
const type = it.type || ""; const type = it.type || "";
if (type === "ball" || type === "balloon" || type === "fan" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true; if (type === "ball" || type === "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 globalRef = (typeof window !== "undefined" ? window : globalThis);
const physics = globalRef.TarinaiPhysicsBodySystem; const physics = globalRef.TarinaiPhysicsBodySystem;
const body = physics?.isBodyType?.(it) ? physics.ensureBody?.(it, null, { syncFromLegacy: false }) : null; const body = physics?.isBodyType?.(it) ? physics.ensureBody?.(it, null, { syncFromLegacy: false }) : null;

View file

@ -12,6 +12,15 @@
map[k] = (map[k] || 0) + n; 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) { function updateCreatures(worldRef, dt) {
const h = helpers; const h = helpers;
const end = global.TarinaiPerf.begin("update.tarinai.individual") || null; const end = global.TarinaiPerf.begin("update.tarinai.individual") || null;
@ -35,6 +44,10 @@
skippedStates: {}, skippedStates: {},
sleepPhysical: 0, sleepPhysical: 0,
passivePhysical: 0, passivePhysical: 0,
fullBudget: backgroundFullBudgetFor(worldRef),
fullBudgetUsed: 0,
fullBudgetSkips: 0,
offscreenSkipped: 0,
}; };
const prevSpatialDeferMode = worldRef.deferSpatialRebuildMode || ""; const prevSpatialDeferMode = worldRef.deferSpatialRebuildMode || "";
worldRef.deferSpatialRebuildDepth = (worldRef.deferSpatialRebuildDepth || 0) + 1; worldRef.deferSpatialRebuildDepth = (worldRef.deferSpatialRebuildDepth || 0) + 1;
@ -60,6 +73,7 @@
else if (interval < 0.75) stats.near += 1; else if (interval < 0.75) stats.near += 1;
else stats.far += 1; else stats.far += 1;
if (!Number.isFinite(interval)) continue; if (!Number.isFinite(interval)) continue;
const usesBackgroundBudget = consumesBackgroundBudget(cadence);
const context = { cadence, motionDt: dt }; const context = { cadence, motionDt: dt };
if (interval <= 0) { if (interval <= 0) {
@ -77,11 +91,21 @@
t._nextLowFreqUpdateAt = now + interval * (0.65 + jitter * 0.70); t._nextLowFreqUpdateAt = now + interval * (0.65 + jitter * 0.70);
} }
if (now >= t._nextLowFreqUpdateAt) { 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 runDt = Math.max(dt, t._updateAccum || dt);
const jitter = stableUnit(t.id || t.familyKey || Math.random(), `creature-cadence:${Math.floor(now * 2)}`); const jitter = stableUnit(t.id || t.familyKey || Math.random(), `creature-cadence:${Math.floor(now * 2)}`);
t._updateAccum = 0; t._updateAccum = 0;
t._nextLowFreqUpdateAt = now + interval * (0.92 + jitter * 0.22); t._nextLowFreqUpdateAt = now + interval * (0.92 + jitter * 0.22);
stats.full += 1; stats.full += 1;
if (usesBackgroundBudget) stats.fullBudgetUsed += 1;
global.TarinaiCreatureRuntime.updateOne(t, runDt, { context }); global.TarinaiCreatureRuntime.updateOne(t, runDt, { context });
} else if (cadence.smoothMotion) { } else if (cadence.smoothMotion) {
if (global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) { if (global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) {
@ -128,14 +152,10 @@
fastTarinai.push(t); fastTarinai.push(t);
} }
if (fastTarinai?.length) { if (fastTarinai?.length) {
worldRef.nextTarinaiCollisionCheckAt = now;
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1); worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
const endCollision = global.TarinaiPerf.begin("update.tarinai.collisions") || null; const endCollision = global.TarinaiPerf.begin("update.tarinai.collisions") || null;
try { worldRef.resolveTarinaiHighSpeedCollisions(dt, { fastSources: fastTarinai, threshold: collisionSpeedThreshold }); } try { worldRef.resolveTarinaiHighSpeedCollisions(dt, { fastSources: fastTarinai, threshold: collisionSpeedThreshold }); }
finally { if (endCollision) endCollision(); } finally { if (endCollision) endCollision(); }
} else if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) {
worldRef.nextTarinaiCollisionCheckAt = now + 0.55;
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
} else { } else {
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
} }

View file

@ -3,6 +3,49 @@
// Layer: simulation/system-effects // Layer: simulation/system-effects
// Owns frame updates for transient visual/effect entities. // Owns frame updates for transient visual/effect entities.
(function (global) { (function (global) {
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) { function updateEffect(effect, dt, frameId = 0, runScale = 1) {
if (!effect || effect.dead) return false; if (!effect || effect.dead) return false;
const runDt = Math.min(0.24, Math.max(0, dt * Math.max(1, Number(runScale) || 1))); const runDt = Math.min(0.24, Math.max(0, dt * Math.max(1, Number(runScale) || 1)));
@ -13,15 +56,17 @@
function updateEffects(worldRef, dt) { function updateEffects(worldRef, dt) {
const effects = worldRef.effects || []; const effects = worldRef.effects || [];
const beforePressure = effects.length;
const pressure = compactEffectPressure(worldRef, effects);
const total = effects.length; const total = effects.length;
const frameId = (worldRef._effectUpdateFrameId || 0) + 1; const frameId = (worldRef._effectUpdateFrameId || 0) + 1;
worldRef._effectUpdateFrameId = frameId; worldRef._effectUpdateFrameId = frameId;
if (total <= 56) { const budget = effectUpdateBudgetFor(total);
if (budget >= total) {
for (const ef of effects) updateEffect(ef, dt, frameId, 1); for (const ef of effects) updateEffect(ef, dt, frameId, 1);
worldRef._effectUpdateStats = { total, updated: total, skipped: 0, batched: false }; worldRef._effectUpdateStats = { total, beforePressure, updated: total, skipped: 0, budget: total, compacted: pressure.compacted, dropped: pressure.dropped, batched: false };
return; 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))); 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 cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % total;
let updated = 0; let updated = 0;
@ -35,7 +80,18 @@
updated += 1; updated += 1;
} }
worldRef._effectUpdateCursor = cursor; 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({ global.TarinaiEffectsSimulationSystem = Object.freeze({

View file

@ -39,6 +39,19 @@
return false; 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) { function updateItemsAndAnts(worldRef, dt) {
const h = helpers; const h = helpers;
const itemCountBefore = worldRef.items.length; const itemCountBefore = worldRef.items.length;
@ -51,10 +64,13 @@
h.updateItemsScheduled(worldRef, dt); h.updateItemsScheduled(worldRef, dt);
updateRandomGrassGrowth(worldRef, dt); updateRandomGrassGrowth(worldRef, dt);
itemsMoved = h.markScheduledItemsMoved(worldRef, "items-moved", 0.25); 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.ensureSpatial?.("ball-ball-collisions");
worldRef.resolveBallBallCollisions(dt); const solved = worldRef.resolveBallBallCollisions(dt) || 0;
worldRef.markSpatialDirty?.("ball-ball-collisions"); if (solved > 0) worldRef.markSpatialDirty?.("ball-ball-collisions");
} else {
worldRef.workStats && (worldRef.workStats.ballCollisionSkips = (worldRef.workStats.ballCollisionSkips || 0) + 1);
} }
worldRef.updateAnts?.(dt); worldRef.updateAnts?.(dt);
antsMoved = h.markSpatialDirtyIfMoved(worldRef, worldRef.ants || [], "ants-moved", 0.25); antsMoved = h.markSpatialDirtyIfMoved(worldRef, worldRef.ants || [], "ants-moved", 0.25);

View file

@ -10,7 +10,6 @@
let itemsCompacted = false; let itemsCompacted = false;
if (worldRef.compactTimer >= compactInterval) { if (worldRef.compactTimer >= compactInterval) {
itemsCompacted = worldRef.compactItems(); itemsCompacted = worldRef.compactItems();
worldRef.compactEffects();
worldRef.compactTarinai(); worldRef.compactTarinai();
worldRef.compactAnts?.(); worldRef.compactAnts?.();
worldRef.compactTimer = 0; worldRef.compactTimer = 0;

View file

@ -32,6 +32,38 @@
const n = Number(value); const n = Number(value);
return Number.isFinite(n) ? n / scale : fallback; 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) { function mortonKeyXY(x = 0, y = 0) {
const ix = Math.max(0, Math.min(65535, Math.round(Number(x) || 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))); const iy = Math.max(0, Math.min(65535, Math.round(Number(y) || 0)));
@ -221,7 +253,7 @@
targetIdx = itemIndex.get(targetId) ?? itemIndex.get(t.target?.id) ?? -1; targetIdx = itemIndex.get(targetId) ?? itemIndex.get(t.target?.id) ?? -1;
} }
return [ 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), 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), needsToArray(t.needs || t.needRaw || {}), personalityToArray(t.currentPersonality),
parentIdx, compactRelationships(t, tarinaiIndex), timers, modes, pinIdx, nestIdx, [enumIndex(STATE_IDS, state), targetIdx], parentIdx, compactRelationships(t, tarinaiIndex), timers, modes, pinIdx, nestIdx, [enumIndex(STATE_IDS, state), targetIdx],
@ -233,12 +265,13 @@
if (!TarinaiClass) throw new Error("Tarinai is not available"); if (!TarinaiClass) throw new Error("Tarinai is not available");
const needs = arrayToNeeds(row[9]); const needs = arrayToNeeds(row[9]);
const age = u(row[4], 10); 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 profile = global.TarinaiSeedFactory.birthProfile(birthSeed) || {};
const opts = { const opts = {
familyKey: birthSeed, familyKey: birthSeed,
birthSeed, birthSeed,
birthSerial: global.TarinaiSeedFactory.serialFromBirthSeed(birthSeed) || 0, birthSerial: birthIdentity.serial,
id: `st${index}`, id: `st${index}`,
liveToken: 1, liveToken: 1,
currentPersonality: arrayToPersonality(row[10]), currentPersonality: arrayToPersonality(row[10]),
@ -313,6 +346,7 @@
q(item.fanSwingPhase || 0, 1000), q(item.fanSwingPhase || 0, 1000),
q(item.fanBodyAngle ?? item.angle ?? 0, 1000), q(item.fanBodyAngle ?? item.angle ?? 0, 1000),
]; ];
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.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)]; if (global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
return []; return [];
@ -323,8 +357,12 @@
return [typeId, q(item?.x, 1), q(item?.y, 1), q(item?.amount ?? item?.hp, 10), itemExtra(item, tarinaiIndex, itemIndex)]; 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 = []) { function applyItemExtra(item, extra, tarinaiList = [], itemList = []) {
if (!Array.isArray(extra)) return;
const type = item.type || ""; 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) { if (item.isStructure) {
item.hp = u(extra[0], 10, item.hp || 1); item.hp = u(extra[0], 10, item.hp || 1);
item.maxHp = u(extra[1], 10, item.maxHp || item.hp || 1); item.maxHp = u(extra[1], 10, item.maxHp || item.hp || 1);
@ -359,11 +397,7 @@
item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60); item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60);
item.robotCleanerTargetId = ""; item.robotCleanerTargetId = "";
item.robotCleanerTargetType = ""; item.robotCleanerTargetType = "";
} else if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) {
return;
} else if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) { } 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"); global.TarinaiPhysicsBodySystem.invalidateItem(item, "physics-extra-missing");
return; return;
} else if (type === "duplicator") { } else if (type === "duplicator") {
@ -400,6 +434,12 @@
item.fanSwingSpeed = Math.max(0, Math.min(360 * Math.PI / 180, u(extra[4], 1000, 48 * 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); item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0);
item.fanBodyAngle = global.normalizedItemAngle(u(extra[6], 1000, item.angle), item.angle); 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)) { } else if (global.isRotatableItemType(type)) {
const fallback = global.defaultItemAngle(type); const fallback = global.defaultItemAngle(type);
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback); item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);

View file

@ -142,7 +142,7 @@
while (changed) { while (changed) {
changed = false; changed = false;
for (const link of worldRef.items || []) { 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 a = pb.endpoint(link, 0);
const b = pb.endpoint(link, 1); const b = pb.endpoint(link, 1);
const hit = (a && a.kind === "item" && removedIds.has(a.id)) || (b && b.kind === "item" && removedIds.has(b.id)); const hit = (a && a.kind === "item" && removedIds.has(a.id)) || (b && b.kind === "item" && removedIds.has(b.id));

View file

@ -114,7 +114,7 @@ function updateNeeds(tarinai, world, dt = 0) {
const championMate = tarinai.isTarinaiChampion && (tarinai.reproductionTimer || 0) <= 8 && (world?.canAddTarinai?.(1) ?? true) 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) ? (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; : null;
if (championMate) { socialParts.mate += 58; socialParts.championMate = 1; raw.social += 58; } 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; } 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"); const enemy = tarinai.strongestRelation?.("fear");
if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; } if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; }
@ -293,7 +293,7 @@ function socialFightMateActionWeights(tarinai) {
fightWeight *= clamp(1 - Math.max(0, Number(parts.bond || 0) || 0) / 180, 0.55, 1.04); 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?.fightMochiTimer || 0) > 0.04) { fightWeight *= 3.4; mateWeight *= 0.55; }
if ((tarinai?.loveMochiTimer || 0) > 0.04) { mateWeight *= 3.15; fightWeight *= 0.58; } if ((tarinai?.loveMochiTimer || 0) > 0.04) { mateWeight *= 3.15; fightWeight *= 0.58; }
if (parts.championMate) { mateWeight *= 5.0; fightWeight *= 0.35; } if (parts.championMate) { mateWeight *= 4.0; fightWeight *= 0.35; }
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight); const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);
return { fightWeight, mateWeight, fightProbability: total > 0 ? fightWeight / total : 0.4 }; return { fightWeight, mateWeight, fightProbability: total > 0 ? fightWeight / total : 0.4 };
} }

View file

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

View file

@ -92,7 +92,7 @@ function socialFightMateWeights(t, other, world, options = {}) {
mateWeight *= 1 + Math.min(0.70, affinity / 140); mateWeight *= 1 + Math.min(0.70, affinity / 140);
if (loveMochi) mateWeight *= 3.15; if (loveMochi) mateWeight *= 3.15;
if (fightMochi) mateWeight *= 0.52; if (fightMochi) mateWeight *= 0.52;
if (championPair) mateWeight *= 5.0; if (championPair) mateWeight *= 4.0;
if (options.mode === "mate") mateWeight *= 1.42; if (options.mode === "mate") mateWeight *= 1.42;
} }
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight); const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);

View file

@ -71,12 +71,14 @@
const id = behaviorId(tarinai); const id = behaviorId(tarinai);
const state = String(tarinai.state || id || ""); 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 isVisible = visible(worldRef, tarinai, visibleRect);
const farRect = isVisible ? visibleRect : (nearVisibleRect || helpers.updateVisibleWorldRect(worldRef, 520)); const farRect = isVisible ? visibleRect : (nearVisibleRect || helpers.updateVisibleWorldRect(worldRef, 520));
const isNear = isVisible || Boolean(Geometry.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 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)))); 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 }; if (nestEntryActive) return { interval: 0, lane: "nest-entry", smoothMotion: false, visible: true, urgent: true };
@ -95,7 +97,8 @@
// runs on visible skipped frames. // runs on visible skipped frames.
const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic/i.test(id); const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic/i.test(id);
if (panicUrgent) { 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 }; return { interval, lane: "panic", smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)), visible: isVisible, urgent: true };
} }
@ -116,21 +119,17 @@
interval = isVisible ? 0.42 : (isNear ? 0.70 : 1.10); interval = isVisible ? 0.42 : (isNear ? 0.70 : 1.10);
} else if (/idle|none/.test(id) || state === "idle") { } else if (/idle|none/.test(id) || state === "idle") {
lane = "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 interval = isNear ? 0.55 : 1.10;
} else if (isVisible) { } else if (isVisible) {
lane = "visible"; lane = "visible";
interval = count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05); interval = 0.05;
} else { } else {
lane = isNear ? "near" : "far"; lane = isNear ? "near" : "far";
interval = isNear ? 0.30 : 0.90; interval = isNear ? 0.30 : 0.90;
} }
if (Number.isFinite(interval) && !/birth|eat|sleep/.test(lane)) { if (Number.isFinite(interval)) interval *= Math.max(1, Number(perfProfile?.creatureCadenceScale || 1));
if (count > 54) interval *= 1.26;
else if (count > 38) interval *= 1.14;
}
if (mobile && Number.isFinite(interval)) interval *= count > 24 ? 1.35 : 1.18;
const passivePhysicsNeed = hasPassivePhysicsNeed(tarinai, worldRef, isVisible); const passivePhysicsNeed = hasPassivePhysicsNeed(tarinai, worldRef, isVisible);
const sleepPhysical = lane === "sleep" && passivePhysicsNeed; const sleepPhysical = lane === "sleep" && passivePhysicsNeed;
const eatPhysical = lane === "eat" && passivePhysicsNeed; const eatPhysical = lane === "eat" && passivePhysicsNeed;
@ -141,7 +140,7 @@
&& interval <= 0.75 && interval <= 0.75
&& !worldRef.isTarinaiHiddenInNestBox?.(tarinai) && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)
&& ((smoothAllowed && motionNeed) || sleepPhysical || eatPhysical)); && ((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({ global.TarinaiCreatureUpdatePolicy = Object.freeze({

View file

@ -23,6 +23,10 @@
const delta = Math.hypot(after.vx - baseline.vx, after.vy - baseline.vy); const delta = Math.hypot(after.vx - baseline.vx, after.vy - baseline.vy);
t._lastPhysicalVelocityX = after.vx; t._lastPhysicalVelocityX = after.vx;
t._lastPhysicalVelocityY = after.vy; 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 (delta < SHOCK_DAMAGE_DELTA_THRESHOLD) return false;
if (now - (t.lastVelocityShockDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false; if (now - (t.lastVelocityShockDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
if (now - (t.lastPhysicalCollisionDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false; if (now - (t.lastPhysicalCollisionDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;

View file

@ -44,7 +44,9 @@ const ui = {
temperatureValue: document.getElementById("temperatureValue"), temperatureValue: document.getElementById("temperatureValue"),
temperatureSlider: document.getElementById("temperatureSlider"), temperatureSlider: document.getElementById("temperatureSlider"),
temperatureAutoToggle: document.getElementById("temperatureAutoToggle"), temperatureAutoToggle: document.getElementById("temperatureAutoToggle"),
tarinaiPopulationLimitSlider: document.getElementById("tarinaiPopulationLimitSlider"),
tarinaiPopulationLimitInput: document.getElementById("tarinaiPopulationLimitInput"), tarinaiPopulationLimitInput: document.getElementById("tarinaiPopulationLimitInput"),
objectLimitSlider: document.getElementById("objectLimitSlider"),
objectLimitInput: document.getElementById("objectLimitInput"), objectLimitInput: document.getElementById("objectLimitInput"),
colonyLimitStatus: document.getElementById("colonyLimitStatus"), colonyLimitStatus: document.getElementById("colonyLimitStatus"),
statGroundType: document.getElementById("statGroundType"), statGroundType: document.getElementById("statGroundType"),
@ -93,8 +95,11 @@ const uiCache = {
chartBucketStart: -Infinity, chartBucketStart: -Infinity,
chartBucketEnd: -Infinity, chartBucketEnd: -Infinity,
chartBucketAccum: null, chartBucketAccum: null,
chartBirthEventSeen: null,
chartDeathEventSeen: null,
lastChartDraw: "", lastChartDraw: "",
chartMode: "population", chartMode: "population",
chartHiddenSeries: new Set(),
canvasDpr: 0, canvasDpr: 0,
panning: false, panning: false,
panLastX: 0, panLastX: 0,

View file

@ -154,18 +154,39 @@ function bindUI() {
ui.temperatureSlider?.addEventListener("input", applyTemperatureSlider); ui.temperatureSlider?.addEventListener("input", applyTemperatureSlider);
ui.temperatureSlider?.addEventListener("change", applyTemperatureSlider); ui.temperatureSlider?.addEventListener("change", applyTemperatureSlider);
const applyColonyLimit = (kind) => { 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; if (!world) return;
const input = kind === "tarinai" ? ui.tarinaiPopulationLimitInput : ui.objectLimitInput; const { slider, input } = colonyLimitControls(kind);
if (!input) return; const sourceEl = String(source).startsWith("slider") ? slider : input;
const value = input.value.trim() === "" ? 0 : Number(input.value); 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); if (kind === "tarinai") world.setTarinaiPopulationLimit?.(value);
else world.setObjectLimit?.(value); else world.setObjectLimit?.(value);
audio.uiClick?.(); syncColonyLimitControls(kind, value);
if (source !== "slider-live") audio.uiClick?.();
renderStats?.(); renderStats?.();
}; };
ui.tarinaiPopulationLimitInput?.addEventListener("change", () => applyColonyLimit("tarinai")); ui.tarinaiPopulationLimitSlider?.addEventListener("input", () => applyColonyLimit("tarinai", "slider-live"));
ui.objectLimitInput?.addEventListener("change", () => applyColonyLimit("object")); 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", () => { ui.panel?.addEventListener("scroll", () => {
const panel = ui.panel; const panel = ui.panel;
@ -335,6 +356,24 @@ function bindUI() {
uiCache.lastChartDraw = ""; uiCache.lastChartDraw = "";
renderStats(); 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", () => { ui.logPushEnabled?.addEventListener("change", () => {
uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked); uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked);
audio.notify?.(); audio.notify?.();
@ -546,6 +585,34 @@ function bindUI() {
showToast("\u5bb6\u7cfb\u56f3\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002"); 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) => { ui.toolPalette?.addEventListener("click", (e) => {
const toggle = e.target.closest(".tool-category-toggle"); const toggle = e.target.closest(".tool-category-toggle");
if (toggle) { if (toggle) {
@ -564,24 +631,16 @@ function bindUI() {
e.stopPropagation(); e.stopPropagation();
return; return;
} }
const tool = btn.dataset.tool; activateControlTool(btn.dataset.tool);
audio.uiClick?.(); });
if (tool === "undo" || tool === "redo") {
const result = window.TarinaiCommands.dispatch(world, { type: tool === "undo" ? "history.undo" : "history.redo" }); window.addEventListener("keydown", (e) => {
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")); if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
renderStats(); if (isKeyboardTypingTarget(e.target)) return;
return; const tool = TOOL_BY_SHORTCUT[String(e.key || "")];
} if (!tool) return;
if (tool === "clear_tool") { e.preventDefault();
world.pendingLinkEndpoint = null; activateControlTool(tool);
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);
}); });
const normalizedWheelDeltaY = (e) => { const normalizedWheelDeltaY = (e) => {

View file

@ -35,6 +35,8 @@ function renderStats() {
juvenile: juvenileCount, juvenile: juvenileCount,
elder: elderCount, elder: elderCount,
dead: world.deadCount, 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), stress: avg(t => t.stress),
temperature: Number(temp) || 0, temperature: Number(temp) || 0,
...needAverages, ...needAverages,
@ -58,8 +60,13 @@ function renderStats() {
const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0); const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0);
const objectLimit = Math.max(0, Number(world.objectLimit) || 0); const objectLimit = Math.max(0, Number(world.objectLimit) || 0);
const objectCount = world.activeObjectCount?.() ?? (world.items || []).filter(item => item && !item.dead).length; 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.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) : ""; if (ui.objectLimitInput && document.activeElement !== ui.objectLimitInput) ui.objectLimitInput.value = objectLimit > 0 ? String(objectLimit) : "";
}
setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `たりない ${values.pop} / ${tarinaiLimit || "∞"} オブジェクト ${objectCount} / ${objectLimit || "∞"}`); setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `たりない ${values.pop} / ${tarinaiLimit || "∞"} オブジェクト ${objectCount} / ${objectLimit || "∞"}`);
bindColonyMoodTooltip(); bindColonyMoodTooltip();
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood); window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
@ -75,11 +82,17 @@ function updateColonyHistory(values) {
const history = uiCache.chartHistory; const history = uiCache.chartHistory;
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12); const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
const maxPoints = 5 * 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; history.length = 0;
uiCache.chartBucketStart = -Infinity; uiCache.chartBucketStart = -Infinity;
uiCache.chartBucketEnd = -Infinity; uiCache.chartBucketEnd = -Infinity;
uiCache.chartBucketAccum = null; uiCache.chartBucketAccum = null;
uiCache.chartBirthEventSeen = null;
uiCache.chartDeathEventSeen = null;
uiCache.lastChartDraw = ""; uiCache.lastChartDraw = "";
} }
const objectKeys = colonyChartObjectKeys(); const objectKeys = colonyChartObjectKeys();
@ -98,6 +111,7 @@ function updateColonyHistory(values) {
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])), ...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
}, },
objects: Object.fromEntries(objectKeys.map(k => [k, 0])), objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
events: { births: 0, deaths: 0 },
}); });
if (!uiCache.chartBucketAccum) { if (!uiCache.chartBucketAccum) {
const start = Math.floor(t / interval) * interval; const start = Math.floor(t / interval) * interval;
@ -117,6 +131,8 @@ function updateColonyHistory(values) {
temperature: bucket.sums.temperature / bucket.count, temperature: bucket.sums.temperature / bucket.count,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / 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])), ...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); history.push(row);
if (history.length > maxPoints) history.splice(0, history.length - maxPoints); if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
@ -129,6 +145,12 @@ function updateColonyHistory(values) {
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart); uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
} }
const bucket = uiCache.chartBucketAccum; 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.count += 1;
bucket.sums.pop += values.pop || 0; bucket.sums.pop += values.pop || 0;
bucket.sums.sick += values.sick || 0; bucket.sums.sick += values.sick || 0;
@ -155,6 +177,8 @@ function chartRows(values) {
elder: 0, elder: 0,
stress: 0, stress: 0,
temperature: 0, temperature: 0,
births: 0,
deaths: 0,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])), ...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])), ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])),
}]; }];
@ -208,8 +232,10 @@ function drawColonyChart(values) {
} }
const rows = chartRows(values); const rows = chartRows(values);
const last = rows[rows.length - 1] || values; const last = rows[rows.length - 1] || values;
const series = chartSeries(mode); const allSeries = chartSeries(mode);
const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(","); 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 dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
const visibleDay = Math.floor(Math.max(0, Number(world.time || 0) || 0) / dayLength); 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}`; const snapshot = `${mode}:${rows.length}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
@ -259,7 +285,10 @@ function drawColonyChart(values) {
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}"/>`; 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(""); }).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>`; 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 = series.map(s => `<span><i style="background:${s.color}"></i>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span>`).join(""); 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") { function chartSeries(mode = "population") {
@ -285,6 +314,8 @@ function chartSeries(mode = "population") {
{ key: "sick", label: "病気", color: "#d96262" }, { key: "sick", label: "病気", color: "#d96262" },
{ key: "juvenile", label: "幼体", color: "#78a642" }, { key: "juvenile", label: "幼体", color: "#78a642" },
{ key: "elder", label: "老体", color: "#8a7054" }, { key: "elder", label: "老体", color: "#8a7054" },
{ key: "births", label: "誕生数", color: "#d48ac2" },
{ key: "deaths", label: "死亡数", color: "#4d4d56" },
]; ];
} }

View file

@ -154,7 +154,6 @@
if (!isBody) return false; if (!isBody) return false;
pb.syncPoseFromItem(target); pb.syncPoseFromItem(target);
pb.setScalar(target, "awakeUntil", Math.max(Number(pb.scalar(target, "awakeUntil", 0) || 0), (world.time || 0) + 0.65), reason); pb.setScalar(target, "awakeUntil", Math.max(Number(pb.scalar(target, "awakeUntil", 0) || 0), (world.time || 0) + 0.65), reason);
pb.markBodyChanged(target, reason);
global.TarinaiMechanicalSystem.invalidateGeometry(target); global.TarinaiMechanicalSystem.invalidateGeometry(target);
return true; return true;
} }

View file

@ -156,7 +156,7 @@ function selectTool(tool) {
const wasCopyArmed = world.tool === "copy" && tool === "copy" && Boolean(world.copyBuffer); const wasCopyArmed = world.tool === "copy" && tool === "copy" && Boolean(world.copyBuffer);
const result = window.TarinaiCommands.dispatch(world, { type: "tool.select", toolId: tool }); const result = window.TarinaiCommands.dispatch(world, { type: "tool.select", toolId: tool });
const activeTool = result?.toolId || world.tool || tool; const activeTool = result?.toolId || world.tool || tool;
if (activeTool !== "rope" && activeTool !== "rod") world.pendingLinkEndpoint = null; if (!["rope", "rod", "spring", "wire", "insulated_wire"].includes(activeTool)) world.pendingLinkEndpoint = null;
if (activeTool !== "copy") world.copyBuffer = null; if (activeTool !== "copy") world.copyBuffer = null;
else if (wasCopyArmed) { else if (wasCopyArmed) {
world.copyBuffer = null; world.copyBuffer = null;

View file

@ -216,7 +216,7 @@ function renderSelected() {
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const behaviorState = currentTarinaiBehavior(t); const behaviorState = currentTarinaiBehavior(t);
const snapshot = [ const snapshot = [
t.id, world?.groundType || "soil", t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), t.id, world?.groundType || "soil", t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), fmt(Math.max(0, Number(t.electricShockUntil || 0) - Number(world?.time || 0))),
behaviorState?.need || "", behaviorState?.actionId || "", behaviorState?.label || "", behaviorState?.reason || "", behaviorState?.source || "", behaviorState?.text || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","), behaviorState?.need || "", behaviorState?.actionId || "", behaviorState?.label || "", behaviorState?.reason || "", behaviorState?.source || "", behaviorState?.text || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness), fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
fmt(t.energy), fmt(t.stress), fmt(t.mood), fmt(t.energy), fmt(t.stress), fmt(t.mood),
@ -333,6 +333,7 @@ function isIdleDisplayText(value = "") {
function reasonLabel(t) { function reasonLabel(t) {
if (!t) return "\u306a\u3057"; if (!t) return "\u306a\u3057";
if (Number(t.electricShockUntil || 0) > Number(t.world?.time || world?.time || 0)) return "感電している。";
const behavior = behaviorText(t); const behavior = behaviorText(t);
if (behavior) { if (behavior) {
const normalized = window.TEXT_CATALOG?.cleanBehaviorText?.(behavior, t.state || "") || behavior; const normalized = window.TEXT_CATALOG?.cleanBehaviorText?.(behavior, t.state || "") || behavior;

View file

@ -1,7 +1,7 @@
"use strict"; "use strict";
const SCALABLE_TOOLS = new Set(scalableToolIds()); const SCALABLE_TOOLS = new Set(scalableToolIds());
const TEXT_ICON_TOOL_IDS = new Set(["poke", "pinch", "water_hose", "rope", "rod", "reciprocator", "copy", "undo", "redo", "area_delete", "clear_tool"]); const TEXT_ICON_TOOL_IDS = new Set(["poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "reciprocator", "copy", "undo", "redo", "area_delete", "clear_tool"]);
function applyToolTextIcon(btn, def, toolId) { function applyToolTextIcon(btn, def, toolId) {
if (!btn || !def?.iconText || !TEXT_ICON_TOOL_IDS.has(toolId)) return false; if (!btn || !def?.iconText || !TEXT_ICON_TOOL_IDS.has(toolId)) return false;
@ -31,6 +31,7 @@ function applyToolImageIcon(btn, def) {
const SIZE_ORDER = window.TarinaiItemToolMetadata?.TOOL_SIZE_ORDER || ["small", "medium", "large"]; const SIZE_ORDER = window.TarinaiItemToolMetadata?.TOOL_SIZE_ORDER || ["small", "medium", "large"];
const SIZE_LABELS = window.TarinaiItemToolMetadata?.TOOL_SIZE_LABELS || { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; const SIZE_LABELS = window.TarinaiItemToolMetadata?.TOOL_SIZE_LABELS || { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
const TOOL_SHORTCUTS = window.TarinaiItemToolMetadata?.TOOL_SHORTCUTS || {};
function renderToolPalette() { function renderToolPalette() {
if (!ui?.toolPalette || typeof toolCategories !== "function") return; if (!ui?.toolPalette || typeof toolCategories !== "function") return;
@ -65,6 +66,14 @@ function renderToolPalette() {
btn.setAttribute("aria-label", label.textContent); btn.setAttribute("aria-label", label.textContent);
btn.removeAttribute("title"); btn.removeAttribute("title");
btn.appendChild(label); btn.appendChild(label);
const shortcut = category.id === "operate" ? TOOL_SHORTCUTS[toolId] : "";
if (shortcut) {
const badge = document.createElement("kbd");
badge.className = "tool-shortcut";
badge.textContent = shortcut;
btn.setAttribute("aria-keyshortcuts", shortcut);
btn.appendChild(badge);
}
attachToolSpritePreview(btn, def, toolId); attachToolSpritePreview(btn, def, toolId);
if (toolId === world?.tool) btn.classList.add("selected"); if (toolId === world?.tool) btn.classList.add("selected");
body.appendChild(btn); body.appendChild(btn);

View file

@ -1,8 +1,8 @@
"use strict"; "use strict";
(function () { (function () {
const APP_VERSION = "39.15.167"; const APP_VERSION = "39.16.18";
const APP_BUILD = "tarinai-mechanical-collision-grid-limit-audit-v39-15-166"; const APP_BUILD = "tarinai-fixes-v39-16-18";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -83,6 +83,7 @@ class World { constructor() {
this.antUpdatePhase = 0; this.antUpdatePhase = 0;
this.selected = null; this.selected = null;
this.deadCount = 0; this.deadCount = 0;
this.birthEventCount = 0;
this.liveIdNext = 1; this.liveIdNext = 1;
this.liveIdSerial = 0; this.liveIdSerial = 0;
this.liveTarinai = new Map(); this.liveTarinai = new Map();

View file

@ -5,9 +5,9 @@
if (!World) throw new Error("World is not available for mixin: world_environment.js"); if (!World) throw new Error("World is not available for mixin: world_environment.js");
const Geometry = global.TarinaiGeometry; const Geometry = global.TarinaiGeometry;
const BOUNCE_FENCE_RESTITUTION = 2.40; const BOUNCE_FENCE_RESTITUTION = 4.20;
const BOUNCE_FENCE_MIN_SPEED = 520; const BOUNCE_FENCE_MIN_SPEED = 820;
const BOUNCE_FENCE_MAX_SPEED = 1180; const BOUNCE_FENCE_MAX_SPEED = 1680;
function angleForItemLocal(it) { function angleForItemLocal(it) {
return itemAngleFor(it); return itemAngleFor(it);
@ -17,7 +17,7 @@
if (!it) return Infinity; if (!it) return Infinity;
const type = String(it.type || ""); const type = String(it.type || "");
let reach = Math.max(32, Number(it.r || it.radius || 32) || 32); let reach = Math.max(32, Number(it.r || it.radius || 32) || 32);
if (type === "fence" || type === "fence_v" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") reach = Math.max(120, reach * 3.9); if (type === "fence" || type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence" || type === "one_way_fence") reach = Math.max(120, reach * 3.9);
else if (type === "nest_box" || type === "pipe") reach = Math.max(92, reach * 1.9); else if (type === "nest_box" || type === "pipe") reach = Math.max(92, reach * 1.9);
else reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, reach * 1.6); else reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, reach * 1.6);
return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach); return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach);
@ -30,6 +30,14 @@
return Math.hypot((Number(x) || 0) - px, (Number(y) || 0) - py); return Math.hypot((Number(x) || 0) - px, (Number(y) || 0) - py);
} }
function nearestRectFaceDirection(left, right, top, bottom) {
const depth = Math.min(left, right, top, bottom);
if (depth === left) return { dx: -1, dy: 0, depth };
if (depth === right) return { dx: 1, dy: 0, depth };
if (depth === top) return { dx: 0, dy: -1, depth };
return { dx: 0, dy: 1, depth };
}
function visitNearestObstaclesLocal(x, y, candidates, limit, visitor, opts = {}) { function visitNearestObstaclesLocal(x, y, candidates, limit, visitor, opts = {}) {
if (!candidates || typeof visitor !== "function") return 0; if (!candidates || typeof visitor !== "function") return 0;
const source = candidates; const source = candidates;
@ -152,6 +160,35 @@
return hit ? { t: hit.t, nx: hit.nx, ny: hit.ny, rect: r } : null; return hit ? { t: hit.t, nx: hit.nx, ny: hit.ny, rect: r } : null;
} }
function nearbyMechanicalObstacleRectsLocal(worldRef, x, y, radius, opts = {}) {
if (!worldRef?.itemsOfType || !worldRef?.solidObstacleRects) return [];
const maxChecks = Math.max(1, Math.min(32, Number(opts.maxChecks || 0) || 10));
const maxRects = Math.max(12, Math.min(128, Number(opts.maxRects || 0) || 72));
const rects = [];
const candidates = [];
for (const type of ["rotator", "poison_block", "reciprocator"]) {
for (const it of worldRef.itemsOfType(type) || []) {
if (!it || it.dead || it === opts.exclude) continue;
const reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, Number(it.r || it.radius || 64) || 64);
const d = Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach);
if (d > radius + 72) continue;
candidates.push({ it, d });
}
}
candidates.sort((a, b) => a.d - b.d);
let checked = 0;
for (const entry of candidates) {
if (checked >= maxChecks || rects.length >= maxRects) break;
checked += 1;
for (const rect of worldRef.solidObstacleRects(entry.it) || []) {
if (rectDistanceToPointLocal(rect, x, y) > radius + 48) continue;
rects.push(rect);
if (rects.length >= maxRects) break;
}
}
return rects;
}
function firstSweptCircleObstacleHitLocal(worldRef, t, x1, y1, x2, y2, radius, opts = {}) { function firstSweptCircleObstacleHitLocal(worldRef, t, x1, y1, x2, y2, radius, opts = {}) {
if (!worldRef?.nearbySolidObstacleRects) return null; if (!worldRef?.nearbySolidObstacleRects) return null;
const midX = (x1 + x2) * 0.5; const midX = (x1 + x2) * 0.5;
@ -159,12 +196,27 @@
const travel = Math.hypot(x2 - x1, y2 - y1); const travel = Math.hypot(x2 - x1, y2 - y1);
const searchRadius = Math.max(Number(opts.searchRadius || 0) || 0, radius + travel * 0.5 + 180); const searchRadius = Math.max(Number(opts.searchRadius || 0) || 0, radius + travel * 0.5 + 180);
const maxChecks = Math.max(4, Number(opts.maxChecks || 0) || 48); const maxChecks = Math.max(4, Number(opts.maxChecks || 0) || 48);
const maxRects = Math.max(18, Math.min(128, Number(opts.maxRects || 0) || Math.ceil(maxChecks * 2.5)));
const mechanicalPadding = Math.max(1.75, Math.min(6.5, radius * 0.24));
let best = null; let best = null;
for (const rect of worldRef.nearbySolidObstacleRects(midX, midY, searchRadius, { maxChecks })) { const testRect = (rect) => {
if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) continue; if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) return;
const hit = sweptCircleRectHitLocal(x1, y1, x2, y2, radius, rect, 0.5); if (rect?.oneWay && worldRef.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) return;
if (!hit) continue; const hit = sweptCircleRectHitLocal(x1, y1, x2, y2, radius, rect, rect?.mechanical ? mechanicalPadding : 0.5);
if (!hit) return;
if (!best || hit.t < best.t) best = hit; if (!best || hit.t < best.t) best = hit;
};
for (const rect of worldRef.nearbySolidObstacleRects(midX, midY, searchRadius, { maxChecks, maxRects })) {
testRect(rect);
}
if (opts.mechanicalFallback !== false) {
const mechanicalSearchRadius = Math.max(searchRadius, radius + travel + 140);
for (const rect of nearbyMechanicalObstacleRectsLocal(worldRef, midX, midY, mechanicalSearchRadius, {
maxChecks: Math.max(6, Math.min(18, Math.ceil(maxChecks * 0.55))),
maxRects: Math.max(32, maxRects),
})) {
testRect(rect);
}
} }
return best; return best;
} }
@ -266,6 +318,7 @@
const bounce = type === "bounce_fence" || type === "bounce_fence_v"; const bounce = type === "bounce_fence" || type === "bounce_fence_v";
const gate = type === "gate_fence"; const gate = type === "gate_fence";
const glassWall = type === "glass_wall"; const glassWall = type === "glass_wall";
const oneWay = type === "one_way_fence";
const len = Math.max(112, (it.r || 42) * 3.55); const len = Math.max(112, (it.r || 42) * 3.55);
const thick = Math.max(10, (it.r || 42) * 0.31); const thick = Math.max(10, (it.r || 42) * 0.31);
const angle = angleForItemLocal(it); const angle = angleForItemLocal(it);
@ -276,7 +329,7 @@
left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom, left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom,
cx: it.x || 0, cy: it.y || 0, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true, cx: it.x || 0, cy: it.y || 0, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true,
vertical: Math.abs(Math.sin(angle)) > Math.abs(Math.cos(angle)), horizontal: Math.abs(Math.cos(angle)) >= Math.abs(Math.sin(angle)), vertical: Math.abs(Math.sin(angle)) > Math.abs(Math.cos(angle)), horizontal: Math.abs(Math.cos(angle)) >= Math.abs(Math.sin(angle)),
bounce, gate, glassWall, gateOpen: Boolean(it.gateOpen), bounce, gate, glassWall, oneWay, oneWayNx: Math.sin(angle), oneWayNy: -Math.cos(angle), gateOpen: Boolean(it.gateOpen),
restitution: bounce ? BOUNCE_FENCE_RESTITUTION : undefined, minBounceSpeed: bounce ? BOUNCE_FENCE_MIN_SPEED : undefined restitution: bounce ? BOUNCE_FENCE_RESTITUTION : undefined, minBounceSpeed: bounce ? BOUNCE_FENCE_MIN_SPEED : undefined
}; };
}, },
@ -506,7 +559,7 @@
addRectsFor(it); addRectsFor(it);
return checked >= queryLimit; return checked >= queryLimit;
}, { include, exclude, stamp }); }, { include, exclude, stamp });
if (checked < queryLimit && !this.nearbyObstacles) { if (checked < queryLimit) {
const mech = global.TarinaiMechanicalSystem; const mech = global.TarinaiMechanicalSystem;
const fallback = []; const fallback = [];
for (const type of ["rotator", "poison_block", "reciprocator"]) { for (const type of ["rotator", "poison_block", "reciprocator"]) {
@ -608,10 +661,16 @@
return removed; return removed;
}, },
oneWayFenceAllows(entity, rect, motionX = null, motionY = null) {
return Geometry.oneWayFenceAllows(entity, rect, motionX, motionY);
},
pushTarinaiOutOfRect(t, r, rr, opts = {}) { pushTarinaiOutOfRect(t, r, rr, opts = {}) {
if (!t || !r) return false; if (!t || !r) return false;
if (this.oneWayFenceAllows?.(t, r, opts.oneWayDx, opts.oneWayDy)) return false;
const preVx = Number(t.vx || 0) || 0; const preVx = Number(t.vx || 0) || 0;
const preVy = Number(t.vy || 0) || 0; const preVy = Number(t.vy || 0) || 0;
const hitRadius = Math.max(1, rr + (r.mechanical ? Math.max(2.5, Math.min(6.5, rr * 0.24)) : 0));
let cx, cy, dx, dy, d, nx, ny; let cx, cy, dx, dy, d, nx, ny;
let insidePenetration = 0; let insidePenetration = 0;
if (r.oriented) { if (r.oriented) {
@ -621,18 +680,16 @@
dx = local.x - clx; dx = local.x - clx;
dy = local.y - cly; dy = local.y - cly;
d = Math.hypot(dx, dy); d = Math.hypot(dx, dy);
if (d >= rr) return false; if (d >= hitRadius) return false;
if (d < 0.001) { if (d < 0.001) {
const left = Math.abs(local.x + (r.halfW || 0)); const face = nearestRectFaceDirection(
const right = Math.abs((r.halfW || 0) - local.x); Math.abs(local.x + (r.halfW || 0)),
const top = Math.abs(local.y + (r.halfH || 0)); Math.abs((r.halfW || 0) - local.x),
const bottom = Math.abs((r.halfH || 0) - local.y); Math.abs(local.y + (r.halfH || 0)),
const m = Math.min(left, right, top, bottom); Math.abs((r.halfH || 0) - local.y)
insidePenetration = m; );
if (m === left) { dx = -1; dy = 0; d = 1; } insidePenetration = face.depth;
else if (m === right) { dx = 1; dy = 0; d = 1; } dx = face.dx; dy = face.dy; d = 1;
else if (m === top) { dx = 0; dy = -1; d = 1; }
else { dx = 0; dy = 1; d = 1; }
} }
const n = Geometry.rectWorldNormal(r, dx / d, dy / d); const n = Geometry.rectWorldNormal(r, dx / d, dy / d);
nx = n.x; ny = n.y; nx = n.x; ny = n.y;
@ -643,64 +700,82 @@
dx = t.x - cx; dx = t.x - cx;
dy = t.y - cy; dy = t.y - cy;
d = Math.hypot(dx, dy); d = Math.hypot(dx, dy);
if (d >= rr) return false; if (d >= hitRadius) return false;
if (d < 0.001) { if (d < 0.001) {
const left = Math.abs(t.x - r.left); const face = nearestRectFaceDirection(
const right = Math.abs(r.right - t.x); Math.abs(t.x - r.left),
const top = Math.abs(t.y - r.top); Math.abs(r.right - t.x),
const bottom = Math.abs(r.bottom - t.y); Math.abs(t.y - r.top),
const m = Math.min(left, right, top, bottom); Math.abs(r.bottom - t.y)
if (m === left) { dx = -1; dy = 0; d = 1; } );
else if (m === right) { dx = 1; dy = 0; d = 1; } dx = face.dx; dy = face.dy; d = 1;
else if (m === top) { dx = 0; dy = -1; d = 1; }
else { dx = 0; dy = 1; d = 1; }
} }
nx = dx / d; nx = dx / d;
ny = dy / d; ny = dy / d;
} }
const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8; const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8;
const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, rr * 2.8) : Math.max(10, rr * 0.92); const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, hitRadius * 2.8) : Math.max(10, hitRadius * 0.92);
const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush); const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush);
const push = r.oriented && insidePenetration > 0 const push = r.oriented && insidePenetration > 0
? Math.min(insidePenetration + rr + slop, maxPush) ? Math.min(insidePenetration + hitRadius + slop, maxPush)
: Math.min(rr - d + slop, maxPush); : Math.min(hitRadius - d + slop, maxPush);
t.x += nx * push; t.x += nx * push;
t.y += ny * push; t.y += ny * push;
t.lastSolidObstacleCollisionSource = r.item || null; t.lastSolidObstacleCollisionSource = r.item || null;
t.lastSolidObstacleCollisionAt = this.time || 0; t.lastSolidObstacleCollisionAt = this.time || 0;
if (r.bounce) { if (r.bounce) {
const restitution = Number.isFinite(r.restitution) ? r.restitution : BOUNCE_FENCE_RESTITUTION; const restitution = Number.isFinite(r.restitution) ? r.restitution : BOUNCE_FENCE_RESTITUTION;
const vx = t.vx || 0;
const vy = t.vy || 0;
const toward = vx * nx + vy * ny;
if (toward < 0) {
t.vx = vx - (1 + restitution) * toward * nx;
t.vy = vy - (1 + restitution) * toward * ny;
} else {
const kick = Math.max(46, Math.min(120, rr * 3.2));
t.vx = vx + nx * kick;
t.vy = vy + ny * kick;
}
const minBounceSpeed = Math.max(0, Number(r.minBounceSpeed || 0) || 0);
if (minBounceSpeed > 0) {
const currentOut = (t.vx || 0) * nx + (t.vy || 0) * ny + (t.impulseVx || 0) * nx + (t.impulseVy || 0) * ny;
const now = this.time || 0; const now = this.time || 0;
if (currentOut < minBounceSpeed && ((t.lastBounceFenceImpulseAt || -999) + 0.16 <= now || toward < 0)) { const source = r.item || null;
const add = minBounceSpeed - currentOut; const vx = Number(t.vx || 0) || 0;
t.impulseVx = clamp((t.impulseVx || 0) + nx * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); const vy = Number(t.vy || 0) || 0;
t.impulseVy = clamp((t.impulseVy || 0) + ny * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); const ivx = Number(t.impulseVx || 0) || 0;
const ivy = Number(t.impulseVy || 0) || 0;
const combinedX = vx + ivx;
const combinedY = vy + ivy;
const toward = combinedX * nx + combinedY * ny;
const recentSameFence = source && t.lastBounceFenceSource === source
&& now - Number(t.lastBounceFenceImpulseAt || -999) < 0.12
&& toward > 0;
if (!recentSameFence) {
const tangentX = combinedX - toward * nx;
const tangentY = combinedY - toward * ny;
const minBounceSpeed = Math.max(0, Number(r.minBounceSpeed || 0) || 0);
const reflected = toward < 0 ? (-toward * restitution) : Math.max(180, minBounceSpeed * 0.72);
const outward = clamp(Math.max(minBounceSpeed, reflected), 0, BOUNCE_FENCE_MAX_SPEED);
// Keep a little ordinary velocity for visual continuity, but place most
// of the launch in the external impulse channel so action states,
// sleeping and AI steering cannot immediately cancel the blow-away.
t.vx = clamp(tangentX * 0.22 + nx * outward * 0.22, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.vy = clamp(tangentY * 0.22 + ny * outward * 0.22, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.impulseVx = clamp(tangentX * 0.36 + nx * outward * 0.78, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.impulseVy = clamp(tangentY * 0.36 + ny * outward * 0.78, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.lastBounceFenceSource = source;
t.lastBounceFenceImpulseAt = now; t.lastBounceFenceImpulseAt = now;
t._sleepExternalMotionUntil = Math.max(Number(t._sleepExternalMotionUntil || 0) || 0, now + 1.15);
global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(t, 0.016, "bounce-fence-launch");
if (t.sleeping || t.state === "sleep") global.TarinaiMovementUpdateStep?.wakeSleepingFromExternalMotion?.(t, "バウンス柵で跳ね飛ばされた");
t.fallTimer = Math.max(Number(t.fallTimer || 0) || 0, 0.62);
t.fallMax = Math.max(Number(t.fallMax || 0) || 0, Number(t.fallTimer || 0) || 0);
// Clear the body beyond the contact skin so the multi-pass collision
// solver cannot immediately reflect it back into the fence.
const launchClearance = Math.min(16, Math.max(5, rr * 0.42));
t.x += nx * launchClearance;
t.y += ny * launchClearance;
} }
}
t.vx = clamp(t.vx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.vx = clamp(t.vx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.vy = clamp(t.vy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.vy = clamp(t.vy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.18); t.impulseVx = clamp(t.impulseVx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
if (t.bubble && (this.time || 0) >= (t.lastBounceFenceBubbleAt || -999) + 0.55) { t.impulseVy = clamp(t.impulseVy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.lastBounceFenceBubbleAt = this.time || 0; t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.34);
if (t.bubble && now >= (t.lastBounceFenceBubbleAt || -999) + 0.55) {
t.lastBounceFenceBubbleAt = now;
t.bubble("!", 0.45, "rgba(80,130,210,0.72)"); t.bubble("!", 0.45, "rgba(80,130,210,0.72)");
} }
if ((this.time || 0) >= (t.lastBounceFenceEffectAt || -999) + 0.08) { if (now >= (t.lastBounceFenceEffectAt || -999) + 0.08) {
t.lastBounceFenceEffectAt = this.time || 0; t.lastBounceFenceEffectAt = now;
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" })); this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" }));
} }
} else if (r.mechanical) { } else if (r.mechanical) {
@ -736,6 +811,9 @@
const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep))); const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep)));
const stepX = moveX / steps; const stepX = moveX / steps;
const stepY = moveY / steps; const stepY = moveY / steps;
const fastSweep = dist > rr * 1.25 || Math.hypot(Number(t.vx || 0) || 0, Number(t.vy || 0) || 0) > 170;
const sweepMaxChecks = Math.max(opts.light ? (fastSweep ? 18 : 10) : (fastSweep ? 44 : 32), Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24);
const sweepMaxRects = fastSweep ? 108 : 72;
let collided = false; let collided = false;
for (let i = 0; i < steps; i++) { for (let i = 0; i < steps; i++) {
const sx = t.x; const sx = t.x;
@ -744,7 +822,8 @@
const ey = sy + stepY; const ey = sy + stepY;
const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, { const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, {
searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170), searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170),
maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24), maxChecks: sweepMaxChecks,
maxRects: sweepMaxRects,
}); });
if (hit && hit.t <= 1) { if (hit && hit.t <= 1) {
const safeT = Math.max(0, hit.t - 0.035); const safeT = Math.max(0, hit.t - 0.035);
@ -758,8 +837,11 @@
collided = this.resolveSolidObstacleCollision(t, { collided = this.resolveSolidObstacleCollision(t, {
maxPasses: 3, maxPasses: 3,
searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170), searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170),
maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24), maxChecks: sweepMaxChecks,
maxRects: sweepMaxRects,
slop: 0.30, slop: 0.30,
oneWayDx: stepX,
oneWayDy: stepY,
reason: opts.reason || "tarinai-swept-hit", reason: opts.reason || "tarinai-swept-hit",
}) || true; }) || true;
continue; continue;
@ -769,8 +851,11 @@
const passHit = this.resolveSolidObstacleCollision(t, { const passHit = this.resolveSolidObstacleCollision(t, {
maxPasses: 2, maxPasses: 2,
searchRadius: Math.max(rr + 96, rr + Math.hypot(stepX, stepY) + 140), searchRadius: Math.max(rr + 96, rr + Math.hypot(stepX, stepY) + 140),
maxChecks: Math.max(opts.light ? 10 : 28, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24), maxChecks: sweepMaxChecks,
maxRects: sweepMaxRects,
slop: 0.35, slop: 0.35,
oneWayDx: stepX,
oneWayDy: stepY,
reason: opts.reason || "tarinai-swept-move", reason: opts.reason || "tarinai-swept-move",
}); });
collided = passHit || collided; collided = passHit || collided;
@ -791,9 +876,10 @@
const maxPasses = Math.max(1, Math.min(4, Number(opts.maxPasses ?? 2) || 2)); const maxPasses = Math.max(1, Math.min(4, Number(opts.maxPasses ?? 2) || 2));
const searchRadius = Math.max(rr + 40, Number(opts.searchRadius || rr + 150) || (rr + 150)); const searchRadius = Math.max(rr + 40, Number(opts.searchRadius || rr + 150) || (rr + 150));
const maxChecks = Math.max(1, Number(opts.maxChecks || CONFIG.maxFenceCollisionChecks || 24) || 24); const maxChecks = Math.max(1, Number(opts.maxChecks || CONFIG.maxFenceCollisionChecks || 24) || 24);
const maxRects = Math.max(12, Math.min(128, Number(opts.maxRects || 0) || Math.ceil(maxChecks * 2.5)));
for (let pass = 0; pass < maxPasses; pass++) { for (let pass = 0; pass < maxPasses; pass++) {
let passPushed = false; let passPushed = false;
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, searchRadius, { maxChecks })) { for (const rect of this.nearbySolidObstacleRects(t.x, t.y, searchRadius, { maxChecks, maxRects })) {
if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue;
if (this.pushTarinaiOutOfRect(t, rect, rr, opts)) { if (this.pushTarinaiOutOfRect(t, rect, rr, opts)) {
pushed = true; pushed = true;
@ -807,7 +893,7 @@
const pad = CONFIG.worldPadding + Math.max(2, (Number(t.radius) || 22) * 0.18); const pad = CONFIG.worldPadding + Math.max(2, (Number(t.radius) || 22) * 0.18);
t.x = clamp(t.x, pad, this.w - pad); t.x = clamp(t.x, pad, this.w - pad);
t.y = clamp(t.y, pad, this.h - pad); t.y = clamp(t.y, pad, this.h - pad);
const maxV = bounced ? 520 : 130; const maxV = bounced ? 1280 : 130;
t.vx = clamp(t.vx || 0, -maxV, maxV); t.vx = clamp(t.vx || 0, -maxV, maxV);
t.vy = clamp(t.vy || 0, -maxV, maxV); t.vy = clamp(t.vy || 0, -maxV, maxV);
} }
@ -836,6 +922,7 @@
const type = rect?.item?.type || rect?.type || ""; const type = rect?.item?.type || rect?.type || "";
if (!type || type === "glass_wall") return false; if (!type || type === "glass_wall") return false;
if (type === "gate_fence" && rect?.item?.gateOpen) return false; if (type === "gate_fence" && rect?.item?.gateOpen) return false;
if (type === "one_way_fence") return true;
// Path routing uses this predicate even though the queried solid rects are // Path routing uses this predicate even though the queried solid rects are
// not limited to fences. Nest containers have real collision footprints; // not limited to fences. Nest containers have real collision footprints;
// include them here so tarinai route around a non-target nest instead of // include them here so tarinai route around a non-target nest instead of
@ -846,6 +933,12 @@
return Boolean(this.isFenceType?.(type)); return Boolean(this.isFenceType?.(type));
}, },
oneWayFenceAllowsPath(x1, y1, x2, y2, rect) {
const dx = (Number(x2) || 0) - (Number(x1) || 0);
const dy = (Number(y2) || 0) - (Number(y1) || 0);
return Geometry.oneWayFenceAllowsMotion(rect, x1, y1, dx, dy);
},
pathBlockedByFence(x1, y1, x2, y2, padding = 16, opts = {}) { pathBlockedByFence(x1, y1, x2, y2, padding = 16, opts = {}) {
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
@ -854,6 +947,7 @@
for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) { for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) {
if (exclude && rect?.item === exclude) continue; if (exclude && rect?.item === exclude) continue;
if (!this.isFenceRoutingRect?.(rect)) continue; if (!this.isFenceRoutingRect?.(rect)) continue;
if (rect?.oneWay && this.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) continue;
if (rect?.oriented) { if (rect?.oriented) {
const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }; const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding };
if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
@ -876,6 +970,9 @@
const searchRadius = Math.max(48, Number(padding || 0) + 72); const searchRadius = Math.max(48, Number(padding || 0) + 72);
for (const rect of this.nearbySolidObstacleRects?.(x, y, searchRadius, { exclude, maxChecks: Math.max(10, Number(opts.maxChecks || 0) || 24) }) || []) { for (const rect of this.nearbySolidObstacleRects?.(x, y, searchRadius, { exclude, maxChecks: Math.max(10, Number(opts.maxChecks || 0) || 24) }) || []) {
if (exclude && rect?.item === exclude) continue; if (exclude && rect?.item === exclude) continue;
// Directional pathfinding evaluates one-way fences on edges, not as
// permanently blocked grid points. Other callers retain solid occupancy.
if (rect?.oneWay && opts?.directionalOneWay === true) continue;
if (this.pointInRect?.(x, y, rect, padding)) return true; if (this.pointInRect?.(x, y, rect, padding)) return true;
} }
return false; return false;

View file

@ -1,6 +1,40 @@
"use strict"; "use strict";
function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
const axes = ["aggression", "openness", "sociability"];
const profileFor = (birthSeed, profileOpts = {}) => globalThis.TarinaiSeedFactory?.birthProfile?.(birthSeed || "", profileOpts)?.birthPersonality || {};
const childBase = normalizePersonality(profileFor(seed, opts));
let inheritedAny = false;
for (const key of axes) {
let totalLegacy = 0;
let parentCount = 0;
for (const parent of parents || []) {
if (!parent) continue;
const parentBase = normalizePersonality(profileFor(parent.birthSeed || parent.familyKey || ""));
const born = normalizePersonality(parent.birthPersonality || parentBase);
// A champion's three boosted axes become a heritable lineage delta. A
// descendant that inherited that delta can pass a damped form onward.
const inheritedDelta = Math.max(0, (Number(born[key]) || 0) - (Number(parentBase[key]) || 0));
let championDelta = 0;
if (parent.isTarinaiChampion) {
const baseValue = Number(parentBase[key]) || 0;
const championTarget = clampPersonalityValue(Math.max(baseValue + 0.48, 0.86));
championDelta = Math.max(0, championTarget - baseValue);
}
const legacy = Math.max(inheritedDelta, championDelta);
if (legacy <= 0) continue;
totalLegacy += legacy;
parentCount += 1;
}
if (parentCount > 0 && totalLegacy > 0) {
childBase[key] = clampPersonalityValue((Number(childBase[key]) || 0) + (totalLegacy / parentCount) * 0.70);
inheritedAny = true;
}
}
return inheritedAny ? childBase : null;
}
(function (global) { (function (global) {
function socialPersonalityProfile(tarinai) { function socialPersonalityProfile(tarinai) {
@ -500,6 +534,13 @@
if (!opts.genetics && seedParents.length >= 2 && typeof inheritedGenetics === "function") { if (!opts.genetics && seedParents.length >= 2 && typeof inheritedGenetics === "function") {
opts.genetics = inheritedGenetics(seedParents[0], seedParents[1], opts.birthSeed || opts.familyKey || ""); opts.genetics = inheritedGenetics(seedParents[0], seedParents[1], opts.birthSeed || opts.familyKey || "");
} }
if (!opts.birthPersonality && seedParents.length >= 2) {
const inheritedPersonality = inheritedChampionPersonality(opts.birthSeed || opts.familyKey || "", seedParents, opts);
if (inheritedPersonality) {
opts.birthPersonality = inheritedPersonality;
if (!opts.currentPersonality) opts.currentPersonality = { ...inheritedPersonality };
}
}
if (opts.genetics) { if (opts.genetics) {
if (!Number.isFinite(opts.adultScale) && typeof adultScaleFromGenetics === "function") opts.adultScale = adultScaleFromGenetics(opts.birthSeed || opts.familyKey || "", opts.genetics); if (!Number.isFinite(opts.adultScale) && typeof adultScaleFromGenetics === "function") opts.adultScale = adultScaleFromGenetics(opts.birthSeed || opts.familyKey || "", opts.genetics);
if (!Number.isFinite(opts.lifeSpan)) { if (!Number.isFinite(opts.lifeSpan)) {
@ -821,6 +862,7 @@
lifeItemMode: "", lifeItemMode: "",
}); });
if (!child) return null; if (!child) return null;
this.birthEventCount = Math.max(0, Number(this.birthEventCount || 0) || 0) + 1;
if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true }); if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true });
this.maxGeneration = Math.max(this.maxGeneration, child.generation); this.maxGeneration = Math.max(this.maxGeneration, child.generation);
a.hasPaired = true; a.hasPaired = true;

View file

@ -29,7 +29,7 @@
const cached = actor._pathWaypoint || null; const cached = actor._pathWaypoint || null;
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0) if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
&& Number.isFinite(cached.x) && Number.isFinite(cached.y) && Number.isFinite(cached.x) && Number.isFinite(cached.y)
&& !this.pointBlockedByObstacle(cached.x, cached.y, pointClearance, { exclude, maxChecks: 18 }) && !this.pointBlockedByObstacle(cached.x, cached.y, pointClearance, { exclude, maxChecks: 18, directionalOneWay: true })
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) { && !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId }; return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId };
} }
@ -89,7 +89,7 @@
let bestScore = Infinity; let bestScore = Infinity;
const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null; const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null;
for (const c of candidates) { for (const c of candidates) {
if (this.pointBlockedByObstacle(c.x, c.y, pointClearance, { exclude, maxChecks: 20 })) continue; if (this.pointBlockedByObstacle(c.x, c.y, pointClearance, { exclude, maxChecks: 20, directionalOneWay: true })) continue;
const firstBlocked = this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude }); const firstBlocked = this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude });
if (firstBlocked) continue; if (firstBlocked) continue;
const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude }); const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude });
@ -120,7 +120,7 @@
const exclude = opts.exclude || opts.targetItem || target || null; const exclude = opts.exclude || opts.targetItem || target || null;
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0) if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
&& Number.isFinite(cached.x) && Number.isFinite(cached.y) && Number.isFinite(cached.x) && Number.isFinite(cached.y)
&& !this.pointBlockedByObstacle?.(cached.x, cached.y, pointClearance, { exclude, maxChecks: 20 }) && !this.pointBlockedByObstacle?.(cached.x, cached.y, pointClearance, { exclude, maxChecks: 20, directionalOneWay: true })
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) { && !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId, gridPath: true }; return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
} }
@ -146,7 +146,7 @@
if (!inBounds(ix, iy)) return false; if (!inBounds(ix, iy)) return false;
if (ix === start.ix && iy === start.iy) return true; if (ix === start.ix && iy === start.iy) return true;
if (ix === goal.ix && iy === goal.iy) return true; if (ix === goal.ix && iy === goal.iy) return true;
return !this.pointBlockedByObstacle?.(px(ix), py(iy), pointClearance, { exclude, maxChecks: 18 }); return !this.pointBlockedByObstacle?.(px(ix), py(iy), pointClearance, { exclude, maxChecks: 18, directionalOneWay: true });
}; };
const edgeOpen = (a, b) => !this.pathBlockedByFence?.(px(a.ix), py(a.iy), px(b.ix), py(b.iy), padding, { exclude }); const edgeOpen = (a, b) => !this.pathBlockedByFence?.(px(a.ix), py(a.iy), px(b.ix), py(b.iy), padding, { exclude });
const h = (ix, iy) => Math.hypot(px(ix) - tx, py(iy) - ty); const h = (ix, iy) => Math.hypot(px(ix) - tx, py(iy) - ty);

View file

@ -412,7 +412,7 @@
if (speedNum) speedNum.value = String(deg); if (speedNum) speedNum.value = String(deg);
if (thick) thick.value = String(initialThickness); if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness); if (thickNum) thickNum.value = String(initialThickness);
if (poweredInput) poweredInput.checked = phyScalar(item, "motorOn", true) !== false; if (poweredInput) { poweredInput.checked = phyScalar(item, "motorOn", true) !== false; poweredInput.disabled = Boolean(item._signalDriven); poweredInput.title = item._signalDriven ? "電線信号で制御中" : ""; }
editor.syncInputPair(speed, speedNum, controller.draw); editor.syncInputPair(speed, speedNum, controller.draw);
editor.syncInputPair(thick, thickNum, controller.draw); editor.syncInputPair(thick, thickNum, controller.draw);
const close = () => { const close = () => {
@ -430,7 +430,7 @@
phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage"); phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage");
} else { } else {
phySet(item, "motorSpeed", (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180, "editor-speed"); phySet(item, "motorSpeed", (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180, "editor-speed");
phySet(item, "motorOn", poweredInput ? Boolean(poweredInput.checked) : phyScalar(item, "motorOn", true) !== false, "editor-power"); if (!item._signalDriven) phySet(item, "motorOn", poweredInput ? Boolean(poweredInput.checked) : phyScalar(item, "motorOn", true) !== false, "editor-power");
if (phyScalar(item, "motorOn", true)) phySet(item, "spin", 0, "editor-spin-reset"); if (phyScalar(item, "motorOn", true)) phySet(item, "spin", 0, "editor-spin-reset");
} }
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, isPoisonBlock ? "poison-block-edited" : "rotator-edited")) global.TarinaiMechanicalSystem.invalidateGeometry(item); if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, isPoisonBlock ? "poison-block-edited" : "rotator-edited")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
@ -550,7 +550,7 @@
if (travelNum) travelNum.value = String(travelVal); if (travelNum) travelNum.value = String(travelVal);
if (thick) thick.value = String(initialThickness); if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness); if (thickNum) thickNum.value = String(initialThickness);
if (powered) powered.checked = phyScalar(item, "railOn", true) !== false; if (powered) { powered.checked = phyScalar(item, "railOn", true) !== false; powered.disabled = Boolean(item._signalDriven); powered.title = item._signalDriven ? "電線信号で制御中" : ""; }
if (solidInput) solidInput.checked = phyScalar(item, "solid", true) !== false; if (solidInput) solidInput.checked = phyScalar(item, "solid", true) !== false;
editor.syncInputPair(angle, angleNum); editor.syncInputPair(angle, angleNum);
editor.syncInputPair(speed, speedNum); editor.syncInputPair(speed, speedNum);
@ -569,7 +569,7 @@
phySet(item, "railAxis", railAxis, "editor-rail-axis"); phySet(item, "railAxis", railAxis, "editor-rail-axis");
phySet(item, "railMotorSpeed", Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0)), "editor-rail-speed"); phySet(item, "railMotorSpeed", Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0)), "editor-rail-speed");
phySet(item, "railTravel", Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150)), "editor-rail-travel"); phySet(item, "railTravel", Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150)), "editor-rail-travel");
phySet(item, "railOn", powered ? Boolean(powered.checked) : phyScalar(item, "railOn", true) !== false, "editor-rail-power"); if (!item._signalDriven) phySet(item, "railOn", powered ? Boolean(powered.checked) : phyScalar(item, "railOn", true) !== false, "editor-rail-power");
phySet(item, "solid", solidInput ? Boolean(solidInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid"); phySet(item, "solid", solidInput ? Boolean(solidInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
if (phyScalar(item, "railOn", true)) phySet(item, "slideSpeed", 0, "editor-slide-reset"); if (phyScalar(item, "railOn", true)) phySet(item, "slideSpeed", 0, "editor-slide-reset");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)), "editor-thickness"); phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)), "editor-thickness");
@ -606,7 +606,7 @@
if (!it || it.dead) continue; if (!it || it.dead) continue;
const type = String(it.type || ""); const type = String(it.type || "");
if (type === "trace" || type === "splat" || type === "ant_corpse") continue; if (type === "trace" || type === "splat" || type === "ant_corpse") continue;
if (type === "rope" || type === "rod") { if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(type)) {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const pb = physicsApi(); const pb = physicsApi();
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef);
@ -790,8 +790,69 @@
} }
function ensureSignalItemEditor() {
return ensureEditorDialog("signalItemEditor", "rotator-editor hidden", `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signalItemEditorTitle">
<div class="rotator-editor-titlebar"><h2 id="signalItemEditorTitle">検知器設定</h2><button id="signalItemEditorClose" class="rotator-editor-close" type="button">×</button></div>
<div class="rotator-editor-controls">
<label>検知対象 <select id="pressureSwitchTarget"><option value="tarinai">たりない</option><option value="item">アイテム</option></select></label>
<label>起動個数 <input id="pressureSwitchCount" type="number" min="1" max="999" step="1"></label>
<label>横幅 <input id="pressureSwitchWidth" type="range" min="60" max="840" step="20"><input id="pressureSwitchWidthNum" type="number" min="60" max="840" step="20"> px</label>
<label>縦幅 <input id="pressureSwitchHeight" type="range" min="60" max="840" step="20"><input id="pressureSwitchHeightNum" type="number" min="60" max="840" step="20"> px</label>
</div>
<div class="rotator-editor-actions"><button id="signalItemEditorCancel" class="btn" type="button">キャンセル</button><button id="signalItemEditorApply" class="btn primary" type="button">保存</button></div>
</div>`);
}
function openSignalItemEditor(item, worldRef) {
if (!item || item.type !== "pressure_switch") return false;
const dialog = ensureSignalItemEditor();
const target = dialog.querySelector("#pressureSwitchTarget");
const count = dialog.querySelector("#pressureSwitchCount");
const width = dialog.querySelector("#pressureSwitchWidth");
const widthNum = dialog.querySelector("#pressureSwitchWidthNum");
const height = dialog.querySelector("#pressureSwitchHeight");
const heightNum = dialog.querySelector("#pressureSwitchHeightNum");
target.value = item.pressureTarget === "item" ? "item" : "tarinai";
count.value = String(item.pressureThreshold || 1);
width.value = widthNum.value = String(item.pressureWidth || 220);
height.value = heightNum.value = String(item.pressureHeight || 160);
const updateOverlay = () => {
worldRef._pressureSwitchOverlay = {
item,
width: Math.max(60, Math.min(840, Number(widthNum.value || width.value || 220) | 0)),
height: Math.max(60, Math.min(840, Number(heightNum.value || height.value || 160) | 0)),
};
global.render?.();
};
syncEditorInputPair(width, widthNum, updateOverlay);
syncEditorInputPair(height, heightNum, updateOverlay);
updateOverlay();
const close = () => {
dialog.classList.add("hidden");
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
global.render?.();
};
dialog.querySelector("#signalItemEditorApply").onclick = () => {
global.TarinaiHistory.capture(worldRef, "detector-edit");
item.pressureTarget = target.value === "item" ? "item" : "tarinai";
item.pressureThreshold = Math.max(1, Math.min(999, Number(count.value || 1) | 0));
item.pressureWidth = Math.max(60, Math.min(840, Number(widthNum.value || width.value || 220) | 0));
item.pressureHeight = Math.max(60, Math.min(840, Number(heightNum.value || height.value || 160) | 0));
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("detector-edit");
global.render?.();
close();
};
dialog.querySelector("#signalItemEditorCancel").onclick = close;
dialog.querySelector("#signalItemEditorClose").onclick = close;
dialog.onclick = e => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
return true;
}
function isEditableItem(item) { function isEditableItem(item) {
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan"); return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan" || item.type === "pressure_switch");
} }
function findEditableItemAt(worldRef, x, y) { function findEditableItemAt(worldRef, x, y) {
@ -819,6 +880,7 @@
if (item.type === "rotator" || item.type === "poison_block") return openRotatorEditor(item, worldRef); if (item.type === "rotator" || item.type === "poison_block") return openRotatorEditor(item, worldRef);
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef); if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
if (item.type === "fan") return openFanEditor(item, worldRef); if (item.type === "fan") return openFanEditor(item, worldRef);
if (item.type === "pressure_switch") return openSignalItemEditor(item, worldRef);
if (item.type === "gate_fence") { if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen; item.gateOpen = !item.gateOpen;
worldRef?.markSpatialDirty?.("gate-toggle"); worldRef?.markSpatialDirty?.("gate-toggle");
@ -876,7 +938,7 @@
function isPhysicsObstacleItemType(type = "") { function isPhysicsObstacleItemType(type = "") {
const key = String(type || ""); const key = String(type || "");
return Boolean( return Boolean(
key === "rope" || key === "rod" key === "rope" || key === "rod" || key === "spring"
|| global.TarinaiMechanicalSystem.isMechanicalType(key) || global.TarinaiMechanicalSystem.isMechanicalType(key)
|| (typeof isFenceItemType === "function" && isFenceItemType(key)) || (typeof isFenceItemType === "function" && isFenceItemType(key))
); );
@ -902,7 +964,7 @@
function placementItemOverlapsSoftItem(worldRef, placedItem, softItem) { function placementItemOverlapsSoftItem(worldRef, placedItem, softItem) {
if (!worldRef || !placedItem || !softItem || softItem.dead) return false; if (!worldRef || !placedItem || !softItem || softItem.dead) return false;
const r = softItemRadius(softItem); const r = softItemRadius(softItem);
if (placedItem.type === "rope" || placedItem.type === "rod") { if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(placedItem.type)) {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const pb = physicsApi(); const pb = physicsApi();
const a = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 0), worldRef); const a = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 0), worldRef);
@ -921,7 +983,7 @@
function placementSoftSearchRadius(item) { function placementSoftSearchRadius(item) {
let search = Math.max(80, Number(item?.r || item?.radius || 36) + 90); let search = Math.max(80, Number(item?.r || item?.radius || 36) + 90);
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) search = Math.max(search, (global.TarinaiMechanicalSystem.reach(item) || item.r || 80) + 96); if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) search = Math.max(search, (global.TarinaiMechanicalSystem.reach(item) || item.r || 80) + 96);
else if (item?.type === "rope" || item?.type === "rod") search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96); else if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(item?.type)) search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96);
return search; return search;
} }
@ -1038,7 +1100,7 @@
const rect = this.fenceRect(item); const rect = this.fenceRect(item);
if (!rect) return false; if (!rect) return false;
const baseMargin = item.type === "gate_fence" ? 1.5 : Math.max(3, (item.r || 42) * 0.12); const baseMargin = item.type === "gate_fence" ? 1.5 : Math.max(3, (item.r || 42) * 0.12);
if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true; if (rect.left < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.top < 0 || rect.bottom > this.h) return true;
const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 56; const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 56;
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) { for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
if (!it || it === item || it.dead) continue; if (!it || it === item || it.dead) continue;
@ -1237,7 +1299,7 @@
handleClick(x, y) { handleClick(x, y) {
const tool = this.tool; const tool = this.tool;
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "new"].includes(tool)) { if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new"].includes(tool)) {
const editable = findEditableItemAt(this, x, y); const editable = findEditableItemAt(this, x, y);
if (editable && openEditableItemEditor(editable, this)) { if (editable && openEditableItemEditor(editable, this)) {
global.TarinaiCommands.setSelection(this, null) || (this.selected = null); global.TarinaiCommands.setSelection(this, null) || (this.selected = null);
@ -1378,10 +1440,9 @@
if (itemType) { if (itemType) {
let px = x, py = y; let px = x, py = y;
const rawItem = this.applyToolSize(new Item(itemType, px, py)); const rawItem = this.applyToolSize(new Item(itemType, px, py));
if (isRotatableItemType(itemType)) rawItem.angle = this.toolAngleFor ? this.toolAngleFor(itemType) : (defaultItemAngle(itemType)); if (isRotatableItemType(itemType)) {
if (itemType === "reciprocator") { const requestedAngle = this.toolAngleFor ? this.toolAngleFor(itemType) : defaultItemAngle(itemType);
global.TarinaiPhysicsBodySystem.setScalar(rawItem, "railAxis", rawItem.angle, "raw-item-axis"); global.TarinaiPlacementPreviewSystem.applyToolOrientation(this, rawItem, requestedAngle);
rawItem.angle = defaultItemAngle(itemType);
} }
// Placement uses the same footprint that the preview validates. // Placement uses the same footprint that the preview validates.
const placed = this.placeItem(rawItem, true); const placed = this.placeItem(rawItem, true);

View file

@ -7,13 +7,10 @@
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
beginFramePerformanceBudgets() { beginFramePerformanceBudgets() {
const count = Math.max(1, (this.tarinai || []).length || 1); const profile = global.TarinaiPerf?.performanceProfile?.() || {};
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
const aiCap = mobile ? 8 : 12;
const envCap = mobile ? 4 : 6;
this.workBudget = { this.workBudget = {
ai: Math.max(1, Math.min(aiCap, Math.ceil(count / 2))), ai: Math.max(1, Number(profile.aiBudget || 10)),
env: Math.max(1, Math.min(envCap, Math.ceil(count / 3))), env: Math.max(1, Number(profile.envBudget || 5)),
aiUsed: 0, aiUsed: 0,
envUsed: 0, envUsed: 0,
}; };
@ -122,6 +119,7 @@
scheduler: this._itemUpdateSchedulerStats || null, scheduler: this._itemUpdateSchedulerStats || null,
creatures: this._creatureUpdateStats || null, creatures: this._creatureUpdateStats || null,
effects: this._effectUpdateStats || null, effects: this._effectUpdateStats || null,
effectRender: this._effectRenderStats || null,
terrain: this.terrainDirtyStatsThisFrame || null, terrain: this.terrainDirtyStatsThisFrame || null,
nearby: this.nearbyQueryStatsThisFrame || null, nearby: this.nearbyQueryStatsThisFrame || null,
constraintDirty: this.constraintDirtyStatsThisFrame || null, constraintDirty: this.constraintDirtyStatsThisFrame || null,
@ -366,14 +364,17 @@
if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map(); if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map();
if (!this.itemIdMap) this.itemIdMap = new Map(); if (!this.itemIdMap) this.itemIdMap = new Map();
if (!this.itemOwnerBuckets) this.itemOwnerBuckets = new Map(); if (!this.itemOwnerBuckets) this.itemOwnerBuckets = new Map();
if (!this.carriedPlushies) this.carriedPlushies = [];
for (const key of Object.keys(counts)) counts[key] = 0; for (const key of Object.keys(counts)) counts[key] = 0;
this.itemTypeBuckets.clear(); this.itemTypeBuckets.clear();
this.itemIdMap.clear(); this.itemIdMap.clear();
this.itemOwnerBuckets.clear(); this.itemOwnerBuckets.clear();
this.carriedPlushies.length = 0;
for (const it of this.items) { for (const it of this.items) {
if (!it || it.dead) continue; if (!it || it.dead) continue;
counts[it.type] = (counts[it.type] || 0) + 1; counts[it.type] = (counts[it.type] || 0) + 1;
if (it.id != null) this.itemIdMap.set(it.id, it); if (it.id != null) this.itemIdMap.set(it.id, it);
if (it.isStructure && it.type === "plushie" && it.carriedById) this.carriedPlushies.push(it);
if (it.ownerId) { if (it.ownerId) {
let ownerBucket = this.itemOwnerBuckets.get(it.ownerId); let ownerBucket = this.itemOwnerBuckets.get(it.ownerId);
if (!ownerBucket) { if (!ownerBucket) {
@ -417,21 +418,6 @@
return changed; return changed;
}, },
compactEffects() {
const limit = CONFIG.effectLimit ?? 96;
if (Number.isFinite(limit) && this.effects.length > limit) {
this.effects.sort((a, b) => (a.life / Math.max(a.maxLife || 1, 0.001)) - (b.life / Math.max(b.maxLife || 1, 0.001)));
const remove = this.effects.length - limit;
for (let i = 0; i < remove; i++) this.effects[i].life = 0;
}
let write = 0;
for (let read = 0; read < this.effects.length; read++) {
const ef = this.effects[read];
if (!ef.dead) this.effects[write++] = ef;
}
this.effects.length = write;
},
compactTarinai() { compactTarinai() {
let write = 0; let write = 0;
for (let read = 0; read < this.tarinai.length; read++) { for (let read = 0; read < this.tarinai.length; read++) {

View file

@ -21,12 +21,25 @@
findDeleteToolTargetAt(x, y) { findDeleteToolTargetAt(x, y) {
let best = null; let best = null;
let bestD = Infinity; let bestD = Infinity;
if (wireTool) {
const antCandidates = this.nearbyAnts?.(x, y, 44, true) || this.ants || [];
for (let i = antCandidates.length - 1; i >= 0; i -= 1) {
const ant = antCandidates[i];
if (!ant || ant.dead) continue;
const hit = Math.max(10, (Number(ant.r) || 6) * 1.9);
const d = distXY(x, y, ant.x, ant.y);
if (d <= hit && d < bestD) {
best = { kind: "ant", id: ant.id || "", _ref: ant, type: ant.kind || "ant", label: ant.kind === "queen" ? "女王アリ" : "アリ", x: ant.x, y: ant.y, localX: 0, localY: 0, center: true };
bestD = d;
}
}
}
const itemCandidates = this.nearbyItems?.(x, y, 190, true) || this.items || []; const itemCandidates = this.nearbyItems?.(x, y, 190, true) || this.items || [];
for (let i = itemCandidates.length - 1; i >= 0; i -= 1) { for (let i = itemCandidates.length - 1; i >= 0; i -= 1) {
const it = itemCandidates[i]; const it = itemCandidates[i];
if (!it || it.dead) continue; if (!it || it.dead) continue;
let d = distXY(x, y, it.x, it.y); let d = distXY(x, y, it.x, it.y);
if (it.type === "rope" || it.type === "rod") { if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type)) {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const pb = global.TarinaiPhysicsBodySystem; const pb = global.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
@ -74,7 +87,7 @@
if (isPinType(it.type) && it.pinState === "lodged") continue; if (isPinType(it.type) && it.pinState === "lodged") continue;
const hitR = Math.max(8, it.r || itemRadiusFor?.(it.type, 12) || 12); const hitR = Math.max(8, it.r || itemRadiusFor?.(it.type, 12) || 12);
let d = distXY(x, y, it.x, it.y); let d = distXY(x, y, it.x, it.y);
if (it.type === "rope" || it.type === "rod") { if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type)) {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const pb = global.TarinaiPhysicsBodySystem; const pb = global.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
@ -129,6 +142,7 @@
findLinkEndpointAt(x, y) { findLinkEndpointAt(x, y) {
let best = null; let best = null;
let bestD = Infinity; let bestD = Infinity;
const wireTool = this.tool === "wire" || this.tool === "insulated_wire";
for (let i = (this.tarinai || []).length - 1; i >= 0; i -= 1) { for (let i = (this.tarinai || []).length - 1; i >= 0; i -= 1) {
const t = this.tarinai[i]; const t = this.tarinai[i];
if (!t || t.dead || this.isTarinaiHiddenInNestBox?.(t)) continue; if (!t || t.dead || this.isTarinaiHiddenInNestBox?.(t)) continue;
@ -143,10 +157,11 @@
for (let i = itemCandidates.length - 1; i >= 0; i -= 1) { for (let i = itemCandidates.length - 1; i >= 0; i -= 1) {
const it = itemCandidates[i]; const it = itemCandidates[i];
if (!it || it.dead) continue; if (!it || it.dead) continue;
if ((this.tool === "rope" || this.tool === "rod") && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue; if ((this.tool === "rope" || this.tool === "rod" || this.tool === "spring") && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue;
if (wireTool && ["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type)) continue;
const isMechanism = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(it.type)); const isMechanism = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(it.type));
const isFence = this.isFenceType?.(it.type) || false; const isFence = this.isFenceType?.(it.type) || false;
const isConstraint = it.type === "rope" || it.type === "rod"; const isConstraint = ["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type);
let hit = false; let hit = false;
let d = distXY(x, y, it.x, it.y); let d = distXY(x, y, it.x, it.y);
if (isConstraint) { if (isConstraint) {
@ -181,7 +196,7 @@
if (isMechanism) global.TarinaiLinkRuntime.snapEndpointToTarget(best, this); if (isMechanism) global.TarinaiLinkRuntime.snapEndpointToTarget(best, this);
bestD = d; bestD = d;
} }
if ((this.tool === "rope" || this.tool === "rod") && bestD > 14) { if ((this.tool === "rope" || this.tool === "rod" || this.tool === "spring") && bestD > 14) {
for (let i = (this.items || []).length - 1; i >= 0; i -= 1) { for (let i = (this.items || []).length - 1; i >= 0; i -= 1) {
const it = this.items[i]; const it = this.items[i];
if (!it || it.dead || !global.TarinaiMechanicalSystem.isMechanicalType(it.type)) continue; if (!it || it.dead || !global.TarinaiMechanicalSystem.isMechanicalType(it.type)) continue;
@ -200,7 +215,8 @@
useLinkToolAt(x, y) { useLinkToolAt(x, y) {
const endpoint = this.findLinkEndpointAt?.(x, y); const endpoint = this.findLinkEndpointAt?.(x, y);
const toolName = this.tool === "rod" ? "\u68d2" : "\u30d2\u30e2"; const linkNames = { rope: "ヒモ", rod: "棒", spring: "バネ", wire: "電線", insulated_wire: "絶縁電線" };
const toolName = linkNames[this.tool] || "接続";
if (!endpoint) { showToast(`${toolName}\u3092\u3064\u306a\u3050\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002`); return true; } if (!endpoint) { showToast(`${toolName}\u3092\u3064\u306a\u3050\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002`); return true; }
if (!this.pendingLinkEndpoint) { if (!this.pendingLinkEndpoint) {
this.pendingLinkEndpoint = endpoint; this.pendingLinkEndpoint = endpoint;
@ -219,7 +235,7 @@
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const p1 = runtime?.endpointWorld?.(first, this) || { x: first.x, y: first.y }; const p1 = runtime?.endpointWorld?.(first, this) || { x: first.x, y: first.y };
const p2 = runtime?.endpointWorld?.(endpoint, this) || { x: endpoint.x, y: endpoint.y }; const p2 = runtime?.endpointWorld?.(endpoint, this) || { x: endpoint.x, y: endpoint.y };
const constraintType = this.tool === "rod" ? "rod" : "rope"; const constraintType = ["rod", "spring", "wire", "insulated_wire"].includes(this.tool) ? this.tool : "rope";
const item = new Item(constraintType, (p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5); const item = new Item(constraintType, (p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5);
item.world = this; item.world = this;
const pb = global.TarinaiPhysicsBodySystem; const pb = global.TarinaiPhysicsBodySystem;
@ -305,7 +321,7 @@
if (!it || it.dead || it.type === "trace" || it.type === "splat") continue; if (!it || it.dead || it.type === "trace" || it.type === "splat") continue;
let d = distXY(x, y, it.x, it.y); let d = distXY(x, y, it.x, it.y);
let hit = false; let hit = false;
if (it.type === "rope" || it.type === "rod") { if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type)) {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const pb = global.TarinaiPhysicsBodySystem; const pb = global.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
@ -465,7 +481,7 @@
useToolAt(x, y) { useToolAt(x, y) {
if (this.tool === "delete") return this.deleteItemAt(x, y); if (this.tool === "delete") return this.deleteItemAt(x, y);
if (this.tool === "area_delete") return this.deleteItemsInArea(x, y); if (this.tool === "area_delete") return this.deleteItemsInArea(x, y);
if (this.tool === "rope" || this.tool === "rod") return this.useLinkToolAt(x, y); if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(this.tool)) return this.useLinkToolAt(x, y);
return false; return false;
}, },
})); }));

View file

@ -210,10 +210,7 @@
changedAnts = true; changedAnts = true;
} }
}); });
if (changedAnts) this.compactAnts?.();
if (changedItems) { if (changedItems) {
this.compactItems();
this.updateItemCounts();
this.markSpatialDirty?.("sanitize-out-of-bounds"); this.markSpatialDirty?.("sanitize-out-of-bounds");
this.ensureSpatial?.("sanitize-out-of-bounds"); this.ensureSpatial?.("sanitize-out-of-bounds");
} else if (changedTarinai || changedAnts) { } else if (changedTarinai || changedAnts) {

View file

@ -2,8 +2,7 @@
"""Lightweight regression guard for the buildless Tarinai app. """Lightweight regression guard for the buildless Tarinai app.
Checks static-file consistency, generated-load order, syntax, and the ActionSpec Checks static-file consistency, generated-load order, syntax, and the ActionSpec
registry contract. This intentionally avoids save-data migration checks; save registry contract. This covers static contracts and focused runtime smoke tests. The current codec intentionally supports only the current binary schema.
compatibility is intentionally not retained for save format changes.
""" """
from __future__ import annotations from __future__ import annotations
@ -250,6 +249,26 @@ def check_legacy_physics_props_removed() -> None:
ok("legacy physics property names removed") ok("legacy physics property names removed")
def check_redundant_correction_paths_removed() -> None:
terms = [
"markBodyChanged", "purgeLegacyPhysicsStorage", "nextTarinaiCollisionCheckAt",
"_physicsWorldFrame", "bodyReads", "bodyWrites", "constraintReads", "constraintWrites",
"compactEffects",
]
offenders = []
for path in (ROOT / "js").glob("*.js"):
text = read_text(path)
for term in terms:
if term in text:
offenders.append(f"{path.relative_to(ROOT).as_posix()}:{term}")
if offenders:
fail("redundant correction path returned: " + ", ".join(offenders[:20]))
ball_source = read_text(ROOT / "js/item_dynamic_ball_system.js")
if ball_source.count("tryBallPickup(") != 1:
fail("ball update must perform exactly one pickup check")
ok("redundant correction paths remain removed")
def check_basic_action_spec_runtime() -> None: def check_basic_action_spec_runtime() -> None:
source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"]) source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"])
required = { required = {
@ -459,20 +478,47 @@ def check_current_save_format() -> None:
fail("broad snapshot fields still present after light-save conversion: " + ", ".join(offenders)) fail("broad snapshot fields still present after light-save conversion: " + ", ".join(offenders))
required_codec = [ required_codec = [
'EXPORT_PREFIX = "\\u305f"', 'EXPORT_PREFIX = "\\u305f"',
'SAVE_TEXT_BITS = 12', 'SAVE_TEXT_BITS = 15',
'SAVE_HEADER_CHARS = "\\u3089\\u308A\\u308B"',
"writeStringTable", "writeStringTable",
"SAVE_TEXT_ALPHABET", "saveTextCharFromValue",
"jp4096Encode", "saveTextValueFromCode",
"jp4096Decode", "base32768Encode",
"writeCurrentItemExtra", "base32768Decode",
"readCurrentItemExtra", "makeSaveFrame",
"openSaveFrame",
"writeTarinaiBitPlane",
"readTarinaiBitPlane",
"TARINAI_COLUMN_LAYOUT",
"chooseCommonExtraPlan",
"standardItemAmount",
"chooseAmountPlan",
"worldSeedWords",
"saveTextCharFromValue",
"saveTextValueFromCode",
"itemFlagPlaneBytes",
"writeNormalItemExtra",
"readNormalItemExtra",
"writeBirthDescriptor",
"readBirthDescriptor",
"async function encodeSnapshot", "async function encodeSnapshot",
"async function decodeSnapshot", "async function decodeSnapshot",
] ]
for token in required_codec: for token in required_codec:
if token not in save_codec: if token not in save_codec:
fail(f"binary save codec token missing: {token}") fail(f"binary save codec token missing: {token}")
if "SAVE_TEXT_ALPHABET" in save_codec or "SAVE_TEXT_REVERSE" in save_codec:
fail("15-bit save text still allocates the legacy alphabet or reverse map")
if "writer.u(Snapshot.version)" in save_codec:
fail("duplicate snapshot version remains in the binary header")
if "writeSaveString(writer, w[8]" in save_codec:
fail("worldSeed is still stored as a string")
for token in ("formatHeader & 0x7f", "formatHeader & 0x80", "payload.length * 2 + (useCompressed ? 1 : 0)"):
if token not in save_codec:
fail(f"compact save framing token missing: {token}")
if "tailMask" in save_codec:
fail("redundant Tarinai tailMask remains in current schema")
if "costBits < best.costBits" not in save_codec or "itemFlagPlaneBytes" not in save_codec:
fail("cost-based common extra or continuous item flag plane is missing")
required_storage = [ required_storage = [
'STORAGE_PREFIX = "tarinai_japanese_hash_slot_v1_"', 'STORAGE_PREFIX = "tarinai_japanese_hash_slot_v1_"',
'hash.startsWith("\\u305f")', 'hash.startsWith("\\u305f")',
@ -1277,10 +1323,14 @@ context.window = context;
context.TarinaiPerf = {{ begin() {{ return null; }} }}; context.TarinaiPerf = {{ begin() {{ return null; }} }};
context.TarinaiConstraintSystem = {{ updateWorld() {{ return 0; }} }}; context.TarinaiConstraintSystem = {{ updateWorld() {{ return 0; }} }};
context.normalizedItemAngle = angle => angle; context.normalizedItemAngle = angle => angle;
context.isRotatableItemType = type => ['rotator', 'poison_block', 'reciprocator'].includes(String(type || ''));
context.defaultItemAngle = type => String(type || '') === 'reciprocator' ? Math.PI / 2 : 0;
context.itemRadiusFor = (_type, fallback = 12) => fallback;
context.toolItemType = tool => tool;
vm.createContext(context); vm.createContext(context);
for (const path of {repr([str(ROOT / rel) for rel in [ for (const path of {repr([str(ROOT / rel) for rel in [
'js/math.js', 'js/geometry_helpers.js', 'js/collision_footprint_system.js', 'js/math.js', 'js/geometry_helpers.js', 'js/collision_footprint_system.js',
'js/physics_world_system.js', 'js/mechanical_system.js', 'js/physics_shape_editor_system.js' 'js/physics_world_system.js', 'js/mechanical_system.js', 'js/placement_preview_system.js', 'js/physics_shape_editor_system.js'
]])}) vm.runInContext(fs.readFileSync(path, 'utf8'), context, {{ filename: path }}); ]])}) vm.runInContext(fs.readFileSync(path, 'utf8'), context, {{ filename: path }});
const fp = context.TarinaiCollisionFootprints; const fp = context.TarinaiCollisionFootprints;
@ -1308,6 +1358,16 @@ function activate(item) {{
if (item.type === 'reciprocator') {{ api.setScalar(item, 'railOn', true, 'test'); api.setScalar(item, 'railAxis', 0, 'test'); api.setScalar(item, 'railMotorSpeed', 80, 'test'); }} if (item.type === 'reciprocator') {{ api.setScalar(item, 'railOn', true, 'test'); api.setScalar(item, 'railAxis', 0, 'test'); api.setScalar(item, 'railMotorSpeed', 80, 'test'); }}
if (item.type === 'poison_block') {{ api.setScalar(item, 'xv', 24, 'test'); api.setScalar(item, 'awakeUntil', 10, 'test'); }} if (item.type === 'poison_block') {{ api.setScalar(item, 'xv', 24, 'test'); api.setScalar(item, 'awakeUntil', 10, 'test'); }}
}} }}
const reverseRotator = makeItem('rotator', 80, 80);
reverseRotator.world = {{ time: 0 }};
context.TarinaiPhysicsBodySystem.ensureBody(reverseRotator, reverseRotator.world);
context.TarinaiPhysicsBodySystem.setScalar(reverseRotator, 'motorSpeed', -1.25, 'reverse-test');
if (Math.abs(context.TarinaiPhysicsBodySystem.scalar(reverseRotator, 'motorSpeed', 0) + 1.25) > 1e-9) throw new Error('negative rotator motor speed was clamped');
reverseRotator.angle = 1.0;
reverseRotator.world = {{ time: 0, drawListDirty: false, markSpatialDirty() {{}}, nearbyItems() {{ return []; }}, nearbyObstacles() {{ return []; }}, items: [], tarinai: [] }};
context.TarinaiMechanicalSystem.updateRotator(reverseRotator, 0.1, reverseRotator.world, {{ skipInteractions: true }});
if (!(reverseRotator.angle < 0.99)) throw new Error('negative rotator speed did not rotate backward');
function pairResult(typeA, typeB, disableB = false) {{ function pairResult(typeA, typeB, disableB = false) {{
const a = makeItem(typeA, 120, 120); const a = makeItem(typeA, 120, 120);
const b = makeItem(typeB, 120, 128); const b = makeItem(typeB, 120, 128);
@ -1330,6 +1390,67 @@ for (const [a, b] of [['rotator', 'rotator'], ['rotator', 'reciprocator'], ['rot
const disabled = pairResult(a, b, true); const disabled = pairResult(a, b, true);
if (disabled.solved !== 0) throw new Error(`${{a}}/${{b}} collision-off pair still interacted`); if (disabled.solved !== 0) throw new Error(`${{a}}/${{b}} collision-off pair still interacted`);
}} }}
const orientationWorld = {{ toolAngleFor() {{ return 0; }} }};
const previewReciprocator = makeItem('reciprocator', 40, 40);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, previewReciprocator, 0);
if (Math.abs(previewReciprocator.physicsBody.pose.angle - Math.PI / 2) > 1e-9) throw new Error('reciprocator placement body angle mismatch');
if (Math.abs(previewReciprocator.physicsBody.rail.axisAngle) > 1e-9) throw new Error('reciprocator placement rail angle mismatch');
const rail = makeItem('reciprocator', 0, 0);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, rail, 0);
activate(rail);
const fence = {{ id: 'fence', type: 'fence_v', x: 8, y: 0, amount: 999, dead: false }};
const collisionWorld = {{
items: [rail, fence], tarinai: [], time: 1, frameCount: 1, w: 900, h: 700, drawListDirty: false, itemBucketRebuildsTotal: 0,
ensureItemBuckets() {{}}, markSpatialDirty() {{}},
nearbyObstacles() {{ return this.items; }}, nearbyItems() {{ return this.items; }},
itemsOfType(type) {{ return this.items.filter(item => item.type === type && !item.dead); }},
isFenceType(type) {{ return type === 'fence_v'; }},
solidObstacleRects(item) {{
if (item !== fence) return [];
return [{{ left: 4, right: 12, top: -100, bottom: 100, cx: 8, cy: 0, halfW: 4, halfH: 100, angle: 0, cos: 1, sin: 0, oriented: true, type: 'fence_v', item }}];
}},
}};
rail.world = collisionWorld; fence.world = collisionWorld;
context.TarinaiMechanicalSystem.updateWorld(collisionWorld, 0.016, {{ maxSubsteps: 1 }});
if (context.TarinaiPhysicsBodySystem.scalar(rail, 'railDir', 1) !== -1) throw new Error('reciprocator did not reverse at a solid obstacle');
const oneWayRail = makeItem('reciprocator', 0, 0);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, oneWayRail, 0);
activate(oneWayRail);
const oneWayFence = {{ id: 'one-way-fence', type: 'one_way_fence', x: 8, y: 0, amount: 999, dead: false }};
const oneWayWorld = {{
items: [oneWayRail, oneWayFence], tarinai: [], time: 1, frameCount: 2, w: 900, h: 700, drawListDirty: false, itemBucketRebuildsTotal: 0,
ensureItemBuckets() {{}}, markSpatialDirty() {{}},
nearbyObstacles() {{ return this.items; }}, nearbyItems() {{ return this.items; }},
itemsOfType(type) {{ return this.items.filter(item => item.type === type && !item.dead); }},
isFenceType(type) {{ return type === 'one_way_fence'; }},
solidObstacleRects(item) {{
if (item !== oneWayFence) return [];
return [{{ left: 4, right: 12, top: -100, bottom: 100, cx: 8, cy: 0, halfW: 4, halfH: 100, angle: 0, cos: 1, sin: 0, oriented: true, type: 'one_way_fence', item, oneWay: true, oneWayNx: 1, oneWayNy: 0 }}];
}},
}};
oneWayRail.world = oneWayWorld; oneWayFence.world = oneWayWorld;
context.TarinaiMechanicalSystem.updateWorld(oneWayWorld, 0.016, {{ maxSubsteps: 1 }});
if (!Number.isFinite(context.TarinaiPhysicsBodySystem.scalar(oneWayRail, 'railDir', 0))) throw new Error('one-way mechanical fence update produced an invalid rail direction');
const cargoRail = makeItem('reciprocator', 0, 0);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, cargoRail, 0);
activate(cargoRail);
const ball = {{ id: 'ball', type: 'ball', x: 8, y: 0, r: 18, radius: 18, vx: 0, vy: 0, dead: false }};
const cargoWorld = {{
items: [cargoRail, ball], tarinai: [], time: 2, frameCount: 2, w: 900, h: 700, drawListDirty: false, itemBucketRebuildsTotal: 0,
ensureItemBuckets() {{}}, markSpatialDirty() {{}},
nearbyObstacles() {{ return this.items; }}, nearbyItems() {{ return this.items; }},
itemsOfType(type) {{ return this.items.filter(item => item.type === type && !item.dead); }},
isFenceType() {{ return false; }}, solidObstacleRects() {{ return []; }},
}};
cargoRail.world = cargoWorld; ball.world = cargoWorld;
context.TarinaiMechanicalSystem.updateWorld(cargoWorld, 0.016, {{ maxSubsteps: 1 }});
if (context.TarinaiPhysicsBodySystem.scalar(cargoRail, 'railDir', 1) !== 1) throw new Error('reciprocator reversed on movable cargo');
if (!(ball.x > 8 || Math.abs(ball.vx) > 0.01 || Math.abs(ball.vy) > 0.01)) throw new Error('reciprocator did not push movable cargo');
""" """
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f: with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js) f.write(js)
@ -1342,7 +1463,7 @@ for (const [a, b] of [['rotator', 'rotator'], ['rotator', 'reciprocator'], ['rot
sys.stderr.write(result.stdout) sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr) sys.stderr.write(result.stderr)
fail("mechanical collision/editor smoke test failed") fail("mechanical collision/editor smoke test failed")
ok("collision-enabled mechanical pairs interact and editor geometry snaps to the 20px lattice") ok("mechanical pairs, obstacle-only reciprocator reversal, cargo pushing, placement orientation, and 20px editor snapping verified")
def colony_limits_and_defaults_smoke() -> None: def colony_limits_and_defaults_smoke() -> None:
tool_defs = read_text(ROOT / "js/item_tool_definitions.js") tool_defs = read_text(ROOT / "js/item_tool_definitions.js")
@ -1403,8 +1524,17 @@ def colony_limits_and_defaults_smoke() -> None:
for rel, token in guarded_sources.items(): for rel, token in guarded_sources.items():
if token not in read_text(ROOT / rel): if token not in read_text(ROOT / rel):
fail(f"object-limit guard missing from {rel}: {token}") fail(f"object-limit guard missing from {rel}: {token}")
if "championMate" not in needs or "championPair" not in social or "mateWeight *= 5.0" not in social: if "championMate" not in needs or "championPair" not in social or "mateWeight *= 4.0" not in social:
fail("champion-pair breeding preference is incomplete") fail("champion-pair breeding preference is incomplete")
charts = read_text(ROOT / "js/ui_charts.js")
family_social = read_text(ROOT / "js/world_family_social.js")
if 'key: "births"' not in charts or 'key: "deaths"' not in charts or "birthEventCount" not in charts:
fail("colony population chart birth/death series are missing")
if "inheritedChampionPersonality" not in family_social or "parent.isTarinaiChampion" not in family_social or "* 0.70" not in family_social:
fail("champion personality inheritance is missing")
inheritance_block = family_social.split("function inheritedChampionPersonality", 1)[1].split("(function (global)", 1)[0]
if "isZunchiSlave" in inheritance_block or "zunchiSlaveLocked" in inheritance_block:
fail("champion inheritance must not alter zunchi-slave handling")
for token in ("tarinaiPopulationLimit", "objectLimit"): for token in ("tarinaiPopulationLimit", "objectLimit"):
if token not in snapshot or token not in codec: if token not in snapshot or token not in codec:
fail(f"colony limit persistence missing: {token}") fail(f"colony limit persistence missing: {token}")
@ -1457,14 +1587,14 @@ world.tarinai[0].dead = true;
if (!world.canAddTarinai(1)) throw new Error('dead tarinai should free capacity'); if (!world.canAddTarinai(1)) throw new Error('dead tarinai should free capacity');
world.setObjectLimit(''); world.setObjectLimit('');
if (world.objectLimit !== 0 || !world.canAddObjects(1000)) throw new Error('blank/zero must mean unlimited'); if (world.objectLimit !== 0 || !world.canAddObjects(1000)) throw new Error('blank/zero must mean unlimited');
context.TarinaiSnapshot = {{ version: 23 }}; context.TarinaiSnapshot = {{ version: 27 }};
context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 28, FIELD_IDS: ['garden'] }}; context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 33, FIELD_IDS: ['garden'], itemTypeValue(i, fallback = '') {{ return fallback; }} }};
context.CONFIG.dayLength = 120; context.CONFIG.dayLength = 120;
context.TextEncoder = TextEncoder; context.TextEncoder = TextEncoder;
context.TextDecoder = TextDecoder; context.TextDecoder = TextDecoder;
vm.runInContext(fs.readFileSync({str(ROOT / 'js/save_codec.js')!r}, 'utf8'), context); vm.runInContext(fs.readFileSync({str(ROOT / 'js/save_codec.js')!r}, 'utf8'), context);
(async () => {{ (async () => {{
const source = {{ v: 23, a: 'tj1', m: [1, 0, 'garden'], w: [0, 0, 0, 120, 0, -9990, 1, 0, 'seed', 0, 12, 345], t: [], i: [] }}; const source = {{ v: 27, a: 'tj1', m: [1, 0, 'garden'], w: [0, 0, 0, 120, 0, -9990, 1, 0, 'seed', 0, 12, 345], t: [], i: [] }};
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(source); const encoded = await context.TarinaiSaveCodec.encodeSnapshot(source);
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded); const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
if (decoded.w[10] !== 12 || decoded.w[11] !== 345) throw new Error('colony limits did not survive save roundtrip'); if (decoded.w[10] !== 12 || decoded.w[11] !== 345) throw new Error('colony limits did not survive save roundtrip');
@ -1492,7 +1622,7 @@ def physics_tuning_smoke() -> None:
dynamic_tools = read_text(ROOT / "js/item_dynamic_tool_system.js") dynamic_tools = read_text(ROOT / "js/item_dynamic_tool_system.js")
initializer = read_text(ROOT / "js/item_type_initializers.js") initializer = read_text(ROOT / "js/item_type_initializers.js")
for token in ("BOUNCE_FENCE_RESTITUTION = 2.40", "BOUNCE_FENCE_MIN_SPEED = 520", "BOUNCE_FENCE_MAX_SPEED = 1180"): for token in ("BOUNCE_FENCE_RESTITUTION = 4.20", "BOUNCE_FENCE_MIN_SPEED = 820", "BOUNCE_FENCE_MAX_SPEED = 1680"):
if token not in environment: if token not in environment:
fail(f"strong bounce-fence tuning missing: {token}") fail(f"strong bounce-fence tuning missing: {token}")
if "const iterations = 8" not in constraints or "const compliance = mode === \"rod\" ? 0.085 / (step * 60) : 0" not in constraints: if "const iterations = 8" not in constraints or "const compliance = mode === \"rod\" ? 0.085 / (step * 60) : 0" not in constraints:
@ -1573,10 +1703,7 @@ context.TarinaiPhysicsBodySystem = {{
setParticles(item, list) {{ item.physicsConstraint.particles = list; return true; }}, setParticles(item, list) {{ item.physicsConstraint.particles = list; return true; }},
ensureConstraint(item) {{ return item.physicsConstraint; }}, ensureConstraint(item) {{ return item.physicsConstraint; }},
applyConstraintState() {{ return true; }}, applyConstraintState() {{ return true; }},
normalizeConstraintState(_item, state) {{ return state; }},
syncPoseFromItem() {{ return true; }}, syncPoseFromItem() {{ return true; }},
normalizeBodyState() {{ return true; }},
markBodyChanged() {{ return true; }},
}}; }};
vm.runInContext(constraintSource, context, {{ filename: 'constraint_system.js' }}); vm.runInContext(constraintSource, context, {{ filename: 'constraint_system.js' }});
const a = {{ id: 'a', type: 'ball', x: 0, y: 0, r: 10, dead: false }}; const a = {{ id: 'a', type: 'ball', x: 0, y: 0, r: 10, dead: false }};
@ -1629,6 +1756,105 @@ if (Math.abs(capturedScale - 0.68) > 1e-9) throw new Error('sticky bomb does not
fail("physics tuning smoke test failed") fail("physics tuning smoke test failed")
ok("round circle template, strong bounce fence, inextensible rope, and small sticky-bomb blast verified") ok("round circle template, strong bounce fence, inextensible rope, and small sticky-bomb blast verified")
def champion_inheritance_smoke() -> None:
source = read_text(ROOT / "js/world_family_social.js")
start = source.find("function inheritedChampionPersonality")
end = source.find("(function (global)", start)
if start < 0 or end < 0:
fail("champion inheritance helper not found")
helper = source[start:end]
js = f"""
const vm = require('vm');
const context = {{ console, globalThis: null }};
context.globalThis = context;
context.normalizePersonality = source => ({{
aggression: Math.max(-1, Math.min(1, Number(source?.aggression || 0))),
openness: Math.max(-1, Math.min(1, Number(source?.openness || 0))),
sociability: Math.max(-1, Math.min(1, Number(source?.sociability || 0))),
neuroticism: Math.max(-1, Math.min(1, Number(source?.neuroticism || 0))),
}});
context.clampPersonalityValue = value => Math.max(-1, Math.min(1, Number(value) || 0));
context.TarinaiSeedFactory = {{ birthProfile() {{ return {{ birthPersonality: {{ aggression: 0, openness: 0, sociability: 0, neuroticism: 0 }} }}; }} }};
vm.createContext(context);
vm.runInContext({helper!r}, context, {{ filename: 'champion_inheritance.js' }});
const normal = {{ birthSeed: 'normal', birthPersonality: {{ aggression: 0, openness: 0, sociability: 0, neuroticism: 0 }}, isTarinaiChampion: false }};
const champion = {{ birthSeed: 'champion', birthPersonality: {{ aggression: 0, openness: 0, sociability: 0, neuroticism: 0 }}, isTarinaiChampion: true }};
const child = context.inheritedChampionPersonality('child', [champion, normal], {{}});
if (!child || child.aggression < 0.5 || child.openness < 0.5 || child.sociability < 0.5) throw new Error('champion boost was not inherited');
if (Math.abs(child.neuroticism) > 1e-9) throw new Error('unboosted personality axis was inherited');
const descendant = {{ birthSeed: 'descendant', birthPersonality: child, isTarinaiChampion: false }};
const grandchild = context.inheritedChampionPersonality('grandchild', [descendant, normal], {{}});
if (!grandchild || !(grandchild.aggression > 0 && grandchild.aggression < child.aggression)) throw new Error('champion lineage did not damp across generations');
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("champion inheritance smoke test failed")
ok("champion personality inheritance and generational damping verified")
def one_way_routing_smoke() -> None:
geometry_source = read_text(ROOT / "js/geometry_helpers.js")
source = read_text(ROOT / "js/world_environment.js")
render = read_text(ROOT / "js/render.js")
js = f"""
const vm = require('vm');
class World {{}}
const context = {{ console, World, globalThis: null, window: null, CONFIG: {{}},
TarinaiMechanicalShapeSystem: {{ isMechanicalType() {{ return false; }}, reach() {{ return 0; }} }},
TarinaiMechanicalSystem: {{}}, TarinaiPhysicsBodySystem: {{}},
}};
context.globalThis = context; context.window = context;
vm.createContext(context);
vm.runInContext({geometry_source!r}, context, {{ filename: 'geometry_helpers.js' }});
vm.runInContext({source!r}, context, {{ filename: 'world_environment.js' }});
const world = new context.World();
world.isFenceType = type => ['fence_h', 'one_way_fence'].includes(type);
const oneWay = {{ type: 'one_way_fence', item: {{ type: 'one_way_fence' }}, oneWay: true, oneWayNx: 1, oneWayNy: 0, oriented: false, left: -4, right: 4, top: -20, bottom: 20 }};
world.nearbySolidObstacleRects = () => [oneWay];
if (world.pathBlockedByFence(-30, 0, 30, 0, 0)) throw new Error('one-way fence blocked arrow direction');
if (!world.pathBlockedByFence(30, 0, -30, 0, 0)) throw new Error('one-way fence allowed reverse direction');
if (world.pointBlockedByObstacle(0, 0, 0, {{ directionalOneWay: true }})) throw new Error('one-way fence permanently blocked grid point');
if (!world.pointBlockedByObstacle(0, 0, 0)) throw new Error('one-way fence lost physical point occupancy');
const normal = {{ type: 'fence_h', item: {{ type: 'fence_h' }}, oriented: false, left: -4, right: 4, top: -20, bottom: 20 }};
world.nearbySolidObstacleRects = () => [normal];
if (!world.pathBlockedByFence(-30, 0, 30, 0, 0)) throw new Error('ordinary fence stopped blocking routes');
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("one-way fence routing smoke test failed")
if 'type === "one_way_fence"' not in render or "arrowLength" not in render:
fail("one-way fence placement direction overlay is missing")
ok("one-way fence preview and directional routing verified")
def check_save_stream_deadlock_guard() -> None:
codec = read_text(ROOT / "js/save_codec.js")
ui = read_text(ROOT / "js/save_system.js")
read_task = codec.find("const readTask = (async () =>")
write_call = codec.find("await sink.write(")
if read_task < 0 or write_call < 0 or read_task > write_call:
fail("save compression output must be drained before awaiting writes")
if 'exportBtn.dataset.busy = "1"' not in ui or 'save text generation failed' not in ui:
fail("save export UI must recover from pending/error states")
ok("save compression stream backpressure guard present")
def main() -> None: def main() -> None:
data = manifest() data = manifest()
check_files_exist(data) check_files_exist(data)
@ -1643,6 +1869,8 @@ def main() -> None:
need_action_runtime_bridge_smoke() need_action_runtime_bridge_smoke()
mechanical_collision_and_editor_smoke() mechanical_collision_and_editor_smoke()
physics_tuning_smoke() physics_tuning_smoke()
champion_inheritance_smoke()
one_way_routing_smoke()
colony_limits_and_defaults_smoke() colony_limits_and_defaults_smoke()
check_basic_action_spec_runtime() check_basic_action_spec_runtime()
check_social_action_spec_runtime() check_social_action_spec_runtime()
@ -1651,6 +1879,7 @@ def main() -> None:
check_social_balance_guards() check_social_balance_guards()
check_item_bucket_polish() check_item_bucket_polish()
check_current_save_format() check_current_save_format()
check_save_stream_deadlock_guard()
check_phase_and_input_split() check_phase_and_input_split()
check_system_order_boundary() check_system_order_boundary()
simulation_boundary_smoke() simulation_boundary_smoke()
@ -1662,6 +1891,7 @@ def main() -> None:
command_dispatcher_smoke() command_dispatcher_smoke()
check_legacy_behavior_refs() check_legacy_behavior_refs()
check_legacy_physics_props_removed() check_legacy_physics_props_removed()
check_redundant_correction_paths_removed()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,11 +1,11 @@
"use strict"; "use strict";
const APP_VERSION = "39.15.167"; const APP_VERSION = "39.16.18";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`; const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
const coreScriptNames = [ const coreScriptNames = [
"version", "event_bus", "domain_ids", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "assets", "audio", "perf_profiler", "display_helpers", "render", "sim_core", "tarinai_seed_factory", "structures", "item_type_catalog", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" "version", "event_bus", "domain_ids", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "signal_system", "assets", "audio", "perf_profiler", "display_helpers", "render", "sim_core", "tarinai_seed_factory", "structures", "item_type_catalog", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
]; ];
const CORE_ASSETS = [ const CORE_ASSETS = [
"./", "./",