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.
- 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.
- 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`.
@ -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.
- UI/text-side meaning consolidation, including labels, wrapping/measurement helpers, and duplicated scalar helpers such as `num`, `finiteOr`, `isObject`, and `clonePlain`.
- Further `World.prototype` reduction is possible, but only for helpers proven not to be runtime-facing canvas/UI/tool APIs.

View file

@ -1,156 +1,29 @@
tarinai_physics_tuning_v167
- Replaced the 8-sided circle template with a 24-sided, 20px-grid-aligned contour.
- Raised bounce-fence restitution from 1.08 to 2.40 and increased minimum/capped launch speeds.
- Made rope endpoint and particle constraints inextensible while retaining slack/flexibility.
- Set sticky-bomb blastScale to 0.68, matching the small firecracker.
tarinai_compact_save_stage4
- Save schema 33 omits standard item amounts and stores only an optional non-standard block base plus per-item differences.
- Relationship targets are sorted and delta-encoded within each Tarinai row.
- World seeds use a canonical 64-bit hexadecimal identity and occupy exactly 8 bytes in the binary save.
- The binary header contains one schema byte; its high bit signals whether a string table follows. Empty string tables and the duplicate snapshot version are omitted.
- Deflate state is folded into the low bit of the frame-length varuint, eliminating the separate compression byte.
- 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map.
- Previous binary schemas are intentionally unsupported.
- Version: 39.16.18
- CompressionStream and DecompressionStream are drained concurrently to avoid browser backpressure stalls.
tarinai_mechanical_collision_grid_limit_audit_v1
- 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
- Load confirmation uses the in-game dialog; fan wind is realtime; zunchi tool preview opacity, carried plushie depth, and blanket grass rules were corrected.
tarinai_champion_limits_mechanical_defaults_v1
- Tarinai champions actively prioritize other champions as breeding partners, with stronger mate drive and shorter champion-pair cooldowns.
- Reciprocators now default to 90 degrees.
- 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
- Passive spring: removed time-driven oscillation; force now depends on extension/compression and relative endpoint velocity.
- Signal effects use the exported TarinaiEffect constructor.
- Pressure-switch placement/editor overlays display the configured detection radius.
tarinai_plushie_fling_speed_v1
- Reduced plushie poke-fling velocity to one third.
- Lowered plushie fling minimum-speed normalization to the same one-third scale.
- 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.
tarinai_sticky_bomb_plushie_stats_v1
- 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.
- Earlier mechanism pass: stronger passive spring, persistent detector ranges, robot-cleaner signal power, and electric shock effect.
tarinai_sticky_bomb_plushie_v1
- 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.
- v39.16.17: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper.
tarinai_grab_rain_death_reason_v1
- 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.
- 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.
tarinai_ground_edgegrass_duplicate_scan_v1
- Unified ground wording for default ground / ground change log / ground change toast to 地面.
- 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.
- v39.16.16: population chart includes per-bucket births/deaths; rotators accept signed motor speed; champion personality bonuses inherit through descendants; champion-pair mate weighting is moderately reduced.
- v39.16.16: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction.
tarinai_tool_fold_fan_area_delete_v1
- 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
- v39.16.16: one-way fences allow Tarinai forward crossing in swept collision; chart legend dots toggle series; wire tools attach to Tarinai/ants; energized living endpoints panic and take damage; selected data reports electrocution.

View file

@ -1,6 +1,6 @@
{
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
"version": "39.15.167",
"version": "39.16.18",
"entrypoints": {
"app": "index.html",
"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
## v39.16.16 link, bounce-fence, and spring fixes
- ヒモ、棒、バネ、電線、絶縁電線は、接続線が画面内を横切る場合、中点の空間セルが画面外でも描画される。
- バウンス柵はたりないを外力インパルスで確実に吹き飛ばし、柵自身が作る急激な速度変化では衝撃ダメージを与えない。
- バネは受動式のまま剛性を高め、減衰と強制位置補正を下げ、より大きく長く往復する。
## v39.16.13 detector and fence changes
- 感圧スイッチを「検知器」へ改名し、道具UI上で通常の柵と位置を入れ替えた。
- 検知器は軸平行の矩形範囲で対象を数える。専用UIから横幅・縦幅・対象・必要数を設定する。
- 柵の配置境界判定を実際の細長い形状に合わせ、フィールド上下端近くへ配置できるようにした。
- バウンス柵の反発係数、最低射出速度、最大速度を引き上げ、衝突直後に強く跳ねるようにした。
- ワープゲートを道具、描画、更新、信号、保存形式から削除した。
- 一方通行柵を追加した。矢印方向には通過でき、逆方向のみ衝突する。
- バイナリスキーマ36、スナップショット30。旧形式との読み込み互換は持たない。
## v39.16.11 mechanism fixes and passive spring
- 検知器は矩形範囲内の「たりない」または「アイテム」を数え、設定個数以上で電線へ信号を送る。
- 一方通行柵は矢印方向だけ通過でき、逆方向のたりない・アイテム・機構を遮る。
- バウンス柵は衝突対象へ強い即時反発を与える。
- バネは棒と同じ2点接続方式で設置する。自動伸縮はせず、外力による伸び縮みに対して受動的に反発し、弾性運動する。
- 電線と絶縁電線はアイテム同士を接続し、複数本を経由して信号を伝える。通常電線は通電中に接触したたりないへダメージを与え、絶縁電線は与えない。
- バイナリスキーマ34、スナップショット28。v39.16.10と保存形式は同じ。
## v39.16.9 load UI and item behavior fixes
- Replaced browser-native load confirmations with an in-game confirmation window.
- Kept fans in the realtime scheduler lane so wind applies continuously instead of as periodic gusts.
- Made the zunchi tool preview use the fresh, fully opaque stage.
- Rendered carried plushies at the same depth position as their owner.
- Disabled grass spawning, growth, placement capacity, and existing grass on the fluffy blanket ground.
## v39.16.8 compact amount, relationship, seed, and framing save
- アイテム種類の標準amountは保存せず、標準値と異なる共通値または個別差分だけを保存する。共通値候補は実バイト数で選択する。
- 個体関係の対象インデックスを昇順に並べ、前の対象との差分として保存する。
- worldSeedを固定16進64bitへ統一し、バイナリ上では8バイト固定で保存する。
- バイナリ先頭はスキーマ1バイトへ統合し、上位bitで文字列テーブルの有無を示す。空の文字列テーブルと重複したスナップショット版番号は保存しない。
- 圧縮フラグをフレーム長の下位bitへ統合し、独立した1バイトを削除する。
- 15bit文字化の32768文字テーブルと逆引きMapを廃止し、Unicodeコードポイントの算術変換へ変更する。
- バイナリスキーマ33、スナップショット27。旧形式との読み込み互換は持たない。
## v39.16.7 column-oriented creature save and packed item flags
- 個体の固定情報を項目別の列指向ビットプレーンへ変更し、全個体で0の列は保存しない。
- 睡眠状態、遺伝差分、戦績差分の存在は各ID・差分マスクから直接判定し、重複していた3bit/個体を削除する。
- アイテムの共通追加状態、amount差分、追加状態差分のフラグを全ブロック共通の連続ビット列へ格納する。
- 共通追加状態は候補ごとに専用バイナリを試算し、全件個別保存より実際に小さい場合だけ採用する。
- バイナリスキーマ32、スナップショット26。旧形式との読み込み互換は持たない。
## v39.16.4 compact identity and normal-item binary save
- 個体のbirthSeed文字列を保存せず、出生番号と32bitハッシュへ分解する。`worldSeed + birthSerial`で再現できる個体はハッシュも省略する。
- 新規出生のbirthSeedは親文字列を含めず、親子関係と遺伝情報は既存の専用フィールドで保持する。
- 通常アイテムの追加情報を型別専用バイナリへ移行し、JSONテキストを保存しない。未知の追加配列も数値・文字列の型付きバイナリで保存する。
- 同じ種類のアイテムはamountの最頻値をブロックに1回だけ保存し、同一追加状態が複数ある場合も共有する。個別アイテムには差分だけを保存する。
- バイナリスキーマ30、スナップショット24。旧形式との読み込み互換は持たない。
## v39.16.3 physics binary save and reciprocator cargo contacts
- 回転体・毒ブロック・往復体・ロープ・ロッドの追加情報を、JSON文字列ではなく専用バイナリで保存する。型名、キー名、デフォルト値、重複座標、標準輪郭、インデックス参照済み接続先の文字列情報を省略する。
- バイナリスキーマは29。v39.16.2のスキーマ28は読み込み互換を維持する。
- 往復体はボール、石、画鋲などの可動物理アイテムを押し出すが、それらとの接触では方向、レール位相、モーター速度を変更しない。
## v39.16.2 reciprocator collision and placement alignment
- 往復体は、進行方向にある機構、静的障害物、物理アイテムへ衝突した時点で方向転換する。接触分離によって重なりが解消されても反転判定を失わない。
- 静的障害物との判定から往復体を除外しない。高速移動時は障害物付近でサブステップ判定を増やし、すり抜けを抑える。
- 往復体の設置前オーバーレイと実配置は同じ角度初期化処理を使用し、物理ボディー角度とレール軸を同じ順序で確定する。
- セーブ形式は変更しない。
## v39.16.1 debug save-size breakdown
- `?debug=1` でセーブまたは呪文出力を実行すると、デバッグオーバーレイにバイナリ内訳、文字数、エンコード時間が表示される。
- 内訳は形式情報、文字列テーブル、ワールド、個体基本情報、個体シード、個体関係、アイテム索引、通常アイテム、物理アイテムに分けて実占有バイトを集計する。
- 通常アイテム・物理アイテムの追加情報について、バイナリ内の占有量とJSON UTF-8元サイズを併記する。ロード後は直近のデコード時間も表示する。
This document describes the playable features of `tarinai_`: what the player sees, what tools do, and how tarinai behave. It is intentionally player/design-facing, not an implementation map.
## v39.15.167 mechanical interaction and limit audit
## v39.16.0 mechanical interaction and limit audit
- 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。
- 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。

View file

@ -13,7 +13,8 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
## Runtime Shape
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.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.
- 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`.
@ -51,3 +52,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- Syntax: `node --check js/*.js`
- Regression: `python scripts/regression_check.py`
- 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/base.css",
"css/layout.css",
@ -36,6 +36,7 @@
"js/constraint_system.js",
"js/physics_world_system.js",
"js/physics_projection_system.js",
"js/signal_system.js",
"js/assets.js",
"js/audio.js",
"js/perf_profiler.js",

View file

@ -619,6 +619,32 @@ h1 {
background: rgba(255,255,255,0.84);
padding: 4px 6px;
}
.signal-channel-label {
font-size: 13px;
color: var(--ink);
font-weight: 700;
}
.signal-channel-picker {
display: grid;
grid-template-columns: repeat(3, minmax(44px, 1fr));
gap: 7px;
}
.signal-channel-picker button {
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(84,68,48,0.18);
background: rgba(255,255,255,0.78);
color: var(--ink);
font: 700 14px ui-rounded, sans-serif;
cursor: pointer;
}
.signal-channel-picker button:hover { background: rgba(255,248,232,0.98); }
.signal-channel-picker button.active {
background: rgba(78,179,224,0.22);
border-color: rgba(42,133,184,0.58);
box-shadow: inset 0 0 0 1px rgba(42,133,184,0.18);
}
.rotator-editor-tools {
display: flex;
flex-wrap: wrap;
@ -728,3 +754,7 @@ h1 {
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.save-confirm-dialog { z-index: 36; }
.save-confirm-panel { width: min(390px, 100%); }
.save-confirm-message { margin-bottom: 8px; color: var(--ink); font-size: 14px; }

View file

@ -790,6 +790,22 @@
top: 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.
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; }
@ -845,9 +861,11 @@
font-size: 12px;
}
.colony-limit-head small { color: var(--muted); font-size: 10px; }
.colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
.colony-limit-grid label { display: grid; gap: 4px; 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-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 8px; }
.colony-limit-row { display: grid; grid-template-columns: minmax(0, 1fr) 54px; gap: 5px 7px; align-items: center; min-width: 0; font-size: 11px; color: var(--muted); }
.colony-limit-row span { grid-column: 1 / -1; min-width: 0; }
.colony-limit-slider { width: 100%; min-width: 0; accent-color: rgba(84, 138, 72, 0.92); }
.colony-limit-number { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid rgba(100, 78, 52, 0.22); border-radius: 8px; padding: 5px 6px; background: rgba(255,255,255,0.78); color: var(--ink); font: inherit; text-align: right; }
.colony-limit-status { margin-top: 7px; color: rgba(68, 55, 41, 0.78); font-size: 10px; line-height: 1.35; }

View file

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

View file

@ -4,14 +4,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.15.167" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.15.167" />
<link rel="shortcut icon" href="favicon.ico?v=39.15.167" />
<link rel="stylesheet" href="css/base.css?v=39.15.167" />
<link rel="stylesheet" href="css/layout.css?v=39.15.167" />
<link rel="stylesheet" href="css/panel.css?v=39.15.167" />
<link rel="stylesheet" href="css/components.css?v=39.15.167" />
<link rel="stylesheet" href="css/mobile.css?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.16.18" />
<link rel="shortcut icon" href="favicon.ico?v=39.16.18" />
<link rel="stylesheet" href="css/base.css?v=39.16.18" />
<link rel="stylesheet" href="css/layout.css?v=39.16.18" />
<link rel="stylesheet" href="css/panel.css?v=39.16.18" />
<link rel="stylesheet" href="css/components.css?v=39.16.18" />
<link rel="stylesheet" href="css/mobile.css?v=39.16.18" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -108,10 +108,10 @@
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
</div>
<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">
<label><span>たりない個体数</span><input id="tarinaiPopulationLimitInput" type="number" min="0" step="1" inputmode="numeric" placeholder="上限なし"></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="tarinaiPopulationLimitSlider" class="colony-limit-slider" type="range" min="0" max="300" step="1" value="0"><input id="tarinaiPopulationLimitInput" class="colony-limit-number" type="number" min="0" max="999" step="1" inputmode="numeric" placeholder="0"></label>
<label class="colony-limit-row"><span>オブジェクト総数</span><input id="objectLimitSlider" class="colony-limit-slider" type="range" min="0" max="1200" step="1" value="0"><input id="objectLimitInput" class="colony-limit-number" type="number" min="0" max="9999" step="1" inputmode="numeric" placeholder="0"></label>
</div>
<div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div>
</div>
@ -194,7 +194,7 @@
<div id="visualSettingsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="visualSettingsDialogTitle">
<div class="field-dialog-panel visual-settings-dialog-panel">
<h2 id="visualSettingsDialogTitle">&#x8868;&#x793A;&#x8A2D;&#x5B9A;</h2>
<p class="hint">&#x91CD;&#x3044;&#x8868;&#x73FE;&#x3092;&#x624B;&#x52D5;&#x3067;&#x8ABF;&#x6574;&#x3057;&#x307E;&#x3059;&#x3002;&#x500B;&#x4F53;&#x6570;&#x3084;&#x7269;&#x306E;&#x4E0A;&#x9650;&#x306F;&#x5909;&#x3048;&#x307E;&#x305B;&#x3093;&#x3002;</p>
<p class="hint">&#x81EA;&#x52D5;&#x3067;&#x306F;FPS&#x306B;&#x5FDC;&#x3058;&#x3066;3&#x6BB5;&#x968E;&#x306E;&#x8CA0;&#x8377;&#x5236;&#x5FA1;&#x3092;&#x884C;&#x3044;&#x307E;&#x3059;&#x3002;&#x9AD8;&#x306F;&#x30B7;&#x30DF;&#x30E5;&#x30EC;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x9593;&#x5F15;&#x304D;&#x306A;&#x3057;&#x3001;&#x4F4E;&#x306F;&#x5E38;&#x6642;&#x8EFD;&#x91CF;&#x5316;&#x3067;&#x3059;&#x3002;</p>
<div class="visual-setting-row">
<div><strong>&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;</strong><span>&#x7834;&#x7247;&#x3001;&#x96E8;&#x3001;&#x5149;&#x306E;&#x6F14;&#x51FA;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x30A8;&#x30D5;&#x30A7;&#x30AF;&#x30C8;">
@ -212,6 +212,7 @@
<div class="visual-setting-row">
<div><strong>&#x63CF;&#x753B;</strong><span>&#x88C5;&#x98FE;&#x3068;&#x89E3;&#x50CF;&#x5EA6;&#x306E;&#x8A2D;&#x5B9A;</span></div>
<div class="visual-setting-options" role="group" aria-label="&#x63CF;&#x753B;">
<button type="button" data-visual-setting="details" data-visual-value="auto">&#x81EA;&#x52D5;</button>
<button type="button" data-visual-setting="details" data-visual-value="low">&#x4F4E;</button>
<button type="button" data-visual-setting="details" data-visual-value="high">&#x9AD8;</button>
</div>
@ -260,167 +261,168 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.15.167" defer></script>
<script src="js/event_bus.js?v=39.15.167" defer></script>
<script src="js/domain_ids.js?v=39.15.167" defer></script>
<script src="js/registry_base.js?v=39.15.167" defer></script>
<script src="js/disease_registry.js?v=39.15.167" defer></script>
<script src="js/sound_pack.js?v=39.15.167" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.167" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.167" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.167" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.167" defer></script>
<script src="js/item_food_definitions.js?v=39.15.167" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.167" defer></script>
<script src="js/item_registry.js?v=39.15.167" defer></script>
<script src="js/data.js?v=39.15.167" defer></script>
<script src="js/ground_types.js?v=39.15.167" defer></script>
<script src="js/input_mode_manager.js?v=39.15.167" defer></script>
<script src="js/math.js?v=39.15.167" defer></script>
<script src="js/geometry_helpers.js?v=39.15.167" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.167" defer></script>
<script src="js/physics_helpers.js?v=39.15.167" defer></script>
<script src="js/placement_preview_system.js?v=39.15.167" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.167" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.167" defer></script>
<script src="js/mechanical_system.js?v=39.15.167" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.167" defer></script>
<script src="js/constraint_system.js?v=39.15.167" defer></script>
<script src="js/physics_world_system.js?v=39.15.167" defer></script>
<script src="js/physics_projection_system.js?v=39.15.167" defer></script>
<script src="js/assets.js?v=39.15.167" defer></script>
<script src="js/audio.js?v=39.15.167" defer></script>
<script src="js/perf_profiler.js?v=39.15.167" defer></script>
<script src="js/display_helpers.js?v=39.15.167" defer></script>
<script src="js/render.js?v=39.15.167" defer></script>
<script src="js/sim_core.js?v=39.15.167" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.167" defer></script>
<script src="js/structures.js?v=39.15.167" defer></script>
<script src="js/item_type_catalog.js?v=39.15.167" defer></script>
<script src="js/items.js?v=39.15.167" defer></script>
<script src="js/item_type_initializers.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.167" defer></script>
<script src="js/item_update_policy.js?v=39.15.167" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.167" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.167" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.167" defer></script>
<script src="js/item_runtime.js?v=39.15.167" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.167" defer></script>
<script src="js/item_render_helpers.js?v=39.15.167" defer></script>
<script src="js/item_render_runtime.js?v=39.15.167" defer></script>
<script src="js/burn_motion_util.js?v=39.15.167" defer></script>
<script src="js/ants.js?v=39.15.167" defer></script>
<script src="js/health.js?v=39.15.167" defer></script>
<script src="js/tarinai.js?v=39.15.167" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.167" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.167" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.167" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.167" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.167" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.167" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.167" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.167" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.167" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.167" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.167" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.167" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.167" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.167" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.167" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.167" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.167" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.167" defer></script>
<script src="js/tarinai_render.js?v=39.15.167" defer></script>
<script src="js/world.js?v=39.15.167" defer></script>
<script src="js/world_view.js?v=39.15.167" defer></script>
<script src="js/family_graph.js?v=39.15.167" defer></script>
<script src="js/world_reset_presets.js?v=39.15.167" defer></script>
<script src="js/world_family_social.js?v=39.15.167" defer></script>
<script src="js/impact_core_system.js?v=39.15.167" defer></script>
<script src="js/impact_response_system.js?v=39.15.167" defer></script>
<script src="js/collision_response_system.js?v=39.15.167" defer></script>
<script src="js/world_combat_effects.js?v=39.15.167" defer></script>
<script src="js/world_environment.js?v=39.15.167" defer></script>
<script src="js/world_temperature_system.js?v=39.15.167" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.167" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.167" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.167" defer></script>
<script src="js/world_ants_system.js?v=39.15.167" defer></script>
<script src="js/weather_system.js?v=39.15.167" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.167" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.167" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.167" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.167" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.167" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.167" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.167" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.167" defer></script>
<script src="js/simulation_systems.js?v=39.15.167" defer></script>
<script src="js/system_order.js?v=39.15.167" defer></script>
<script src="js/simulation.js?v=39.15.167" defer></script>
<script src="js/colony_situation_system.js?v=39.15.167" defer></script>
<script src="js/world_update.js?v=39.15.167" defer></script>
<script src="js/world_tool_actions.js?v=39.15.167" defer></script>
<script src="js/world_placement_log.js?v=39.15.167" defer></script>
<script src="js/world_event_effects.js?v=39.15.167" defer></script>
<script src="js/command_dispatcher.js?v=39.15.167" defer></script>
<script src="js/text_catalog.js?v=39.15.167" defer></script>
<script src="js/ui.js?v=39.15.167" defer></script>
<script src="js/ui_log.js?v=39.15.167" defer></script>
<script src="js/ui_helpers.js?v=39.15.167" defer></script>
<script src="js/ui_selected.js?v=39.15.167" defer></script>
<script src="js/save_schema.js?v=39.15.167" defer></script>
<script src="js/snapshot_system.js?v=39.15.167" defer></script>
<script src="js/restore_coordinator.js?v=39.15.167" defer></script>
<script src="js/history_system.js?v=39.15.167" defer></script>
<script src="js/save_codec.js?v=39.15.167" defer></script>
<script src="js/save_storage.js?v=39.15.167" defer></script>
<script src="js/save_system.js?v=39.15.167" defer></script>
<script src="js/ui_tooltips.js?v=39.15.167" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.167" defer></script>
<script src="js/ui_ground.js?v=39.15.167" defer></script>
<script src="js/ui_tools.js?v=39.15.167" defer></script>
<script src="js/ui_input_shared.js?v=39.15.167" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.167" defer></script>
<script src="js/ui_input_touch.js?v=39.15.167" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.167" defer></script>
<script src="js/ui_bind.js?v=39.15.167" defer></script>
<script src="js/ui_charts.js?v=39.15.167" defer></script>
<script src="js/ui_family_data.js?v=39.15.167" defer></script>
<script src="js/ui_family_async.js?v=39.15.167" defer></script>
<script src="js/ui_family_layout.js?v=39.15.167" defer></script>
<script src="js/ui_family_paths.js?v=39.15.167" defer></script>
<script src="js/ui_family_render.js?v=39.15.167" defer></script>
<script src="js/main.js?v=39.15.167" defer></script>
<script src="js/debug_tools.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.16.18" defer></script>
<script src="js/domain_ids.js?v=39.16.18" defer></script>
<script src="js/registry_base.js?v=39.16.18" defer></script>
<script src="js/disease_registry.js?v=39.16.18" defer></script>
<script src="js/sound_pack.js?v=39.16.18" defer></script>
<script src="js/deterministic_helpers.js?v=39.16.18" defer></script>
<script src="js/item_tool_metadata.js?v=39.16.18" defer></script>
<script src="js/item_tool_definitions.js?v=39.16.18" defer></script>
<script src="js/item_visual_definitions.js?v=39.16.18" defer></script>
<script src="js/item_food_definitions.js?v=39.16.18" defer></script>
<script src="js/item_effect_definitions.js?v=39.16.18" defer></script>
<script src="js/item_registry.js?v=39.16.18" defer></script>
<script src="js/data.js?v=39.16.18" defer></script>
<script src="js/ground_types.js?v=39.16.18" defer></script>
<script src="js/input_mode_manager.js?v=39.16.18" defer></script>
<script src="js/math.js?v=39.16.18" defer></script>
<script src="js/geometry_helpers.js?v=39.16.18" defer></script>
<script src="js/collision_footprint_system.js?v=39.16.18" defer></script>
<script src="js/physics_helpers.js?v=39.16.18" defer></script>
<script src="js/placement_preview_system.js?v=39.16.18" defer></script>
<script src="js/pin_attachment_system.js?v=39.16.18" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.16.18" defer></script>
<script src="js/mechanical_system.js?v=39.16.18" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.16.18" defer></script>
<script src="js/constraint_system.js?v=39.16.18" defer></script>
<script src="js/physics_world_system.js?v=39.16.18" defer></script>
<script src="js/physics_projection_system.js?v=39.16.18" defer></script>
<script src="js/signal_system.js?v=39.16.18" defer></script>
<script src="js/assets.js?v=39.16.18" defer></script>
<script src="js/audio.js?v=39.16.18" defer></script>
<script src="js/perf_profiler.js?v=39.16.18" defer></script>
<script src="js/display_helpers.js?v=39.16.18" defer></script>
<script src="js/render.js?v=39.16.18" defer></script>
<script src="js/sim_core.js?v=39.16.18" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.16.18" defer></script>
<script src="js/structures.js?v=39.16.18" defer></script>
<script src="js/item_type_catalog.js?v=39.16.18" defer></script>
<script src="js/items.js?v=39.16.18" defer></script>
<script src="js/item_type_initializers.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_support.js?v=39.16.18" defer></script>
<script src="js/item_update_policy.js?v=39.16.18" defer></script>
<script src="js/fire_runtime_system.js?v=39.16.18" defer></script>
<script src="js/robot_cleaner_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.16.18" defer></script>
<script src="js/item_dynamic_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.16.18" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.16.18" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.16.18" defer></script>
<script src="js/item_runtime.js?v=39.16.18" defer></script>
<script src="js/structure_lifecycle.js?v=39.16.18" defer></script>
<script src="js/item_render_helpers.js?v=39.16.18" defer></script>
<script src="js/item_render_runtime.js?v=39.16.18" defer></script>
<script src="js/burn_motion_util.js?v=39.16.18" defer></script>
<script src="js/ants.js?v=39.16.18" defer></script>
<script src="js/health.js?v=39.16.18" defer></script>
<script src="js/tarinai.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_spec.js?v=39.16.18" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.16.18" defer></script>
<script src="js/tarinai_identity_social.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_state.js?v=39.16.18" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_effects.js?v=39.16.18" defer></script>
<script src="js/tarinai_needs_core.js?v=39.16.18" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.16.18" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.16.18" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.16.18" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.16.18" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.16.18" defer></script>
<script src="js/tarinai_needs_items.js?v=39.16.18" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.18" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.16.18" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.16.18" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.16.18" defer></script>
<script src="js/tarinai_runtime.js?v=39.16.18" defer></script>
<script src="js/tarinai_render.js?v=39.16.18" defer></script>
<script src="js/world.js?v=39.16.18" defer></script>
<script src="js/world_view.js?v=39.16.18" defer></script>
<script src="js/family_graph.js?v=39.16.18" defer></script>
<script src="js/world_reset_presets.js?v=39.16.18" defer></script>
<script src="js/world_family_social.js?v=39.16.18" defer></script>
<script src="js/impact_core_system.js?v=39.16.18" defer></script>
<script src="js/impact_response_system.js?v=39.16.18" defer></script>
<script src="js/collision_response_system.js?v=39.16.18" defer></script>
<script src="js/world_combat_effects.js?v=39.16.18" defer></script>
<script src="js/world_environment.js?v=39.16.18" defer></script>
<script src="js/world_temperature_system.js?v=39.16.18" defer></script>
<script src="js/world_pathfinding_system.js?v=39.16.18" defer></script>
<script src="js/world_grass_placement_system.js?v=39.16.18" defer></script>
<script src="js/world_spatial_budget.js?v=39.16.18" defer></script>
<script src="js/world_ants_system.js?v=39.16.18" defer></script>
<script src="js/weather_system.js?v=39.16.18" defer></script>
<script src="js/item_update_scheduler.js?v=39.16.18" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.16.18" defer></script>
<script src="js/tarinai_update_policy.js?v=39.16.18" defer></script>
<script src="js/simulation_environment_system.js?v=39.16.18" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.16.18" defer></script>
<script src="js/simulation_effects_system.js?v=39.16.18" defer></script>
<script src="js/simulation_creature_system.js?v=39.16.18" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.16.18" defer></script>
<script src="js/simulation_ambient_system.js?v=39.16.18" defer></script>
<script src="js/simulation_systems.js?v=39.16.18" defer></script>
<script src="js/system_order.js?v=39.16.18" defer></script>
<script src="js/simulation.js?v=39.16.18" defer></script>
<script src="js/colony_situation_system.js?v=39.16.18" defer></script>
<script src="js/world_update.js?v=39.16.18" defer></script>
<script src="js/world_tool_actions.js?v=39.16.18" defer></script>
<script src="js/world_placement_log.js?v=39.16.18" defer></script>
<script src="js/world_event_effects.js?v=39.16.18" defer></script>
<script src="js/command_dispatcher.js?v=39.16.18" defer></script>
<script src="js/text_catalog.js?v=39.16.18" defer></script>
<script src="js/ui.js?v=39.16.18" defer></script>
<script src="js/ui_log.js?v=39.16.18" defer></script>
<script src="js/ui_helpers.js?v=39.16.18" defer></script>
<script src="js/ui_selected.js?v=39.16.18" defer></script>
<script src="js/save_schema.js?v=39.16.18" defer></script>
<script src="js/snapshot_system.js?v=39.16.18" defer></script>
<script src="js/restore_coordinator.js?v=39.16.18" defer></script>
<script src="js/history_system.js?v=39.16.18" defer></script>
<script src="js/save_codec.js?v=39.16.18" defer></script>
<script src="js/save_storage.js?v=39.16.18" defer></script>
<script src="js/save_system.js?v=39.16.18" defer></script>
<script src="js/ui_tooltips.js?v=39.16.18" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.16.18" defer></script>
<script src="js/ui_ground.js?v=39.16.18" defer></script>
<script src="js/ui_tools.js?v=39.16.18" defer></script>
<script src="js/ui_input_shared.js?v=39.16.18" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.16.18" defer></script>
<script src="js/ui_input_touch.js?v=39.16.18" defer></script>
<script src="js/ui_input_mouse.js?v=39.16.18" defer></script>
<script src="js/ui_bind.js?v=39.16.18" defer></script>
<script src="js/ui_charts.js?v=39.16.18" defer></script>
<script src="js/ui_family_data.js?v=39.16.18" defer></script>
<script src="js/ui_family_async.js?v=39.16.18" defer></script>
<script src="js/ui_family_layout.js?v=39.16.18" defer></script>
<script src="js/ui_family_paths.js?v=39.16.18" defer></script>
<script src="js/ui_family_render.js?v=39.16.18" defer></script>
<script src="js/main.js?v=39.16.18" defer></script>
<script src="js/debug_tools.js?v=39.16.18" defer></script>
</body>
</html>

View file

@ -259,7 +259,20 @@ class AntActor {
if (this.dead) return;
const hpBefore = this.hp;
this.age += dt;
if (this.burning || (this.burnTimer || 0) > 0.02) {
if (Number(this.electricShockUntil || 0) > Number(this.world?.time || 0)) {
this.state = "panic";
this.targetId = "";
this.targetToken = 0;
this.targetRef = null;
this.nextTurn = Math.min(Number(this.nextTurn || 0), 0);
this.wanderAngle = Number(this.wanderAngle || 0) + rand(-2.4, 2.4);
const shockSpeed = (this.kind === "queen" ? ANT_QUEEN_SPEED : ANT_WORKER_RETURN_SPEED) * 1.75;
this.vx = Math.cos(this.wanderAngle) * shockSpeed;
this.vy = Math.sin(this.wanderAngle) * shockSpeed;
this.x = clamp(this.x + this.vx * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
this.y = clamp(this.y + this.vy * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
if (this.hp <= 0) this.dieAsCorpse(this.homeNest?.() || null);
} else if (this.burning || (this.burnTimer || 0) > 0.02) {
this.updateBurning(dt);
if (this.hp <= 0) this.dieAsCorpse(this.homeNest?.() || null);
} else if (this.kind === "queen") this.updateQueen(dt);

View file

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

View file

@ -91,8 +91,8 @@
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem.isMechanicalType(item.type) || item.type === "rope" || item.type === "rod" || isFenceItemType(item.type));
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem.isMechanicalType(item.type) || ["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type) || isFenceItemType(item.type));
if (!important.has(item.type) && !isPhysicsItem) return false;
const itemRects = placementRectsFor(worldRef, item);
const itemRadius = itemFootprintRadius(worldRef, item, itemRects);

View file

@ -26,6 +26,7 @@
const now = worldRef.time || 0;
if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map();
const balls = ballLikeItems(worldRef);
let solved = 0;
for (const a of balls) {
if (!a || a.dead || !isBallLikeType(a.type)) continue;
const ar = a.r || 18;
@ -88,6 +89,7 @@
const relNormal = rvx * nx + rvy * ny;
if (relNormal > 0 && d >= hitRadius - 0.5) continue;
worldRef.ballCollisionMemo.set(key, now);
solved += 1;
const restitution = (a.type === "balloon" || b.type === "balloon") ? 0.58 : 0.86;
const impulse = -(1 + restitution) * relNormal / 2;
if (Number.isFinite(impulse)) {
@ -109,6 +111,7 @@
worldRef.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" }));
}
}
return solved;
}

View file

@ -16,22 +16,17 @@
return bodyApi()?.ensureConstraint?.(item, worldRef || item?.world || null, { syncFromLegacy: opts.syncFromLegacy === true }) || item?.physicsConstraint || null;
}
function applyConstraintState(item) { return bodyApi()?.applyConstraintState?.(item, item?.physicsConstraint) || false; }
function normalizeConstraintState(item) { return bodyApi()?.normalizeConstraintState?.(item, item?.physicsConstraint) || item?.physicsConstraint || null; }
function commitConstraint(item) {
if (!item || (item.type !== "rope" && item.type !== "rod")) return null;
if (!item || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type)) return null;
const c = ensureConstraint(item, item.world || null, { syncFromLegacy: false });
if (!c) return null;
normalizeConstraintState(item);
return c;
}
function commitMechanicalBody(item, reason = "constraint-moved-body") {
function commitMechanicalBody(item) {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
// Constraint pulls sometimes mutate item.x/y as a scratch pose. Preserve
// that pose in physicsBody before normalizing, otherwise normalizeBody()
// can restore the old body pose and silently cancel the correction.
// Constraint pulls mutate item.x/y as a scratch pose; preserve that pose in
// the authoritative physics body before the next frame begins.
bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
return true;
}
@ -58,28 +53,12 @@
function markConstraintSpatialDirty(worldRef, item, reason = "constraint-projection", threshold = 0.35, minInterval = 0.065) {
const world = worldRef || item?.world || null;
if (!world || !item) return false;
const now = Number(world.time || 0) || 0;
const x = Number(item.x || 0) || 0;
const y = Number(item.y || 0) || 0;
const r = Number(item.r || item.radius || 0) || 0;
const lastX = Number(item._lastConstraintSpatialDirtyX);
const lastY = Number(item._lastConstraintSpatialDirtyY);
const lastR = Number(item._lastConstraintSpatialDirtyR);
const missing = !Number.isFinite(lastX) || !Number.isFinite(lastY) || !Number.isFinite(lastR);
const moved = missing || Math.hypot(x - lastX, y - lastY) > threshold || Math.abs(r - lastR) > threshold;
const due = now >= Number(item._nextConstraintSpatialDirtyAt || 0);
if (moved || due) {
item._lastConstraintSpatialDirtyX = x;
item._lastConstraintSpatialDirtyY = y;
item._lastConstraintSpatialDirtyR = r;
item._nextConstraintSpatialDirtyAt = now + Math.max(0.016, Number(minInterval || 0.065) || 0.065);
const marked = global.TarinaiPhysicsProjectionSystem.markSpatialDirty(world, item, reason, threshold, minInterval) ?? false;
if (!marked) world.markSpatialDirty?.(reason);
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.marked = (world.constraintDirtyStatsThisFrame.marked || 0) + 1;
return true;
const marked = global.TarinaiPhysicsProjectionSystem.markSpatialDirty(world, item, reason, threshold, minInterval) ?? false;
if (world.constraintDirtyStatsThisFrame) {
const key = marked ? "marked" : "coalesced";
world.constraintDirtyStatsThisFrame[key] = (world.constraintDirtyStatsThisFrame[key] || 0) + 1;
}
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.coalesced = (world.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
return false;
return marked;
}
function mechanicalPowered(item) {
@ -89,8 +68,6 @@
function commitConstraintMovedMechanical(item, reason = "constraint-projection") {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
markConstraintSpatialDirty(item.world, item, reason, 0.22, 0.045);
item.world && (item.world.drawListDirty = true);
return true;
@ -115,7 +92,21 @@
if (!endpoint || !worldRef) return null;
const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.fuzzyFallback === true;
const cached = endpoint._ref;
if (cached && !cached.dead && (cached.id === endpoint.id || endpoint.kind === "tarinai")) return cached;
if (cached && !cached.dead && (cached.id === endpoint.id || endpoint.kind === "tarinai" || endpoint.kind === "ant")) return cached;
if (endpoint.kind === "ant") {
let found = (worldRef.ants || []).find(ant => ant && !ant.dead && ant.id === endpoint.id) || null;
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
const source = worldRef.nearbyAnts?.(endpoint.x, endpoint.y, 48, true) || worldRef.ants || [];
found = source.find(ant => ant && !ant.dead && distXY(ant.x, ant.y, endpoint.x, endpoint.y) <= Math.max(16, (ant.r || 6) * 2.4)) || null;
if (found) {
endpoint.id = found.id;
endpoint.fuzzyResolve = false;
endpoint.fuzzyFallback = false;
}
}
endpoint._ref = found || null;
return found || null;
}
if (endpoint.kind === "tarinai") {
let found = null;
if (worldRef.liveTarinaiById) found = worldRef.liveTarinaiById(endpoint.id, endpoint.liveToken ?? null) || null;
@ -269,7 +260,7 @@
return localDelta > 0.001 || worldDelta > 0.001;
};
for (const item of worldRef.items || []) {
if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) continue;
if (!item || item.dead || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type)) continue;
const a = remap(ep(item, 0));
const b = remap(ep(item, 1));
if (a || b) {
@ -287,9 +278,9 @@
function endpointWorld(endpoint, worldRef, depth = 0, seen = null) {
const obj = resolveEndpointObject(endpoint, worldRef);
if (obj && (obj.type === "rope" || obj.type === "rod") && worldRef) obj.world = worldRef;
if (obj && ["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && worldRef) obj.world = worldRef;
if (!obj) return Number.isFinite(endpoint?.x) && Number.isFinite(endpoint?.y) ? { x: endpoint.x, y: endpoint.y, obj: null } : null;
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint.attachT))) {
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && Number.isFinite(Number(endpoint.attachT))) {
if (depth > 6) return { x: obj.x || 0, y: obj.y || 0, obj };
const guard = seen || new Set();
if (guard.has(obj.id)) return { x: obj.x || 0, y: obj.y || 0, obj };
@ -326,7 +317,7 @@
if (obj.type === "balloon") return 0.22;
if (obj.type === "ball") return 0.7;
if (obj.type === "zunchi") return 0.55;
if (obj.type === "rope" || obj.type === "rod") return 1.6;
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type)) return 1.6;
if (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) {
if (obj.type === "rotator") return Infinity; // anchored pivot: links may torque it, not translate it
if (obj.type === "reciprocator") return mechanicalPowered(obj) ? 18.0 : 5.8;
@ -344,7 +335,7 @@
const moveX = dx * strength;
const moveY = dy * strength;
if (Math.hypot(moveX, moveY) < 0.001) return false;
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint?.attachT))) {
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && Number.isFinite(Number(endpoint?.attachT))) {
const t = clamp(Number(endpoint.attachT || 0), 0, 1);
let changed = false;
const wA = 1 - t;
@ -418,7 +409,7 @@
const delta = clamp(torque * 0.00018, -0.11, 0.11);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0015) return false;
pset(obj, "spin", clamp(ps(obj, "spin", 0) * 0.82 + delta, -1.45, 1.45), "constraint-passive-rotator-project");
commitMechanicalBody(obj, "constraint-passive-rotator-project");
commitMechanicalBody(obj);
return true;
}
if (obj.type && obj.type.includes("fence")) return false;
@ -855,13 +846,126 @@
return changed;
}
function endpointVelocity(obj, dt = 0.016) {
if (!obj) return { x: 0, y: 0 };
if (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) {
return { x: ps(obj, "xv", Number(obj.vx || 0) || 0), y: ps(obj, "yv", Number(obj.vy || 0) || 0) };
}
const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016));
const vx = Number(obj.vx);
const vy = Number(obj.vy);
return {
x: Number.isFinite(vx) ? vx : ((Number(obj.x || 0) - Number(obj.prevX ?? obj.x ?? 0)) / step),
y: Number.isFinite(vy) ? vy : ((Number(obj.y || 0) - Number(obj.prevY ?? obj.y ?? 0)) / step),
};
}
function addSpringVelocity(obj, dvx, dvy, worldRef) {
if (!obj || obj.dead || obj._heldByPlayer || obj.playerHeld) return false;
if (!Number.isFinite(dvx) || !Number.isFinite(dvy) || Math.hypot(dvx, dvy) < 0.001) return false;
if (obj.radius && obj.name) {
// Tarinai steering and state updates damp ordinary velocity quickly. Put
// most spring recoil in the external impulse channel so the body keeps
// oscillating instead of immediately cancelling the bounce.
obj.vx = (Number(obj.vx) || 0) + dvx * 0.14;
obj.vy = (Number(obj.vy) || 0) + dvy * 0.14;
obj.impulseVx = (Number(obj.impulseVx) || 0) + dvx * 1.26;
obj.impulseVy = (Number(obj.impulseVy) || 0) + dvy * 1.26;
global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(obj, 0.016, "spring-recoil");
return true;
}
if (obj.type === "poison_block") {
pset(obj, "xv", ps(obj, "xv", 0) + dvx, "spring-impulse");
pset(obj, "yv", ps(obj, "yv", 0) + dvy, "spring-impulse");
global.TarinaiMechanicalSystem.wakeItem?.(obj, "spring-impulse");
return true;
}
if (obj.type === "reciprocator") {
const axis = global.TarinaiMechanicalSystem.axis?.(obj) || reciprocatorAxis(obj);
const along = dvx * axis.x + dvy * axis.y;
if (Math.abs(along) < 0.001) return false;
pset(obj, "slideSpeed", ps(obj, "slideSpeed", 0) + along, "spring-rail-impulse");
global.TarinaiMechanicalSystem.wakeItem?.(obj, "spring-rail-impulse");
return true;
}
if (obj.type === "rotator" || (obj.type && obj.type.includes("fence"))) return false;
obj.vx = (Number(obj.vx) || 0) + dvx;
obj.vy = (Number(obj.vy) || 0) + dvy;
global.TarinaiItemUpdateScheduler?.wakeItem?.(worldRef || obj.world || null, obj, 0.35);
return true;
}
function applyPassiveSpring(item, a, b, dt, rest, worldRef) {
if (!a?.obj || !b?.obj) return false;
const dx = b.x - a.x;
const dy = b.y - a.y;
const distance = Math.max(0.001, Math.hypot(dx, dy));
const delta = distance - rest;
const nx = dx / distance;
const ny = dy / distance;
const va = endpointVelocity(a.obj, dt);
const vb = endpointVelocity(b.obj, dt);
const relativeSpeed = (vb.x - va.x) * nx + (vb.y - va.y) * ny;
const ma = endpointMass(a.obj);
const mb = endpointMass(b.obj);
const ia = Number.isFinite(ma) ? 1 / Math.max(0.1, ma) : 0;
const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0;
const inverseSum = ia + ib;
if (inverseSum <= 0) return false;
const step = Math.max(0.008, Math.min(0.04, Number(dt || 0.016) || 0.016));
// Hooke force plus light damping. There is no time-driven target: the
// spring only reacts when external motion stretches or compresses it.
// Deliberately under-damped and stiff: it remains passive, but external
// displacement produces a large, visibly oscillating recoil.
const stiffness = 92.0;
const damping = 0.06;
const impulse = clamp((delta * stiffness + relativeSpeed * damping) * step, -250, 250);
let changed = false;
if (Math.abs(impulse) > 0.015) {
changed = addSpringVelocity(a.obj, nx * impulse * (ia / inverseSum), ny * impulse * (ia / inverseSum), worldRef) || changed;
changed = addSpringVelocity(b.obj, -nx * impulse * (ib / inverseSum), -ny * impulse * (ib / inverseSum), worldRef) || changed;
}
// A small positional correction prevents unbounded stretching while
// retaining enough inertia for visible elastic recoil.
if (Math.abs(delta) > 1.0) {
const correction = clamp(delta * 0.004, -0.95, 0.95);
changed = moveEndpointObject(a.obj, nx * correction * (ia / inverseSum), ny * correction * (ia / inverseSum), ep(item, 0), step, 0.92, 0, "spring") || changed;
changed = moveEndpointObject(b.obj, -nx * correction * (ib / inverseSum), -ny * correction * (ib / inverseSum), ep(item, 1), step, 0.92, 0, "spring") || changed;
}
return changed;
}
function updateSpringLink(item, dt, worldRef) {
const before = beginLinkUpdate(item, worldRef, "spring");
if (!before) return false;
if (before.detached) return true;
const { a, b } = before;
const rest = primeLinkState(item, a, b);
const changed = applyPassiveSpring(item, a, b, dt, rest, worldRef);
const fa = endpointWorld(ep(item, 0), worldRef) || a;
const fb = endpointWorld(ep(item, 1), worldRef) || b;
if (fa && fb) {
item.x = (fa.x + fb.x) * 0.5;
item.y = (fa.y + fb.y) * 0.5;
item.r = Math.max(18, distXY(fa.x, fa.y, fb.x, fb.y) * 0.5);
ep(item, 0).x = fa.x; ep(item, 0).y = fa.y;
ep(item, 1).x = fb.x; ep(item, 1).y = fb.y;
const residual = Math.abs(distXY(fa.x, fa.y, fb.x, fb.y) - rest);
if (changed || residual > 0.8) lset(item, "awakeUntil", Number(worldRef?.time || 0) + 0.36);
}
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "spring-constraint", 0.22, 0.035);
return changed;
}
function updateLink(item, dt, worldRef) {
if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) return false;
if (!item || item.dead || !["rope", "rod", "spring"].includes(item.type)) return false;
item.world = worldRef || item.world || null;
applyConstraintState(item);
let changed = false;
if (item.type === "rope") changed = updateFlexibleLink(item, dt, worldRef);
else if (item.type === "rod") changed = updateRigidLink(item, dt, worldRef);
else if (item.type === "spring") changed = updateSpringLink(item, dt, worldRef);
commitConstraint(item);
return changed;
}
@ -921,6 +1025,7 @@
}
}
}
if (item.type === "spring") return true;
const interval = item.type === "rope" ? 0.32 : 0.55;
if ((Number(item._nextPassiveLinkUpdateAt || 0) || 0) <= now) {
item._nextPassiveLinkUpdateAt = now + interval;
@ -937,7 +1042,7 @@
function linkScheduleScore(item, worldRef) {
const now = Number(worldRef?.time || 0) || 0;
let score = item?.type === "rod" ? 2.0 : 1.0;
let score = item?.type === "spring" ? 6.0 : (item?.type === "rod" ? 2.0 : 1.0);
if (ls(item, "awakeUntil", 0) > now) score += 4.0;
if (item?._linkLastStamp && item._linkPendingStamp && item._linkLastStamp !== item._linkPendingStamp) score += 2.5;
score += Math.min(3, Math.hypot(ls(item, "midVx", 0), ls(item, "midVy", 0)) / 40);
@ -954,6 +1059,7 @@
const links = [];
for (const item of worldRef.itemsOfType("rope") || []) if (item && !item.dead) links.push(item);
for (const item of worldRef.itemsOfType("rod") || []) if (item && !item.dead) links.push(item);
for (const item of worldRef.itemsOfType("spring") || []) if (item && !item.dead) links.push(item);
if (!links.length) {
worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 };
return 0;
@ -970,8 +1076,8 @@
let rodCorrections = 0;
const pending = [];
for (const item of ordered) {
ensureConstraint(item, worldRef, { syncFromLegacy: false });
applyConstraintState(item);
if (opts.statePrepared === true) ensureConstraint(item, worldRef, { syncFromLegacy: false });
else applyConstraintState(item);
visited += 1;
if (!linkNeedsUpdate(item, worldRef)) {
skipped += 1;
@ -1006,6 +1112,7 @@
const api = Object.freeze({
updateFlexibleLink,
updateRigidLink,
updateSpringLink,
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(", ") || "-";
}
function saveStatsLines() {
const codec = window.TarinaiSaveCodec;
const stats = codec?.getLastEncodeStats?.();
if (!stats) return [];
const load = codec?.getLastDecodeStats?.();
const sections = stats.sections || {};
const details = stats.details || {};
const rawTotal = Math.max(1, Number(stats.rawBinaryBytes || stats.binaryBytes) || 0);
const part = key => {
const bytes = Number(sections[key]) || 0;
return `${bytes}B/${Math.round(bytes / rawTotal * 100)}%`;
};
const ageSeconds = Math.max(0, Math.round((Date.now() - (Number(stats.createdAt) || Date.now())) / 1000));
const decode = load ? ` decode:${fmtMs(load.decodeMs)}ms` : "";
return [
`save stats ${stats.hashChars || 0} chars stored:${stats.binaryBytes || 0}B raw:${stats.rawBinaryBytes || stats.binaryBytes || 0}B ${stats.compressed ? "deflate" : "plain"} encode:${fmtMs(stats.encodeMs)}ms${decode} age:${ageSeconds}s`,
` save format:${part("format")} strings:${part("stringTable")}(${details.stringTableEntries || 0}) world:${part("world")}`,
` save tarinai basic:${part("tarinaiBasic")} seed:${part("tarinaiSeed")} rel:${part("tarinaiRelationships")} count:${details.tarinaiCount || 0} relRows:${details.relationshipRows || 0} parents:${details.parentLinks || 0}`,
` save items index:${part("itemIndex")} normal:${part("normalItems")}(${details.normalItemCount || 0}) physics:${part("physicsItems")}(${details.physicsItemCount || 0})`,
` save extra normal:${details.normalItemExtraEncodedBytes || 0}B(raw ${details.normalItemExtraRawUtf8Bytes || 0}B) physics:${details.physicsItemExtraEncodedBytes || 0}B(raw ${details.physicsItemExtraRawUtf8Bytes || 0}B) seedRaw:${details.birthSeedRawUtf8Bytes || 0}B`,
` save accounted:${stats.accountedBytes || 0}B unaccounted:${stats.unaccountedBytes || 0}B compressed:${stats.compressedBytes || 0}B saved:${stats.compressionSavedBytes || 0}B text:${stats.textBits || 15}bit payload:${stats.payloadChars || 0} chars`,
];
}
function perfLines(perf) {
const self = (perf.topSelf || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 10);
const incl = (perf.topInclusive || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 6);
@ -124,6 +148,9 @@
if ((diag.creatures?.smooth || 0) > 0) hints.push(`smooth motion ${diag.creatures.smooth}`);
if ((diag.scheduler?.activeRealtime || 0) > 120) hints.push(`realtime items ${diag.scheduler.activeRealtime}`);
if ((diag.scheduler?.heap || 0) > 700) hints.push(`scheduled heap ${diag.scheduler.heap}`);
if ((diag.effects?.skipped || 0) > 0) hints.push(`effect update skip ${diag.effects.skipped}`);
if ((diag.effectRender?.skipped || 0) > 0) hints.push(`effect draw skip ${diag.effectRender.skipped}`);
if ((diag.creatures?.fullBudgetSkips || 0) > 0) hints.push(`creature bg skip ${diag.creatures.fullBudgetSkips}`);
if ((diag.items?.bucketRebuildsTotal || 0) > 0 && diag.items?.bucketsDirty) hints.push("item buckets dirty");
const m = diag.scheduler?.mechanicalStats || diag.items?.mechanicalStats || diag.scheduler?.physics?.mechanical || null;
if (m?.candidates > 250 || m?.pairs > 180) hints.push(`mechanical candidates ${m.candidates || "?"} pairs ${m.pairs || m.solved || "?"}`);
@ -145,19 +172,23 @@
const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?";
const creature = diag.creatures || {};
const scheduler = diag.scheduler || {};
const effects = diag.effects || {};
const effectRender = diag.effectRender || {};
const terrain = diag.terrain || {};
const lines = [
`tarinai v${window.TARINAI_VERSION || "?"} debug profiler:${perf.enabled ? "on" : "off"}${Date.now() < copiedMessageUntil ? " copied" : " copy:button/select"}`,
`fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms visual effects:${visual.effects || "?"} shadows:${visual.shadows || "?"} details:${visual.details || perf.tier || "?"} dprScale:${perf.dprScale || 1}`,
`fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms profile:${perf.profile || "?"} visual effects:${visual.effects || "?"} shadows:${visual.shadows || "?"} details:${visual.details || perf.tier || "?"} dprScale:${perf.dprScale || 1}`,
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} items ${(w?.items || []).length} ants ${(w?.ants || []).length} effects ${(w?.effects || []).length}`,
`visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}`,
`creatures full:${creature.full ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
...saveStatsLines(),
`visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}/${effectRender.budget ?? metrics["render.effectDrawBudget"] ?? "?"} skip:${effectRender.skipped ?? metrics["render.effectDrawSkipped"] ?? "?"}`,
`creatures full:${creature.full ?? 0} bg:${creature.fullBudgetUsed ?? 0}/${creature.fullBudget ?? "?"} bgSkip:${creature.fullBudgetSkips ?? 0} offSkip:${creature.offscreenSkipped ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
`creature lanes ${topMap(creature.lanes, 6)} smoothStates ${topMap(creature.smoothStates, 4)}`,
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} partial:${diag.spatial?.partialRebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} deferred:${diag.spatial?.deferredReads ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
`spatial dirtyReasons ${topMap(diag.spatial?.dirtyReasons, 4)} rebuildReasons ${topMap(diag.spatial?.rebuildReasons, 4)}`,
`nearby obstacleRects hit:${diag.nearby?.obstacleRectHits ?? 0} miss:${diag.nearby?.obstacleRectMisses ?? 0} built:${diag.nearby?.obstacleRectBuilt ?? 0} bypass:${diag.nearby?.obstacleRectBypass ?? 0} constraintDirty mark:${diag.constraintDirty?.marked ?? 0} coal:${diag.constraintDirty?.coalesced ?? 0}`,
`scheduler heap:${scheduler.heap ?? 0} realtime:${scheduler.activeRealtime ?? scheduler.realtime ?? 0} ran:${scheduler.lastRan ?? 0} itemRt:${scheduler.realtimeRan ?? 0} itemDue:${scheduler.dueRan ?? 0} constraints:${scheduler.constraints ?? 0} postPairs:${scheduler.postConstraintPairs ?? 0} rodStretch:${fmtMs(scheduler.constraintStats?.rodStretchMax || 0)} corr:${scheduler.constraintStats?.rodCorrections ?? 0}`,
`items active:${scheduler.activeRealtime ?? 0} asleep:${scheduler.sleeping ?? scheduler.heap ?? 0} budgetPairs:${scheduler.physicsBudget?.maxPairs ?? "?"} substeps:${scheduler.physics?.mechanical?.substeps ?? scheduler.mechanicalStats?.substeps ?? "?"} fenceCCD:${Boolean((scheduler.physics?.mechanical || scheduler.mechanicalStats)?.fenceSensitive)}`,
`effects update:${effects.updated ?? 0}/${effects.budget ?? effects.total ?? 0} skipped:${effects.skipped ?? 0} compact:${effects.compacted ?? 0} dropped:${effects.dropped ?? 0} render:${effectRender.drawn ?? 0}/${effectRender.budget ?? "?"} rSkip:${effectRender.skipped ?? 0} level:${effectRender.level || visual.effects || "?"}`,
`terrain dirty raw:${terrain.raw ?? 0} global:${terrain.global ?? 0} chunk:${terrain.chunk ?? 0} coalesced:${terrain.coalesced ?? 0} reasons:${topMap(terrain.reasons, 4)}`,
`work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`,
`hints ${diagnosticHints(diag, perf)}`,

View file

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

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);
}
function oneWayFenceAllowsMotion(rect, prevX, prevY, motionX, motionY) {
if (!rect?.oneWay) return false;
const nx = num(rect.oneWayNx);
const ny = num(rect.oneWayNy, -1);
const prevSide = (num(prevX) - num(rect.cx)) * nx + (num(prevY) - num(rect.cy)) * ny;
const motion = num(motionX) * nx + num(motionY) * ny;
const sourceMargin = Math.max(1, num(rect.halfH) * 0.2);
return motion > 0.01 || (prevSide < -sourceMargin && motion >= -0.01);
}
function oneWayFenceAllows(obj, rect, motionX = null, motionY = null) {
if (!rect?.oneWay || !obj) return false;
const hinted = Number.isFinite(Number(motionX)) || Number.isFinite(Number(motionY));
const vx = hinted ? num(motionX) : num(obj.vx) + num(obj.impulseVx);
const vy = hinted ? num(motionY) : num(obj.vy) + num(obj.impulseVy);
const prevX = Number.isFinite(Number(obj.prevX)) ? Number(obj.prevX) : num(obj.x) - vx * 0.016;
const prevY = Number.isFinite(Number(obj.prevY)) ? Number(obj.prevY) : num(obj.y) - vy * 0.016;
return oneWayFenceAllowsMotion(rect, prevX, prevY, vx, vy);
}
global.TarinaiGeometry = Object.freeze({
num,
clampValue,
@ -212,5 +232,7 @@
pointSegmentDistance,
segmentAabbHit,
segmentIntersectsOrientedRect,
oneWayFenceAllowsMotion,
oneWayFenceAllows,
});
})(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 }),
dirt: Object.freeze({ id: "dirt", label: "\u571f", description: "\u8349\u304c\u3088\u304f\u80b2\u3064\u5730\u9762\u3002", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }),
concrete: Object.freeze({ id: "concrete", label: "\u30b3\u30f3\u30af\u30ea\u30fc\u30c8", description: "\u8349\u304c\u751f\u3048\u306a\u3044", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
blanket: Object.freeze({ id: "blanket", label: "\u3075\u308f\u3075\u308f\u6bdb\u5e03", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u306b\u304f\u3044", stressMultiplier: 0.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
blanket: Object.freeze({ id: "blanket", label: "\u3075\u308f\u3075\u308f\u6bdb\u5e03", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u306b\u304f\u3044\u3002\u8349\u306f\u751f\u3048\u306a\u3044", stressMultiplier: 0.5, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
foot_massage: Object.freeze({ id: "foot_massage", label: "\u8db3\u3064\u307c", description: "\u9752\u3044\u307e\u3060\u3089\u5730\u9762\u3002\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u3084\u3059\u3044", stressMultiplier: 1.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
ice: Object.freeze({ id: "ice", label: "\u6c37", description: "\u3064\u308b\u3064\u308b\u6ed1\u308b\u3002\u8349\u306f\u751f\u3048\u306b\u304f\u3044", stressMultiplier: 1.1, grassMultiplier: 0.15, grassGrowthMultiplier: 0.35 }),
laboratory: Object.freeze({ id: "laboratory", label: "\u5b9f\u9a13\u5ba4", description: "\u5e38\u306b\u6674\u308c\u3002\u8349\u306f\u751f\u3048\u305a\u3001\u305a\u3093\u3061\u306f\u3059\u3050\u6d88\u3048\u308b\u3002", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0, fixedWeather: "sunny", zunchiStageMultiplier: 8.0, zunchiDecayMultiplier: 520.0 }),

View file

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

View file

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

View file

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

View file

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

View file

@ -1018,9 +1018,10 @@ draw(ctx, t, lighting = null) {
ctx.textBaseline = "middle";
ctx.strokeText(timerText, 0, this.r * 0.02);
ctx.fillText(timerText, 0, this.r * 0.02);
} else if (this.type === "fence_v" || this.type === "fence_h" || this.type === "glass_wall" || this.type === "bounce_fence" || this.type === "bounce_fence_v" || this.type === "gate_fence") {
} else if (this.type === "fence_v" || this.type === "fence_h" || this.type === "glass_wall" || this.type === "bounce_fence" || this.type === "bounce_fence_v" || this.type === "gate_fence" || this.type === "one_way_fence") {
const bounce = this.type === "bounce_fence" || this.type === "bounce_fence_v";
const gate = this.type === "gate_fence";
const oneWay = this.type === "one_way_fence";
const glassWall = this.type === "glass_wall";
const angle = itemAngleFor(this);
const len = Math.max(112, this.r * 3.55);
@ -1028,9 +1029,9 @@ draw(ctx, t, lighting = null) {
const open = gate && this.gateOpen;
ctx.save();
ctx.rotate(angle);
const wood = bounce ? (styleNight ? "#4e6f9f" : "#5aa2e0") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"));
const edge = bounce ? (styleNight ? "#243755" : "#286093") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25");
const hi = bounce ? (styleNight ? "rgba(190,230,255,0.24)" : "rgba(235,252,255,0.54)") : gate ? (styleNight ? "rgba(214,240,210,0.18)" : "rgba(220,255,210,0.34)") : (styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)");
const wood = bounce ? (styleNight ? "#4e6f9f" : "#5aa2e0") : oneWay ? (styleNight ? "#6b5a86" : "#9b78c8") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"));
const edge = bounce ? (styleNight ? "#243755" : "#286093") : oneWay ? (styleNight ? "#352943" : "#593a80") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25");
const hi = bounce ? (styleNight ? "rgba(190,230,255,0.24)" : "rgba(235,252,255,0.54)") : oneWay ? (styleNight ? "rgba(232,220,255,0.25)" : "rgba(255,245,255,0.55)") : gate ? (styleNight ? "rgba(214,240,210,0.18)" : "rgba(220,255,210,0.34)") : (styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)");
const drawRail = (x0, y0, w, h, alpha = 1) => {
ctx.save();
ctx.globalAlpha *= alpha;
@ -1118,12 +1119,32 @@ draw(ctx, t, lighting = null) {
ctx.fill();
}
}
if (oneWay) {
ctx.fillStyle = styleNight ? "rgba(246,235,255,0.92)" : "rgba(255,255,255,0.94)";
for (const xx of [-0.28, 0, 0.28]) {
ctx.beginPath();
ctx.moveTo(len * xx, -thick * 0.72);
ctx.lineTo(len * xx - thick * 0.34, -thick * 0.12);
ctx.lineTo(len * xx + thick * 0.34, -thick * 0.12);
ctx.closePath();
ctx.fill();
}
}
}
ctx.restore();
} else if (this.type === "pressure_switch") {
const active = Boolean(this.signalActive);
ctx.save(); ctx.globalAlpha *= active ? 1 : 0.78;
ctx.fillStyle = active ? (styleNight ? "#ffcf5a" : "#ffd74c") : (styleNight ? "#505866" : "#c7ced8");
ctx.strokeStyle = active ? "#9b6512" : "#69717a"; ctx.lineWidth=3;
ctx.beginPath(); ctx.roundRect?.(-27,-18,54,36,8); if (!ctx.roundRect) { ctx.rect(-27,-18,54,36); } ctx.fill(); ctx.stroke();
ctx.fillStyle = active ? "#fff6c9" : "#7b838d"; ctx.beginPath(); ctx.arc(0,-3,9,0,Math.PI*2); ctx.fill();
ctx.fillStyle = styleNight ? "#eef2f6" : "#26323e"; ctx.font="bold 10px ui-rounded, sans-serif"; ctx.textAlign="center"; ctx.fillText(`${this.pressureTarget === "item" ? "物" : "た"}${this.pressureThreshold || 1}`,0,14);
ctx.restore();
} else if (this.type === "rotator") {
const angle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem;
const powered = pb?.scalar?.(this, "motorOn", true) !== false;
const powered = window.TarinaiMechanicalSystem?.isPowered?.(this) ?? (pb?.scalar?.(this, "motorOn", true) !== false);
const speed = Number((powered ? pb?.scalar?.(this, "motorSpeed", 0) : pb?.scalar?.(this, "spin", 0)) || 0) || 0;
const segments = (pb?.segments?.(this) || [[-78, 0, 78, 0], [0, -52, 0, 52]]);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
@ -1171,7 +1192,7 @@ draw(ctx, t, lighting = null) {
const bodyAngle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem;
const axisAngle = window.TarinaiMechanicalSystem.railAxisAngle(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle;
const powered = pb?.scalar?.(this, "railOn", true) !== false;
const powered = window.TarinaiMechanicalSystem?.isPowered?.(this) ?? (pb?.scalar?.(this, "railOn", true) !== false);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
const tier = mechanicalRenderTier();
const travel = Math.max(24, Number(pb?.scalar?.(this, "railTravel", 150) || 150) || 150);
@ -1238,7 +1259,7 @@ draw(ctx, t, lighting = null) {
} else {
ctx.fillText("\u25cb", 0, 0);
}
} else if (this.type === "rope" || this.type === "rod") {
} else if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(this.type)) {
const runtime = window.TarinaiLinkRuntime;
const pb = window.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(this, 0), world);
@ -1249,7 +1270,16 @@ draw(ctx, t, lighting = null) {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (this.type === "rod") {
if (this.type === "wire" || this.type === "insulated_wire") {
const active = Boolean(this.signalActive);
ctx.strokeStyle = this.type === "insulated_wire" ? (active ? "#ffd447" : "#506e92") : (active ? "#fff06a" : "#6f6254");
ctx.lineWidth = this.type === "insulated_wire" ? 5.2 : 3.2;
ctx.setLineDash(active ? [9,5] : []); ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); ctx.setLineDash([]);
} else if (this.type === "spring") {
const dx=bx-ax, dy=by-ay, len=Math.max(1,Math.hypot(dx,dy)), nx=-dy/len, ny=dx/len;
ctx.strokeStyle=styleNight?"#d6d9df":"#6e737b"; ctx.lineWidth=4; ctx.beginPath(); ctx.moveTo(ax,ay);
const coils=16; for(let i=1;i<coils;i++){ const t=i/coils; const amp=(i%2?1:-1)*11; ctx.lineTo(ax+dx*t+nx*amp,ay+dy*t+ny*amp); } ctx.lineTo(bx,by); ctx.stroke();
} else if (this.type === "rod") {
ctx.strokeStyle = styleNight ? "rgba(176,166,150,0.96)" : "rgba(92,82,68,0.96)";
ctx.lineWidth = 7.2;
ctx.beginPath();
@ -1275,15 +1305,15 @@ draw(ctx, t, lighting = null) {
drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by);
ctx.stroke();
}
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)");
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (this.type === "spring" ? "rgba(105,112,124,0.96)" : (this.type === "wire" || this.type === "insulated_wire" ? "rgba(70,80,92,0.96)" : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)")));
for (const [kx, ky] of [[ax, ay], [bx, by]]) {
ctx.beginPath(); ctx.arc(kx, ky, this.type === "rod" ? 4.8 : 4.2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(kx, ky, (this.type === "rod" || this.type === "spring") ? 4.8 : 4.2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
} else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "zunchi";
const img = images.get(zunchiId) || images.get("zunchi");
const img = getRenderableImage(zunchiId, "zunchi");
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
ctx.globalAlpha *= stageAlpha;
if (img) {

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" },
rope: { id: "rope", label: "\u30d2\u30e2", placeable: false, scalable: false, iconText: "\u2570", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3075\u306b\u3083\u3075\u306b\u3083\u3057\u305f\u30d2\u30e2\u3067\u7e4b\u3050\u3002" },
rod: { id: "rod", label: "\u68d2", placeable: false, scalable: false, iconText: "\u2501", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001\u66f2\u304c\u3089\u306a\u3044\u68d2\u3067\u63a5\u7d9a\u3059\u308b\u3002" },
spring: { id: "spring", label: "バネ", placeable: false, scalable: false, iconText: "≋", tooltip: "2つの対象を順にクリックして、伸縮するバネでつなぐ。" },
wire: { id: "wire", label: "電線", placeable: false, scalable: false, iconText: "⌁", tooltip: "検知器の信号を電気で伝える。触ると感電。" },
insulated_wire: { id: "insulated_wire", label: "絶縁電線", placeable: false, scalable: false, iconText: "⌁", tooltip: "検知器の信号を電気で伝える。感電しない。" },
shoot: { id: "shoot", label: "\u5c04\u6483", placeable: false, scalable: false, icon: "assets/ui/tool_shoot.png", tooltip: "\u9283\u3067\u653b\u6483\u3059\u308b\u3002" },
robot_cleaner: { id: "robot_cleaner", itemType: "robot_cleaner", label: "\u30ed\u30dc\u6383\u9664\u6a5f", placeable: true, scalable: false, radius: 22, amount: 999, iconText: "\u25c9", tooltip: "\u30b4\u30df\u3084\u6c5a\u308c\u3092\u7247\u4ed8\u3051\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
@ -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_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u5f37\u70c8\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
one_way_fence: { id: "one_way_fence", itemType: "one_way_fence", label: "一方通行柵", placeable: true, scalable: true, radius: 42, amount: 999, iconText: "⇧", collisionShape: "oriented_rect", tooltip: "矢印方向には通過でき、逆方向からは通れない柵。Q/Eで45度、Shift+Q/Eで5度ずつ向きを変える。" },
rotator: { id: "rotator", itemType: "rotator", label: "\u56de\u8ee2\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21bb", collisionShape: "custom_rotator", tooltip: "\u56de\u8ee2\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 44, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" },
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
pressure_switch: { id: "pressure_switch", itemType: "pressure_switch", label: "検知器", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "▣", collisionShape: "circle", tooltip: "表示された矩形範囲内にいるたりない、またはアイテム数を検知して電線へ信号を送る。クリックで対象・必要数・範囲を設定。" },
water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" },
trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },

View file

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

View file

@ -2,8 +2,7 @@
// Layer: data/item-type-catalog
// 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
// within the same app build.
// The item-type table remains build-defined. Schema 36 includes the current mechanical and signal tool ordering.
(function (global) {
const DomainIds = global.TarinaiDomainIds;
if (!DomainIds) throw new Error("TarinaiDomainIds must be loaded before item_type_catalog.js");

View file

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

View file

@ -11,49 +11,88 @@
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod",
"stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch",
]);
function mobilePerfHint() {
return Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
function itemSpeed(item) {
if (!item) return 0;
return Math.hypot(item.vx || 0, item.vy || 0, item.impulseVx || 0, item.impulseVy || 0);
}
function itemRealtimeActive(item) {
if (!item || item.dead) return false;
if (!Number.isFinite(item._schedulerLastAt)) return true;
const now = item.world?.time || 0;
if (item.playerHeld || item._heldByPlayer || item.dragging || item.dropTimer > 0) return true;
if (item.burning || (item.burnTimer || 0) > 0.02) return true;
if (itemSpeed(item) > 0.08) return true;
const activeUntil = Math.max(
Number(item._activeUntil || -Infinity) || -Infinity,
Number(item.awakeUntil || -Infinity) || -Infinity,
Number(item.eatProtectedUntil || -Infinity) || -Infinity
);
if (activeUntil > now) return true;
const lastActiveAt = Math.max(
Number(item.lastKickedAt || -999) || -999,
Number(item.lastShotAt || -999) || -999
);
if (now - lastActiveAt < 0.35) return true;
// Fan wind is a continuous force. Scheduling it as an idle item turns the
// accumulated dt into periodic gusts, so fans must remain in the realtime lane.
if (item.type === "fan") return true;
if (item.type === "firecracker" || item.type === "flame_firecracker" || item.type === "sticky_bomb") return Boolean(item.fuseTimer > 0 || item.armed || item.active);
if (item.type === "robot_cleaner") return Boolean(item.active || item.cleanerActive || item.targetId || item.targetRef || item.state === "clean" || item.state === "return");
if (PIN_ITEM_TYPES.has(item.type)) return Boolean(item.pinState && item.pinState !== "idle" && item.pinState !== "ground");
if (item.type === "plushie") return Boolean(item.carriedById || item.onHead);
return false;
}
function idleRealtimeInterval(item) {
if (!item) return Infinity;
if (item.type === "ball" || item.type === "balloon") return 0.24;
if (item.type === "fan") return 0.60;
if (item.type === "genkotsu") return 0.45;
if (item.type === "firecracker" || item.type === "flame_firecracker" || item.type === "sticky_bomb") return 0.35;
if (item.type === "robot_cleaner") return 0.75;
if (PIN_ITEM_TYPES.has(item.type)) return 0.80;
if (item.isStructure && item.type === "plushie") return 0.95;
return 0.80;
}
function adjustedInterval(baseInterval) {
if (!Number.isFinite(baseInterval) || baseInterval <= 0) return baseInterval;
const profile = global.TarinaiPerf?.performanceProfile?.();
if (profile?.simulationThrottled === false) return 0;
return baseInterval * Math.max(1, Number(profile?.itemCadenceScale || 1));
}
function itemUpdateInterval(item) {
if (!item || item.dead) return Infinity;
const mobile = mobilePerfHint();
// Mechanical bodies and links are owned by the central physics step.
// Keeping them out of the per-item scheduler prevents N independent
// collision passes and makes future physics-format changes cheaper.
if (item.type === "rotator" || item.type === "reciprocator" || item.type === "poison_block" || item.type === "rope" || item.type === "rod") return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return 0;
if (PIN_ITEM_TYPES.has(item.type)) return 0;
if (item.isStructure && item.type === "plushie") return 0;
if (item.isStructure && item.type === "grass_bed") return 3.0;
if (item.type === "fire") {
const fireCount = item.world?.itemsOfType?.("fire")?.length || 0;
if (fireCount > 64) return mobile ? 0.55 : 0.35;
if (fireCount > 36) return mobile ? 0.42 : 0.25;
return mobile ? 0.30 : 0.18;
}
if (item.type === "nest_box" && global.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(item)) return mobile ? 0.30 : 0.18;
if (item.type === "rotator" || item.type === "reciprocator" || item.type === "poison_block" || item.type === "rope" || item.type === "rod" || item.type === "spring" || item.type === "wire" || item.type === "insulated_wire" || item.type === "pressure_switch") return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (PIN_ITEM_TYPES.has(item.type)) return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (item.isStructure && item.type === "plushie") return itemRealtimeActive(item) ? 0 : adjustedInterval(idleRealtimeInterval(item));
if (item.isStructure && item.type === "grass_bed") return adjustedInterval(3.0);
if (item.type === "fire") return adjustedInterval(0.18);
if (item.type === "nest_box" && global.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(item)) return adjustedInterval(0.18);
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi" && (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") {
const groundId = item.world?.groundType || (typeof world !== "undefined" ? world?.groundType : "") || "soil";
const decayMul = Number(global.TarinaiGround?.zunchiDecayMultiplier?.(groundId) || 1) || 1;
// High-decay grounds need a shorter update cadence; otherwise the amount
// changes only once every few seconds and the laboratory effect looks idle.
if (decayMul >= 100) return mobile ? 0.18 : 0.12;
if (decayMul > 1.01) return mobile ? 0.42 : 0.28;
return 2.4;
if (decayMul >= 100) return adjustedInterval(0.12);
if (decayMul > 1.01) return adjustedInterval(0.28);
return adjustedInterval(2.4);
}
if (item.type === "grass") return Infinity;
if (item.type === "duplicator") return mobile ? 0.8 : 0.5;
if (item.type === "water") return mobile ? 2.4 : 1.8;
if (item.type === "trace" || item.type === "splat") return mobile ? 1.8 : 1.2;
if (item.type === "ant_corpse") return 3.5;
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return 5.0;
if (isServingFoodType(item.type)) return 5.0;
if (item.type === "duplicator") return adjustedInterval(0.5);
if (item.type === "water") return adjustedInterval(1.8);
if (item.type === "trace" || item.type === "splat") return adjustedInterval(1.2);
if (item.type === "ant_corpse") return adjustedInterval(3.5);
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return adjustedInterval(5.0);
if (isServingFoodType(item.type)) return adjustedInterval(5.0);
return Infinity;
}
@ -93,6 +132,7 @@
global.TarinaiItemUpdatePolicy = Object.freeze({
itemUpdateInterval,
itemRealtimeActive,
forScheduledItems,
ensureBuckets,
candidateItems,

View file

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

View file

@ -546,6 +546,9 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
pinState: "loose",
spin: type === "pushpin" || type === "oshibyo" ? -0.55 : 0,
zunchiVariant: "zunchi",
stage: type === "zunchi" ? "fresh" : undefined,
freshness: type === "zunchi" ? 1 : undefined,
fertility: type === "zunchi" ? 0 : undefined,
antCount: typeof ANT_NEST_START_COUNT !== "undefined" ? ANT_NEST_START_COUNT : 6,
fuseTimer: 5,
fuseMax: 5,

View file

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

View file

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

View file

@ -17,15 +17,109 @@
let frameOpen = null;
let frameCount = 0;
let lastSmoothAt = 0;
const storageKey = "tarinai_visual_settings_v1";
const storageKey = "tarinai_visual_settings_v2";
const legacyStorageKey = "tarinai_visual_settings_v1";
const allowed = {
effects: new Set(["off", "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 resizeRequested = false;
let autoProfileId = "full";
let fpsAccumSeconds = 0;
let fpsAccumFrames = 0;
let smoothedFps = 0;
let pendingProfileId = "";
let pendingProfileSamples = 0;
function bucketFor(label) {
let b = buckets.get(label);
@ -43,7 +137,6 @@
return () => {
const top = stack.pop();
const active = top === frame ? frame : top || frame;
// If calls ended out of order, keep the profiler alive rather than throwing.
if (top !== frame) {
const idx = stack.lastIndexOf(frame);
if (idx >= 0) stack.splice(idx, 1);
@ -86,8 +179,8 @@
if (raw === "on" || raw === "low" || raw === "mid" || raw === "high") return "high";
}
if (key === "details") {
if (raw === "low") return "low";
if (raw === "mid" || raw === "high") return "high";
if (raw === "auto" || raw === "low" || raw === "high") return raw;
if (raw === "mid") return "auto";
}
return defaults[key];
}
@ -104,8 +197,15 @@
function loadVisualSettings() {
try {
const raw = global.localStorage?.getItem(storageKey);
return sanitizeVisualSettings(raw ? JSON.parse(raw) : null);
const current = global.localStorage?.getItem(storageKey);
if (current) return sanitizeVisualSettings(JSON.parse(current));
const legacy = global.localStorage?.getItem(legacyStorageKey);
if (!legacy) return { ...defaults };
const migrated = sanitizeVisualSettings(JSON.parse(legacy));
// Old builds had no automatic mode. Preserve explicit effect/shadow choices,
// but use the new automatic drawing mode as the migration default.
migrated.details = "auto";
return migrated;
} catch (_) {
return { ...defaults };
}
@ -117,9 +217,63 @@
} catch (_) {}
}
function dprScaleForDetails(details) {
if (details === "low") return 0.72;
return 1;
function activeProfile() {
if (visualSettings.details === "high") return PROFILE_FULL;
if (visualSettings.details === "low") return PROFILE_AGGRESSIVE;
return profiles[autoProfileId] || PROFILE_BALANCED;
}
function chooseAutoProfile(fps) {
if (!Number.isFinite(fps) || fps <= 0) return autoProfileId;
if (autoProfileId === "full") return fps < 51 ? "balanced" : "full";
if (autoProfileId === "aggressive") return fps >= 45 ? "balanced" : "aggressive";
if (fps >= 57) return "full";
if (fps < 38) return "aggressive";
return "balanced";
}
function applyAutoProfile(nextId) {
if (!profiles[nextId] || nextId === autoProfileId) return false;
const before = activeProfile();
autoProfileId = nextId;
const after = activeProfile();
if (before.dprScale !== after.dprScale || before.renderTier !== after.renderTier) resizeRequested = true;
if (after.id === "aggressive") global.TarinaiAssetsMemory?.trim?.("aggressive");
try {
global.dispatchEvent?.(new CustomEvent("tarinai-performance-profile", { detail: { profile: after.id, fps: smoothedFps } }));
} catch (_) {}
return true;
}
function observeFrameDelta(rawDt) {
const dt = Number(rawDt);
if (!Number.isFinite(dt) || dt <= 0 || dt > 0.25 || global.document?.hidden) return;
fpsAccumSeconds += dt;
fpsAccumFrames += 1;
if (fpsAccumSeconds < 0.75) return;
const sampledFps = fpsAccumFrames / Math.max(0.001, fpsAccumSeconds);
smoothedFps = smoothedFps > 0 ? smoothedFps * 0.65 + sampledFps * 0.35 : sampledFps;
global.__tarinaiFps = Math.round(smoothedFps);
fpsAccumSeconds = 0;
fpsAccumFrames = 0;
if (visualSettings.details !== "auto") return;
const desired = chooseAutoProfile(sampledFps);
if (desired === autoProfileId) {
pendingProfileId = "";
pendingProfileSamples = 0;
return;
}
if (pendingProfileId !== desired) {
pendingProfileId = desired;
pendingProfileSamples = 1;
return;
}
pendingProfileSamples += 1;
if (pendingProfileSamples >= 2) {
applyAutoProfile(desired);
pendingProfileId = "";
pendingProfileSamples = 0;
}
}
function endFrame() {
@ -144,6 +298,12 @@
}
function visualLevel(key, fallback = "high") {
if (key === "details") return activeProfile().renderTier;
if (key === "effects") {
if (visualSettings.effects === "off") return "off";
return activeProfile().renderTier;
}
if (key === "shadows") return shadowQualityTier();
return visualSettings[key] || fallback;
}
@ -156,15 +316,23 @@
const nextValue = normalizeVisualSetting(key, value);
if (!allowed[key]?.has(nextValue)) return false;
if (visualSettings[key] === nextValue) return true;
const before = activeProfile();
visualSettings = { ...visualSettings, [key]: nextValue };
saveVisualSettings();
if (key === "details") resizeRequested = true;
const after = activeProfile();
if (key === "details" || before.dprScale !== after.dprScale || before.renderTier !== after.renderTier) resizeRequested = true;
if (after.id === "aggressive") global.TarinaiAssetsMemory?.trim?.("aggressive");
return true;
}
function renderQualityTier() { return visualSettings.details; }
function shadowQualityTier() { return visualSettings.shadows; }
function dprScaleValue() { return dprScaleForDetails(visualSettings.details); }
function renderQualityTier() { return activeProfile().renderTier; }
function shadowQualityTier() {
if (visualSettings.shadows === "off") return "off";
return activeProfile().renderTier;
}
function dprScaleValue() { return activeProfile().dprScale; }
function performanceProfile() { return activeProfile(); }
function simulationOptimizationTier() { return activeProfile().id; }
function consumeResizeRequest() {
const v = resizeRequested;
resizeRequested = false;
@ -191,9 +359,13 @@
}));
const topInclusive = entries.slice().sort((a, b) => b.avg - a.avg).slice(0, 12);
const topSelf = entries.slice().sort((a, b) => b.selfAvg - a.selfAvg).slice(0, 12);
const profile = activeProfile();
return {
enabled: debugEnabled,
tier: visualSettings.details,
tier: profile.renderTier,
profile: profile.id,
autoProfile: autoProfileId,
fps: Number(smoothedFps.toFixed(1)),
visual: visualSettingsValue(),
dprScale: dprScaleValue(),
frameCount,
@ -209,12 +381,15 @@
begin,
beginFrame,
endFrame,
observeFrameDelta,
renderQualityTier,
shadowQualityTier,
visualLevel,
visualSettings: visualSettingsValue,
setVisualSetting,
dprScale: dprScaleValue,
performanceProfile,
simulationOptimizationTier,
consumeResizeRequest,
setMetric,
snapshot,

View file

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

View file

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

View file

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

View file

@ -229,7 +229,7 @@ function drawToolTargetHoverHighlight(ctx, world) {
if (isTarinaiTarget) {
const r = Math.max(17, target.radius || 22);
strokeEllipse(target.x || 0, target.y || 0, r * 1.02, r * 0.80);
} else if (target.type === "rope" || target.type === "rod") {
} else if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(target.type)) {
const runtime = TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime;
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world);
@ -536,6 +536,37 @@ function drawPlacementPreview(ctx, world) {
ctx.stroke();
}
ctx.setLineDash([]);
if (type === "one_way_fence") {
const rr = rect || (rects && rects[0]) || null;
const angle = Number(rr?.angle || preview.item?.angle || 0) || 0;
const nx = Math.sin(angle);
const ny = -Math.cos(angle);
const arrowLength = Math.max(38, Math.min(68, Number(rr?.halfW || 48) * 0.70));
const head = Math.max(10, Math.min(17, arrowLength * 0.28));
const sx = x - nx * arrowLength * 0.48;
const sy = y - ny * arrowLength * 0.48;
const ex = x + nx * arrowLength * 0.48;
const ey = y + ny * arrowLength * 0.48;
const px = -ny;
const py = nx;
ctx.save();
ctx.globalAlpha = blocked ? 0.68 : 0.96;
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.96)" : "rgba(246,252,226,0.98)";
ctx.fillStyle = blocked ? "rgba(194,70,58,0.96)" : "rgba(246,252,226,0.98)";
ctx.lineWidth = Math.max(3.2, ctx.lineWidth * 1.55);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - nx * head + px * head * 0.62, ey - ny * head + py * head * 0.62);
ctx.lineTo(ex - nx * head - px * head * 0.62, ey - ny * head - py * head * 0.62);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = blocked ? 0.52 : 0.70;
ctx.beginPath();
ctx.moveTo(x - 8, y);
@ -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) {
const p = world?.pointer;
if (!p?.inside) return;
@ -603,16 +685,16 @@ function drawOperationToolPreview(ctx, world) {
return;
}
if ((world?.tool === "rope" || world?.tool === "rod") && world.pendingLinkEndpoint) {
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(world?.tool) && world.pendingLinkEndpoint) {
const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(world.pendingLinkEndpoint, world) || world.pendingLinkEndpoint;
ctx.save();
ctx.globalAlpha = 0.72;
ctx.lineWidth = 3.2;
ctx.lineCap = "round";
if (world?.tool === "rope") ctx.setLineDash([8, 6]);
if (world?.tool === "rope" || world?.tool === "wire" || world?.tool === "insulated_wire") ctx.setLineDash([8, 6]);
else ctx.setLineDash([]);
ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : "rgba(142,94,45,0.88)";
ctx.strokeStyle = world?.tool === "rod" ? "rgba(96,84,70,0.92)" : (world?.tool === "spring" ? "rgba(105,112,124,0.92)" : ((world?.tool === "wire" || world?.tool === "insulated_wire") ? "rgba(232,196,64,0.92)" : "rgba(142,94,45,0.88)"));
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(p.x, p.y);
@ -748,8 +830,41 @@ function isCachedTerrainItem(it) {
return false;
}
const LINK_RENDER_TYPES = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire"]);
function isLinkRenderItem(it) {
return Boolean(it && !it.dead && LINK_RENDER_TYPES.includes(it.type));
}
function linkIntersectsVisibleRect(it, worldRef, rect, margin = 18) {
if (!isLinkRenderItem(it) || !worldRef || !rect) return false;
const runtime = window.TarinaiLinkRuntime;
const pb = window.TarinaiPhysicsBodySystem;
const points = [];
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), worldRef);
if (a) points.push(a);
if (b) points.push(b);
if (it.type === "rope") {
const particles = pb?.particles?.(it);
if (Array.isArray(particles)) {
for (const point of particles) if (point && Number.isFinite(point.x) && Number.isFinite(point.y)) points.push(point);
}
}
if (points.length < 2) return false;
let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
for (const point of points) {
left = Math.min(left, Number(point.x) || 0);
right = Math.max(right, Number(point.x) || 0);
top = Math.min(top, Number(point.y) || 0);
bottom = Math.max(bottom, Number(point.y) || 0);
}
const pad = Math.max(4, Number(margin) || 18);
return right + pad >= rect.left && left - pad <= rect.right && bottom + pad >= rect.top && top - pad <= rect.bottom;
}
function isBehindSpriteLayerItem(it) {
return Boolean(it && !it.dead && (it.type === "rope" || it.type === "rod" || it.type === "bed" || it.type === "grass_bed"));
return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed"));
}
function renderLayerSortY(entity) {
@ -941,7 +1056,9 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
const seen = new Set();
const addItem = (it) => {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it) || !isEntityVisibleInRect(it, visibleRect)) return;
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it)) return;
const visible = isEntityVisibleInRect(it, visibleRect) || linkIntersectsVisibleRect(it, worldRef, visibleRect);
if (!visible) return;
seen.add(it);
if (isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
@ -973,6 +1090,13 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
for (const it of itemScratch) addItem(it);
// Link-like items can cross the viewport while their midpoint lives in an
// off-screen spatial cell. Pull them from their type buckets as a small,
// deterministic fallback so a newly connected or very long link never
// disappears merely because its cached origin is outside the query rect.
for (const type of LINK_RENDER_TYPES) {
for (const it of worldRef.itemsOfType?.(type) || []) addItem(it);
}
const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
tarinaiScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
@ -981,7 +1105,13 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
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 || []) {
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
}
@ -1420,9 +1550,7 @@ function renderVisualLevel(kind, fallback = "high") {
function visualEffectLimit(level) {
if (level === "off") return 0;
if (level === "low") return 36;
if (level === "mid") return 96;
return Infinity;
return Math.max(0, Number(TARINAI_RENDER_GLOBAL.TarinaiPerf?.performanceProfile?.().effectDrawBudget || 96));
}
function fillScreenFallback(ctx, w, h, lighting) {
@ -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");
for (const entry of renderStack.layered) {
entry.entity.draw(ctx, world.time, lighting);
drawAttachedPinsForEntity(entry.entity);
// A carried plushie shares its owner's depth position instead of being
// rendered above every entity as a global attachment layer.
for (const it of carriedPlushiesByOwner.get(entry.entity.id) || []) {
syncCarriedPlushieToOwner(it, world);
it.draw(ctx, world.time, lighting);
}
}
renderPerfEnd(endLayered);
const endAttachments = renderPerfBegin("render.draw.attachments");
for (const it of renderStack.carriedPlushies) {
syncCarriedPlushieToOwner(it, world);
it.draw(ctx, world.time, lighting);
}
for (const t of renderStack.champions || []) {
t.drawChampionCrown(ctx, world.time, lighting);
}
@ -1562,8 +1700,10 @@ function render() {
const maxEffects = visualEffectLimit(effectLevel);
const effects = world.effects || [];
const totalEffects = effects.length;
let effectScanCap = 0;
if (totalEffects > 0 && maxEffects > 0) {
const scanCap = totalEffects > 180 ? Math.min(totalEffects, Math.max(maxEffects * 8, 160)) : totalEffects;
effectScanCap = scanCap;
let cursor = totalEffects > 180 ? (Math.max(0, Math.floor(world._renderEffectCursor || 0)) % totalEffects) : 0;
for (let scanned = 0; scanned < scanCap && drawnEffects < maxEffects; scanned += 1) {
const ef = effects[cursor];
@ -1575,8 +1715,19 @@ function render() {
}
if (totalEffects > 180) world._renderEffectCursor = cursor;
}
world._effectRenderStats = {
total: totalEffects,
drawn: drawnEffects,
skipped: Math.max(0, totalEffects - drawnEffects),
budget: maxEffects,
scanCap: effectScanCap,
level: effectLevel,
};
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects);
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
renderPerfEnd(endEffects);
drawPressureSwitchRangeOverlay(ctx, world);
ctx.restore();
// Light affecting the whole visible scene, including characters and ground.

View file

@ -309,6 +309,17 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
if (!item || item.dead || !worldRef) return false;
normalizeRobotCleanerState(item);
dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0));
// A connected signal wire becomes the cleaner's power source. Unwired
// cleaners keep their original always-on behavior.
if (item._signalDriven && !item._signalOn) {
item.vx = 0;
item.vy = 0;
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
item.robotCleanerScanTimer = 0;
worldRef.drawListDirty = true;
return true;
}
item.robotCleanerCleanFlash = Math.max(0, Number(item.robotCleanerCleanFlash || 0) - dt * 2.8);
item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt);
let target = currentRobotTarget(item, worldRef);

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,15 @@
"use strict";
// 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) {
const ids = global.TarinaiDomainIds;
const itemCatalog = global.TarinaiItemTypeCatalog;
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js");
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
const SNAPSHOT_VERSION = 23;
const BINARY_SCHEMA_VERSION = 28;
const SNAPSHOT_VERSION = 30;
const BINARY_SCHEMA_VERSION = 36;
function moodValue(index, fallback = "relaxed") {
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}`;
}
function ensureLoadConfirmDialog() {
let confirmDialog = document.getElementById("saveLoadConfirmDialog");
if (confirmDialog) return confirmDialog;
confirmDialog = document.createElement("div");
confirmDialog.id = "saveLoadConfirmDialog";
confirmDialog.className = "field-dialog save-confirm-dialog hidden";
confirmDialog.setAttribute("role", "alertdialog");
confirmDialog.setAttribute("aria-modal", "true");
confirmDialog.setAttribute("aria-labelledby", "saveLoadConfirmTitle");
confirmDialog.setAttribute("aria-describedby", "saveLoadConfirmMessage");
confirmDialog.innerHTML = `
<div class="field-dialog-panel save-confirm-panel">
<h2 id="saveLoadConfirmTitle">ロード前の確認</h2>
<p id="saveLoadConfirmMessage" class="field-dialog-lead save-confirm-message"></p>
<p class="hint save-warning">現在の状態は上書きされます必要であれば先にセーブしてください</p>
<div class="field-dialog-actions">
<button class="btn" data-save-load-cancel type="button">キャンセル</button>
<button class="btn primary" data-save-load-confirm type="button">ロードする</button>
</div>
</div>`;
document.body.appendChild(confirmDialog);
return confirmDialog;
}
function requestLoadConfirmation(message) {
const confirmDialog = ensureLoadConfirmDialog();
const messageEl = confirmDialog.querySelector("#saveLoadConfirmMessage");
if (messageEl) messageEl.textContent = String(message || "保存した状態をロードします。");
confirmDialog.classList.remove("hidden");
const confirmBtn = confirmDialog.querySelector("[data-save-load-confirm]");
const cancelBtn = confirmDialog.querySelector("[data-save-load-cancel]");
queueMicrotask(() => confirmBtn?.focus?.());
return new Promise((resolve) => {
let settled = false;
const finish = (accepted) => {
if (settled) return;
settled = true;
confirmDialog.classList.add("hidden");
confirmDialog.removeEventListener("click", onClick);
document.removeEventListener("keydown", onKeyDown, true);
resolve(Boolean(accepted));
};
const onClick = (event) => {
if (event.target === confirmDialog || event.target.closest("[data-save-load-cancel]")) finish(false);
else if (event.target.closest("[data-save-load-confirm]")) finish(true);
};
const onKeyDown = (event) => {
if (event.key === "Escape") { event.preventDefault(); finish(false); }
else if (event.key === "Enter" && document.activeElement === confirmBtn) { event.preventDefault(); finish(true); }
};
confirmDialog.addEventListener("click", onClick);
document.addEventListener("keydown", onKeyDown, true);
cancelBtn?.setAttribute?.("aria-label", "ロードをキャンセル");
});
}
function ensureDialog() {
let dialog = document.getElementById("saveDialog");
if (dialog) return dialog;
@ -142,7 +199,7 @@
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`)) return;
if (!await requestLoadConfirmation(`スロット${slot}をロードします。`)) return;
try {
await loadSlot(slot);
closeDialog();
@ -165,19 +222,40 @@
}
if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) {
const exportBtn = dialog.querySelector("#saveExportBtn");
const area = dialog.querySelector("#saveHashText");
if (area) {
area.value = await encodeSnapshot(createSnapshot(global.world));
area.focus();
area.select();
if (exportBtn?.dataset.busy === "1") return;
if (exportBtn) {
exportBtn.dataset.busy = "1";
exportBtn.disabled = true;
exportBtn.textContent = "\u751f\u6210\u4e2d...";
}
global.showToast?.("\u546a\u6587\u3092\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002");
try {
const hash = await encodeSnapshot(createSnapshot(global.world));
if (!hash || typeof hash !== "string") throw new Error("save text was not generated");
if (area) {
area.value = hash;
area.focus();
area.select();
}
global.showToast?.("\u546a\u6587\u3092\u30bb\u30fc\u30d6\u3057\u307e\u3057\u305f\u3002");
} catch (error) {
console.warn("save text generation failed", error);
global.showToast?.("\u546a\u6587\u306e\u751f\u6210\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
} finally {
if (exportBtn) {
exportBtn.dataset.busy = "0";
exportBtn.disabled = false;
exportBtn.textContent = "\u546a\u6587\u3092\u30bb\u30fc\u30d6";
}
}
return;
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
if (!text.trim()) { global.showToast?.("\u30ed\u30fc\u30c9\u3059\u308b\u546a\u6587\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
if (!global.confirm?.("\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return;
if (!await requestLoadConfirmation("貼り付けた呪文をロードします。")) return;
try {
restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog();

251
js/signal_system.js Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -32,6 +32,38 @@
const n = Number(value);
return Number.isFinite(n) ? n / scale : fallback;
}
function birthHash32(seed = "") {
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
if (match) {
const parsed = parseInt(match[1], 36);
if (Number.isFinite(parsed)) return parsed >>> 0;
}
let h = 2166136261;
const value = String(seed || "");
for (let i = 0; i < value.length; i++) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function compactBirthDescriptor(t = null, fallbackSerial = 1) {
const seed = String(t?.birthSeed || t?.familyKey || t?.id || "");
const parsed = global.TarinaiSeedFactory?.serialFromBirthSeed?.(seed) || 0;
const serial = Math.max(1, Math.floor(Number(t?.birthSerial) || parsed || fallbackSerial || 1));
return [serial, birthHash32(seed)];
}
function expandBirthDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) {
const serial = Math.max(1, Math.floor(Number(descriptor?.[0]) || fallbackSerial || 1));
const expected = global.TarinaiSeedFactory?.childSeed?.(worldSeed, serial, []) || `b${serial.toString(36)}_0`;
const hash = Number.isFinite(Number(descriptor?.[1])) ? (Number(descriptor[1]) >>> 0) : birthHash32(expected);
const expectedHash = birthHash32(expected);
return {
serial,
seed: hash === expectedHash ? expected : `b${serial.toString(36)}_${hash.toString(36)}`,
};
}
function mortonKeyXY(x = 0, y = 0) {
const ix = Math.max(0, Math.min(65535, Math.round(Number(x) || 0)));
const iy = Math.max(0, Math.min(65535, Math.round(Number(y) || 0)));
@ -221,7 +253,7 @@
targetIdx = itemIndex.get(targetId) ?? itemIndex.get(t.target?.id) ?? -1;
}
return [
flagPack(t), t.birthSeed || t.familyKey || t.id || `s${idx}`, q(t.x, 1), q(t.y, 1),
flagPack(t), compactBirthDescriptor(t, idx + 1), q(t.x, 1), q(t.y, 1),
q(t.age, 10), q(t.generation, 1, 1), q(t.hunger, 1), q(t.energy, 1), q(t.circadianSleepPressure, 1),
needsToArray(t.needs || t.needRaw || {}), personalityToArray(t.currentPersonality),
parentIdx, compactRelationships(t, tarinaiIndex), timers, modes, pinIdx, nestIdx, [enumIndex(STATE_IDS, state), targetIdx],
@ -233,12 +265,13 @@
if (!TarinaiClass) throw new Error("Tarinai is not available");
const needs = arrayToNeeds(row[9]);
const age = u(row[4], 10);
const birthSeed = String(row[1] || `s${index}`);
const birthIdentity = expandBirthDescriptor(row[1], worldRef?.worldSeed || "", index + 1);
const birthSeed = birthIdentity.seed;
const profile = global.TarinaiSeedFactory.birthProfile(birthSeed) || {};
const opts = {
familyKey: birthSeed,
birthSeed,
birthSerial: global.TarinaiSeedFactory.serialFromBirthSeed(birthSeed) || 0,
birthSerial: birthIdentity.serial,
id: `st${index}`,
liveToken: 1,
currentPersonality: arrayToPersonality(row[10]),
@ -313,6 +346,7 @@
q(item.fanSwingPhase || 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.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
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)];
}
function applyItemExtra(item, extra, tarinaiList = [], itemList = []) {
if (!Array.isArray(extra)) return;
const type = item.type || "";
if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) return;
if (!Array.isArray(extra)) {
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) global.TarinaiPhysicsBodySystem.invalidateItem(item, "physics-extra-missing");
return;
}
if (item.isStructure) {
item.hp = u(extra[0], 10, item.hp || 1);
item.maxHp = u(extra[1], 10, item.maxHp || item.hp || 1);
@ -359,11 +397,7 @@
item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60);
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
} else if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) {
return;
} else if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
// Physics items only accept the normalized pf:2 payload from this schema.
// Old array payloads are intentionally unsupported.
global.TarinaiPhysicsBodySystem.invalidateItem(item, "physics-extra-missing");
return;
} else if (type === "duplicator") {
@ -400,6 +434,12 @@
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.fanBodyAngle = global.normalizedItemAngle(u(extra[6], 1000, item.angle), item.angle);
} else if (type === "pressure_switch") {
item.pressureTarget = Number(extra[0]) === 1 ? "item" : "tarinai";
item.pressureThreshold = Math.max(1, Math.min(999, Number(extra[1] || 1) | 0));
item.pressureWidth = Math.max(60, Math.min(840, Number(extra[2] || 220) | 0));
item.pressureHeight = Math.max(60, Math.min(840, Number(extra[3] || 160) | 0));
item.signalActive = false;
} else if (global.isRotatableItemType(type)) {
const fallback = global.defaultItemAngle(type);
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);

View file

@ -142,7 +142,7 @@
while (changed) {
changed = false;
for (const link of worldRef.items || []) {
if (!link || link.dead || (link.type !== "rope" && link.type !== "rod")) continue;
if (!link || link.dead || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(link.type)) continue;
const a = pb.endpoint(link, 0);
const b = pb.endpoint(link, 1);
const hit = (a && a.kind === "item" && removedIds.has(a.id)) || (b && b.kind === "item" && removedIds.has(b.id));

View file

@ -114,7 +114,7 @@ function updateNeeds(tarinai, world, dt = 0) {
const championMate = tarinai.isTarinaiChampion && (tarinai.reproductionTimer || 0) <= 8 && (world?.canAddTarinai?.(1) ?? true)
? (world?.nearestOther?.(tarinai, 780, other => other && other !== tarinai && !other.dead && other.isTarinaiChampion && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease && (other.reproductionTimer || 0) <= 10) || null)
: null;
if (championMate) { socialParts.mate += 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; }
const enemy = tarinai.strongestRelation?.("fear");
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);
if ((tarinai?.fightMochiTimer || 0) > 0.04) { fightWeight *= 3.4; mateWeight *= 0.55; }
if ((tarinai?.loveMochiTimer || 0) > 0.04) { mateWeight *= 3.15; fightWeight *= 0.58; }
if (parts.championMate) { mateWeight *= 5.0; fightWeight *= 0.35; }
if (parts.championMate) { mateWeight *= 4.0; fightWeight *= 0.35; }
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);
return { fightWeight, mateWeight, fightProbability: total > 0 ? fightWeight / total : 0.4 };
}

View file

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

View file

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

View file

@ -71,12 +71,14 @@
const id = behaviorId(tarinai);
const state = String(tarinai.state || id || "");
const count = (worldRef.tarinai || []).length;
const mobile = Boolean(global.TarinaiInputMode.snapshot().touchFirst || (global.innerWidth || 9999) <= 760);
const isVisible = visible(worldRef, tarinai, visibleRect);
const farRect = isVisible ? visibleRect : (nearVisibleRect || helpers.updateVisibleWorldRect(worldRef, 520));
const isNear = isVisible || Boolean(Geometry.pointInRect(tarinai.x || 0, tarinai.y || 0, farRect));
const motionNeed = hasMotionNeed(tarinai);
const perfProfile = global.TarinaiPerf?.performanceProfile?.();
if (perfProfile?.simulationThrottled === false) {
return { interval: 0, lane: "quality-high", smoothMotion: false, visible: isVisible, near: isNear, urgent: false };
}
const nestEntryActive = Boolean(tarinai.insideNestBoxId && (typeof tarinai.nestBoxEntryActive === "function" ? tarinai.nestBoxEntryActive() : ((worldRef.time || 0) < (tarinai._nestBoxEntrySmoothUntil || -Infinity) || ((tarinai.nestFade || 0) > 0.01 && (tarinai.nestFade || 0) < 0.985))));
if (nestEntryActive) return { interval: 0, lane: "nest-entry", smoothMotion: false, visible: true, urgent: true };
@ -95,7 +97,8 @@
// runs on visible skipped frames.
const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic/i.test(id);
if (panicUrgent) {
const interval = isVisible ? (mobile ? 0.13 : 0.08) : (isNear ? (mobile ? 0.24 : 0.18) : (mobile ? 0.46 : 0.36));
const baseInterval = isVisible ? 0.08 : (isNear ? 0.18 : 0.36);
const interval = baseInterval * Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().creatureCadenceScale || 1));
return { interval, lane: "panic", smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)), visible: isVisible, urgent: true };
}
@ -116,21 +119,17 @@
interval = isVisible ? 0.42 : (isNear ? 0.70 : 1.10);
} else if (/idle|none/.test(id) || state === "idle") {
lane = "idle";
if (isVisible) interval = count > 28 ? 0.16 : 0.12;
if (isVisible) interval = 0.12;
else interval = isNear ? 0.55 : 1.10;
} else if (isVisible) {
lane = "visible";
interval = count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05);
interval = 0.05;
} else {
lane = isNear ? "near" : "far";
interval = isNear ? 0.30 : 0.90;
}
if (Number.isFinite(interval) && !/birth|eat|sleep/.test(lane)) {
if (count > 54) interval *= 1.26;
else if (count > 38) interval *= 1.14;
}
if (mobile && Number.isFinite(interval)) interval *= count > 24 ? 1.35 : 1.18;
if (Number.isFinite(interval)) interval *= Math.max(1, Number(perfProfile?.creatureCadenceScale || 1));
const passivePhysicsNeed = hasPassivePhysicsNeed(tarinai, worldRef, isVisible);
const sleepPhysical = lane === "sleep" && passivePhysicsNeed;
const eatPhysical = lane === "eat" && passivePhysicsNeed;
@ -141,7 +140,7 @@
&& interval <= 0.75
&& !worldRef.isTarinaiHiddenInNestBox?.(tarinai)
&& ((smoothAllowed && motionNeed) || sleepPhysical || eatPhysical));
return { interval, lane, smoothMotion, visible: isVisible, urgent: false, sleepPhysical, eatPhysical, passivePhysicsNeed };
return { interval, lane, smoothMotion, visible: isVisible, near: isNear, urgent: false, sleepPhysical, eatPhysical, passivePhysicsNeed };
}
global.TarinaiCreatureUpdatePolicy = Object.freeze({

View file

@ -23,6 +23,10 @@
const delta = Math.hypot(after.vx - baseline.vx, after.vy - baseline.vy);
t._lastPhysicalVelocityX = after.vx;
t._lastPhysicalVelocityY = after.vy;
// A bounce fence is designed to launch rather than injure. Its deliberate
// velocity discontinuity must not be interpreted as generic collision
// shock damage on the following movement update.
if (now - Number(t.lastBounceFenceImpulseAt || -999) < 0.62) return false;
if (delta < SHOCK_DAMAGE_DELTA_THRESHOLD) return false;
if (now - (t.lastVelocityShockDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;
if (now - (t.lastPhysicalCollisionDamageAt || -999) < SHOCK_DAMAGE_COOLDOWN) return false;

View file

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

View file

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

View file

@ -35,6 +35,8 @@ function renderStats() {
juvenile: juvenileCount,
elder: elderCount,
dead: world.deadCount,
birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0),
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
stress: avg(t => t.stress),
temperature: Number(temp) || 0,
...needAverages,
@ -58,8 +60,13 @@ function renderStats() {
const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0);
const objectLimit = Math.max(0, Number(world.objectLimit) || 0);
const objectCount = world.activeObjectCount?.() ?? (world.items || []).filter(item => item && !item.dead).length;
if (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 (typeof window.syncColonyLimitControls === "function") {
window.syncColonyLimitControls("tarinai", tarinaiLimit);
window.syncColonyLimitControls("object", objectLimit);
} else {
if (ui.tarinaiPopulationLimitInput && document.activeElement !== ui.tarinaiPopulationLimitInput) ui.tarinaiPopulationLimitInput.value = tarinaiLimit > 0 ? String(tarinaiLimit) : "";
if (ui.objectLimitInput && document.activeElement !== ui.objectLimitInput) ui.objectLimitInput.value = objectLimit > 0 ? String(objectLimit) : "";
}
setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `たりない ${values.pop} / ${tarinaiLimit || "∞"} オブジェクト ${objectCount} / ${objectLimit || "∞"}`);
bindColonyMoodTooltip();
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
@ -75,11 +82,17 @@ function updateColonyHistory(values) {
const history = uiCache.chartHistory;
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
const maxPoints = 5 * 12;
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) {
const currentBirthEvents = Math.max(0, Number(values.birthEvents || 0) || 0);
const currentDeathEvents = Math.max(0, Number(values.deathEvents || 0) || 0);
const eventCountersRewound = (Number.isFinite(uiCache.chartBirthEventSeen) && currentBirthEvents < uiCache.chartBirthEventSeen)
|| (Number.isFinite(uiCache.chartDeathEventSeen) && currentDeathEvents < uiCache.chartDeathEventSeen);
if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart) || eventCountersRewound) {
history.length = 0;
uiCache.chartBucketStart = -Infinity;
uiCache.chartBucketEnd = -Infinity;
uiCache.chartBucketAccum = null;
uiCache.chartBirthEventSeen = null;
uiCache.chartDeathEventSeen = null;
uiCache.lastChartDraw = "";
}
const objectKeys = colonyChartObjectKeys();
@ -98,6 +111,7 @@ function updateColonyHistory(values) {
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
},
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
events: { births: 0, deaths: 0 },
});
if (!uiCache.chartBucketAccum) {
const start = Math.floor(t / interval) * interval;
@ -117,6 +131,8 @@ function updateColonyHistory(values) {
temperature: bucket.sums.temperature / bucket.count,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
births: Math.max(0, Number(bucket.events?.births || 0) || 0),
deaths: Math.max(0, Number(bucket.events?.deaths || 0) || 0),
};
history.push(row);
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
@ -129,6 +145,12 @@ function updateColonyHistory(values) {
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
}
const bucket = uiCache.chartBucketAccum;
if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = currentBirthEvents;
if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = currentDeathEvents;
bucket.events.births += Math.max(0, currentBirthEvents - uiCache.chartBirthEventSeen);
bucket.events.deaths += Math.max(0, currentDeathEvents - uiCache.chartDeathEventSeen);
uiCache.chartBirthEventSeen = currentBirthEvents;
uiCache.chartDeathEventSeen = currentDeathEvents;
bucket.count += 1;
bucket.sums.pop += values.pop || 0;
bucket.sums.sick += values.sick || 0;
@ -155,6 +177,8 @@ function chartRows(values) {
elder: 0,
stress: 0,
temperature: 0,
births: 0,
deaths: 0,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])),
}];
@ -208,8 +232,10 @@ function drawColonyChart(values) {
}
const rows = chartRows(values);
const last = rows[rows.length - 1] || values;
const series = chartSeries(mode);
const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
const allSeries = chartSeries(mode);
const hiddenSeries = uiCache.chartHiddenSeries instanceof Set ? uiCache.chartHiddenSeries : (uiCache.chartHiddenSeries = new Set());
const series = allSeries.filter(s => !hiddenSeries.has(s.key));
const seriesSnapshot = allSeries.map(s => `${s.key}:${hiddenSeries.has(s.key) ? 0 : 1}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(",");
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
const visibleDay = Math.floor(Math.max(0, Number(world.time || 0) || 0) / dayLength);
const snapshot = `${mode}:${rows.length}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
@ -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}"/>`;
}).join("");
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${dayMarkers.join("")}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">2時間ごと / 1週間</text></svg>`;
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = 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") {
@ -285,6 +314,8 @@ function chartSeries(mode = "population") {
{ key: "sick", label: "病気", color: "#d96262" },
{ key: "juvenile", label: "幼体", color: "#78a642" },
{ key: "elder", label: "老体", color: "#8a7054" },
{ key: "births", label: "誕生数", color: "#d48ac2" },
{ key: "deaths", label: "死亡数", color: "#4d4d56" },
];
}

View file

@ -154,7 +154,6 @@
if (!isBody) return false;
pb.syncPoseFromItem(target);
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);
return true;
}

View file

@ -156,7 +156,7 @@ function selectTool(tool) {
const wasCopyArmed = world.tool === "copy" && tool === "copy" && Boolean(world.copyBuffer);
const result = window.TarinaiCommands.dispatch(world, { type: "tool.select", toolId: 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;
else if (wasCopyArmed) {
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 behaviorState = currentTarinaiBehavior(t);
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(","),
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
fmt(t.energy), fmt(t.stress), fmt(t.mood),
@ -333,6 +333,7 @@ function isIdleDisplayText(value = "") {
function reasonLabel(t) {
if (!t) return "\u306a\u3057";
if (Number(t.electricShockUntil || 0) > Number(t.world?.time || world?.time || 0)) return "感電している。";
const behavior = behaviorText(t);
if (behavior) {
const normalized = window.TEXT_CATALOG?.cleanBehaviorText?.(behavior, t.state || "") || behavior;

View file

@ -1,7 +1,7 @@
"use strict";
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) {
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_LABELS = window.TarinaiItemToolMetadata?.TOOL_SIZE_LABELS || { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
const TOOL_SHORTCUTS = window.TarinaiItemToolMetadata?.TOOL_SHORTCUTS || {};
function renderToolPalette() {
if (!ui?.toolPalette || typeof toolCategories !== "function") return;
@ -65,6 +66,14 @@ function renderToolPalette() {
btn.setAttribute("aria-label", label.textContent);
btn.removeAttribute("title");
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);
if (toolId === world?.tool) btn.classList.add("selected");
body.appendChild(btn);

View file

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

View file

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

View file

@ -5,9 +5,9 @@
if (!World) throw new Error("World is not available for mixin: world_environment.js");
const Geometry = global.TarinaiGeometry;
const BOUNCE_FENCE_RESTITUTION = 2.40;
const BOUNCE_FENCE_MIN_SPEED = 520;
const BOUNCE_FENCE_MAX_SPEED = 1180;
const BOUNCE_FENCE_RESTITUTION = 4.20;
const BOUNCE_FENCE_MIN_SPEED = 820;
const BOUNCE_FENCE_MAX_SPEED = 1680;
function angleForItemLocal(it) {
return itemAngleFor(it);
@ -17,7 +17,7 @@
if (!it) return Infinity;
const type = String(it.type || "");
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 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);
@ -30,6 +30,14 @@
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 = {}) {
if (!candidates || typeof visitor !== "function") return 0;
const source = candidates;
@ -152,6 +160,35 @@
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 = {}) {
if (!worldRef?.nearbySolidObstacleRects) return null;
const midX = (x1 + x2) * 0.5;
@ -159,12 +196,27 @@
const travel = Math.hypot(x2 - x1, y2 - y1);
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 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;
for (const rect of worldRef.nearbySolidObstacleRects(midX, midY, searchRadius, { maxChecks })) {
if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) continue;
const hit = sweptCircleRectHitLocal(x1, y1, x2, y2, radius, rect, 0.5);
if (!hit) continue;
const testRect = (rect) => {
if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) return;
if (rect?.oneWay && worldRef.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) return;
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;
};
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;
}
@ -266,6 +318,7 @@
const bounce = type === "bounce_fence" || type === "bounce_fence_v";
const gate = type === "gate_fence";
const glassWall = type === "glass_wall";
const oneWay = type === "one_way_fence";
const len = Math.max(112, (it.r || 42) * 3.55);
const thick = Math.max(10, (it.r || 42) * 0.31);
const angle = angleForItemLocal(it);
@ -276,7 +329,7 @@
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,
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
};
},
@ -506,7 +559,7 @@
addRectsFor(it);
return checked >= queryLimit;
}, { include, exclude, stamp });
if (checked < queryLimit && !this.nearbyObstacles) {
if (checked < queryLimit) {
const mech = global.TarinaiMechanicalSystem;
const fallback = [];
for (const type of ["rotator", "poison_block", "reciprocator"]) {
@ -608,10 +661,16 @@
return removed;
},
oneWayFenceAllows(entity, rect, motionX = null, motionY = null) {
return Geometry.oneWayFenceAllows(entity, rect, motionX, motionY);
},
pushTarinaiOutOfRect(t, r, rr, opts = {}) {
if (!t || !r) return false;
if (this.oneWayFenceAllows?.(t, r, opts.oneWayDx, opts.oneWayDy)) return false;
const preVx = Number(t.vx || 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 insidePenetration = 0;
if (r.oriented) {
@ -621,18 +680,16 @@
dx = local.x - clx;
dy = local.y - cly;
d = Math.hypot(dx, dy);
if (d >= rr) return false;
if (d >= hitRadius) return false;
if (d < 0.001) {
const left = Math.abs(local.x + (r.halfW || 0));
const right = Math.abs((r.halfW || 0) - local.x);
const top = Math.abs(local.y + (r.halfH || 0));
const bottom = Math.abs((r.halfH || 0) - local.y);
const m = Math.min(left, right, top, bottom);
insidePenetration = m;
if (m === left) { dx = -1; dy = 0; d = 1; }
else if (m === right) { dx = 1; dy = 0; d = 1; }
else if (m === top) { dx = 0; dy = -1; d = 1; }
else { dx = 0; dy = 1; d = 1; }
const face = nearestRectFaceDirection(
Math.abs(local.x + (r.halfW || 0)),
Math.abs((r.halfW || 0) - local.x),
Math.abs(local.y + (r.halfH || 0)),
Math.abs((r.halfH || 0) - local.y)
);
insidePenetration = face.depth;
dx = face.dx; dy = face.dy; d = 1;
}
const n = Geometry.rectWorldNormal(r, dx / d, dy / d);
nx = n.x; ny = n.y;
@ -643,64 +700,82 @@
dx = t.x - cx;
dy = t.y - cy;
d = Math.hypot(dx, dy);
if (d >= rr) return false;
if (d >= hitRadius) return false;
if (d < 0.001) {
const left = Math.abs(t.x - r.left);
const right = Math.abs(r.right - t.x);
const top = Math.abs(t.y - r.top);
const bottom = Math.abs(r.bottom - t.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; d = 1; }
else if (m === right) { dx = 1; dy = 0; d = 1; }
else if (m === top) { dx = 0; dy = -1; d = 1; }
else { dx = 0; dy = 1; d = 1; }
const face = nearestRectFaceDirection(
Math.abs(t.x - r.left),
Math.abs(r.right - t.x),
Math.abs(t.y - r.top),
Math.abs(r.bottom - t.y)
);
dx = face.dx; dy = face.dy; d = 1;
}
nx = dx / d;
ny = dy / d;
}
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 push = r.oriented && insidePenetration > 0
? Math.min(insidePenetration + rr + slop, maxPush)
: Math.min(rr - d + slop, maxPush);
? Math.min(insidePenetration + hitRadius + slop, maxPush)
: Math.min(hitRadius - d + slop, maxPush);
t.x += nx * push;
t.y += ny * push;
t.lastSolidObstacleCollisionSource = r.item || null;
t.lastSolidObstacleCollisionAt = this.time || 0;
if (r.bounce) {
const restitution = Number.isFinite(r.restitution) ? r.restitution : 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;
if (currentOut < minBounceSpeed && ((t.lastBounceFenceImpulseAt || -999) + 0.16 <= now || toward < 0)) {
const add = minBounceSpeed - currentOut;
t.impulseVx = clamp((t.impulseVx || 0) + nx * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.impulseVy = clamp((t.impulseVy || 0) + ny * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.lastBounceFenceImpulseAt = now;
}
const now = this.time || 0;
const source = r.item || null;
const vx = Number(t.vx || 0) || 0;
const vy = Number(t.vy || 0) || 0;
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._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.vy = clamp(t.vy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.18);
if (t.bubble && (this.time || 0) >= (t.lastBounceFenceBubbleAt || -999) + 0.55) {
t.lastBounceFenceBubbleAt = this.time || 0;
t.impulseVx = clamp(t.impulseVx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.impulseVy = clamp(t.impulseVy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
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)");
}
if ((this.time || 0) >= (t.lastBounceFenceEffectAt || -999) + 0.08) {
t.lastBounceFenceEffectAt = this.time || 0;
if (now >= (t.lastBounceFenceEffectAt || -999) + 0.08) {
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)" }));
}
} else if (r.mechanical) {
@ -736,6 +811,9 @@
const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep)));
const stepX = moveX / 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;
for (let i = 0; i < steps; i++) {
const sx = t.x;
@ -744,7 +822,8 @@
const ey = sy + stepY;
const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, {
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) {
const safeT = Math.max(0, hit.t - 0.035);
@ -758,8 +837,11 @@
collided = this.resolveSolidObstacleCollision(t, {
maxPasses: 3,
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,
oneWayDx: stepX,
oneWayDy: stepY,
reason: opts.reason || "tarinai-swept-hit",
}) || true;
continue;
@ -769,8 +851,11 @@
const passHit = this.resolveSolidObstacleCollision(t, {
maxPasses: 2,
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,
oneWayDx: stepX,
oneWayDy: stepY,
reason: opts.reason || "tarinai-swept-move",
});
collided = passHit || collided;
@ -791,9 +876,10 @@
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 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++) {
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 (this.pushTarinaiOutOfRect(t, rect, rr, opts)) {
pushed = true;
@ -807,7 +893,7 @@
const pad = CONFIG.worldPadding + Math.max(2, (Number(t.radius) || 22) * 0.18);
t.x = clamp(t.x, pad, this.w - 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.vy = clamp(t.vy || 0, -maxV, maxV);
}
@ -836,6 +922,7 @@
const type = rect?.item?.type || rect?.type || "";
if (!type || type === "glass_wall") 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
// not limited to fences. Nest containers have real collision footprints;
// include them here so tarinai route around a non-target nest instead of
@ -846,6 +933,12 @@
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 = {}) {
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
@ -854,6 +947,7 @@
for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) {
if (exclude && rect?.item === exclude) continue;
if (!this.isFenceRoutingRect?.(rect)) continue;
if (rect?.oneWay && this.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) continue;
if (rect?.oriented) {
const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding };
if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
@ -876,6 +970,9 @@
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) }) || []) {
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;
}
return false;

View file

@ -1,6 +1,40 @@
"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 socialPersonalityProfile(tarinai) {
@ -500,6 +534,13 @@
if (!opts.genetics && seedParents.length >= 2 && typeof inheritedGenetics === "function") {
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 (!Number.isFinite(opts.adultScale) && typeof adultScaleFromGenetics === "function") opts.adultScale = adultScaleFromGenetics(opts.birthSeed || opts.familyKey || "", opts.genetics);
if (!Number.isFinite(opts.lifeSpan)) {
@ -821,6 +862,7 @@
lifeItemMode: "",
});
if (!child) return null;
this.birthEventCount = Math.max(0, Number(this.birthEventCount || 0) || 0) + 1;
if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true });
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
a.hasPaired = true;

View file

@ -29,7 +29,7 @@
const cached = actor._pathWaypoint || null;
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
&& 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 })) {
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId };
}
@ -89,7 +89,7 @@
let bestScore = Infinity;
const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null;
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 });
if (firstBlocked) continue;
const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude });
@ -120,7 +120,7 @@
const exclude = opts.exclude || opts.targetItem || target || null;
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
&& 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 })) {
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 (ix === start.ix && iy === start.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 h = (ix, iy) => Math.hypot(px(ix) - tx, py(iy) - ty);

View file

@ -412,7 +412,7 @@
if (speedNum) speedNum.value = String(deg);
if (thick) thick.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(thick, thickNum, controller.draw);
const close = () => {
@ -430,7 +430,7 @@
phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage");
} else {
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 (!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 (thick) thick.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;
editor.syncInputPair(angle, angleNum);
editor.syncInputPair(speed, speedNum);
@ -569,7 +569,7 @@
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, "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");
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");
@ -606,7 +606,7 @@
if (!it || it.dead) continue;
const type = String(it.type || "");
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 pb = physicsApi();
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) {
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) {
@ -819,6 +880,7 @@
if (item.type === "rotator" || item.type === "poison_block") return openRotatorEditor(item, worldRef);
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
if (item.type === "fan") return openFanEditor(item, worldRef);
if (item.type === "pressure_switch") return openSignalItemEditor(item, worldRef);
if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen;
worldRef?.markSpatialDirty?.("gate-toggle");
@ -876,7 +938,7 @@
function isPhysicsObstacleItemType(type = "") {
const key = String(type || "");
return Boolean(
key === "rope" || key === "rod"
key === "rope" || key === "rod" || key === "spring"
|| global.TarinaiMechanicalSystem.isMechanicalType(key)
|| (typeof isFenceItemType === "function" && isFenceItemType(key))
);
@ -902,7 +964,7 @@
function placementItemOverlapsSoftItem(worldRef, placedItem, softItem) {
if (!worldRef || !placedItem || !softItem || softItem.dead) return false;
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 pb = physicsApi();
const a = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 0), worldRef);
@ -921,7 +983,7 @@
function placementSoftSearchRadius(item) {
let search = Math.max(80, Number(item?.r || item?.radius || 36) + 90);
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) search = Math.max(search, (global.TarinaiMechanicalSystem.reach(item) || item.r || 80) + 96);
else if (item?.type === "rope" || item?.type === "rod") search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96);
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;
}
@ -1038,7 +1100,7 @@
const rect = this.fenceRect(item);
if (!rect) return false;
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;
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
if (!it || it === item || it.dead) continue;
@ -1237,7 +1299,7 @@
handleClick(x, y) {
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);
if (editable && openEditableItemEditor(editable, this)) {
global.TarinaiCommands.setSelection(this, null) || (this.selected = null);
@ -1378,10 +1440,9 @@
if (itemType) {
let px = x, py = y;
const rawItem = this.applyToolSize(new Item(itemType, px, py));
if (isRotatableItemType(itemType)) rawItem.angle = this.toolAngleFor ? this.toolAngleFor(itemType) : (defaultItemAngle(itemType));
if (itemType === "reciprocator") {
global.TarinaiPhysicsBodySystem.setScalar(rawItem, "railAxis", rawItem.angle, "raw-item-axis");
rawItem.angle = defaultItemAngle(itemType);
if (isRotatableItemType(itemType)) {
const requestedAngle = this.toolAngleFor ? this.toolAngleFor(itemType) : defaultItemAngle(itemType);
global.TarinaiPlacementPreviewSystem.applyToolOrientation(this, rawItem, requestedAngle);
}
// Placement uses the same footprint that the preview validates.
const placed = this.placeItem(rawItem, true);

View file

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

View file

@ -21,12 +21,25 @@
findDeleteToolTargetAt(x, y) {
let best = null;
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 || [];
for (let i = itemCandidates.length - 1; i >= 0; i -= 1) {
const it = itemCandidates[i];
if (!it || it.dead) continue;
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 pb = global.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
@ -74,7 +87,7 @@
if (isPinType(it.type) && it.pinState === "lodged") continue;
const hitR = Math.max(8, it.r || itemRadiusFor?.(it.type, 12) || 12);
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 pb = global.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
@ -129,6 +142,7 @@
findLinkEndpointAt(x, y) {
let best = null;
let bestD = Infinity;
const wireTool = this.tool === "wire" || this.tool === "insulated_wire";
for (let i = (this.tarinai || []).length - 1; i >= 0; i -= 1) {
const t = this.tarinai[i];
if (!t || t.dead || this.isTarinaiHiddenInNestBox?.(t)) continue;
@ -143,10 +157,11 @@
for (let i = itemCandidates.length - 1; i >= 0; i -= 1) {
const it = itemCandidates[i];
if (!it || it.dead) continue;
if ((this.tool === "rope" || this.tool === "rod") && 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 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 d = distXY(x, y, it.x, it.y);
if (isConstraint) {
@ -181,7 +196,7 @@
if (isMechanism) global.TarinaiLinkRuntime.snapEndpointToTarget(best, this);
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) {
const it = this.items[i];
if (!it || it.dead || !global.TarinaiMechanicalSystem.isMechanicalType(it.type)) continue;
@ -200,7 +215,8 @@
useLinkToolAt(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 (!this.pendingLinkEndpoint) {
this.pendingLinkEndpoint = endpoint;
@ -219,7 +235,7 @@
const runtime = global.TarinaiLinkRuntime;
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 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);
item.world = this;
const pb = global.TarinaiPhysicsBodySystem;
@ -305,7 +321,7 @@
if (!it || it.dead || it.type === "trace" || it.type === "splat") continue;
let d = distXY(x, y, it.x, it.y);
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 pb = global.TarinaiPhysicsBodySystem;
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
@ -465,7 +481,7 @@
useToolAt(x, y) {
if (this.tool === "delete") return this.deleteItemAt(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;
},
}));

View file

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

View file

@ -2,8 +2,7 @@
"""Lightweight regression guard for the buildless Tarinai app.
Checks static-file consistency, generated-load order, syntax, and the ActionSpec
registry contract. This intentionally avoids save-data migration checks; save
compatibility is intentionally not retained for save format changes.
registry contract. This covers static contracts and focused runtime smoke tests. The current codec intentionally supports only the current binary schema.
"""
from __future__ import annotations
@ -250,6 +249,26 @@ def check_legacy_physics_props_removed() -> None:
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:
source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"])
required = {
@ -459,20 +478,47 @@ def check_current_save_format() -> None:
fail("broad snapshot fields still present after light-save conversion: " + ", ".join(offenders))
required_codec = [
'EXPORT_PREFIX = "\\u305f"',
'SAVE_TEXT_BITS = 12',
'SAVE_HEADER_CHARS = "\\u3089\\u308A\\u308B"',
'SAVE_TEXT_BITS = 15',
"writeStringTable",
"SAVE_TEXT_ALPHABET",
"jp4096Encode",
"jp4096Decode",
"writeCurrentItemExtra",
"readCurrentItemExtra",
"saveTextCharFromValue",
"saveTextValueFromCode",
"base32768Encode",
"base32768Decode",
"makeSaveFrame",
"openSaveFrame",
"writeTarinaiBitPlane",
"readTarinaiBitPlane",
"TARINAI_COLUMN_LAYOUT",
"chooseCommonExtraPlan",
"standardItemAmount",
"chooseAmountPlan",
"worldSeedWords",
"saveTextCharFromValue",
"saveTextValueFromCode",
"itemFlagPlaneBytes",
"writeNormalItemExtra",
"readNormalItemExtra",
"writeBirthDescriptor",
"readBirthDescriptor",
"async function encodeSnapshot",
"async function decodeSnapshot",
]
for token in required_codec:
if token not in save_codec:
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 = [
'STORAGE_PREFIX = "tarinai_japanese_hash_slot_v1_"',
'hash.startsWith("\\u305f")',
@ -1277,10 +1323,14 @@ context.window = context;
context.TarinaiPerf = {{ begin() {{ return null; }} }};
context.TarinaiConstraintSystem = {{ updateWorld() {{ return 0; }} }};
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);
for (const path of {repr([str(ROOT / rel) for rel in [
'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 }});
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 === '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) {{
const a = makeItem(typeA, 120, 120);
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);
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:
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.stderr)
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:
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():
if token not in read_text(ROOT / rel):
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")
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"):
if token not in snapshot or token not in codec:
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');
world.setObjectLimit('');
if (world.objectLimit !== 0 || !world.canAddObjects(1000)) throw new Error('blank/zero must mean unlimited');
context.TarinaiSnapshot = {{ version: 23 }};
context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 28, FIELD_IDS: ['garden'] }};
context.TarinaiSnapshot = {{ version: 27 }};
context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 33, FIELD_IDS: ['garden'], itemTypeValue(i, fallback = '') {{ return fallback; }} }};
context.CONFIG.dayLength = 120;
context.TextEncoder = TextEncoder;
context.TextDecoder = TextDecoder;
vm.runInContext(fs.readFileSync({str(ROOT / 'js/save_codec.js')!r}, 'utf8'), context);
(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 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');
@ -1492,7 +1622,7 @@ def physics_tuning_smoke() -> None:
dynamic_tools = read_text(ROOT / "js/item_dynamic_tool_system.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:
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:
@ -1573,10 +1703,7 @@ context.TarinaiPhysicsBodySystem = {{
setParticles(item, list) {{ item.physicsConstraint.particles = list; return true; }},
ensureConstraint(item) {{ return item.physicsConstraint; }},
applyConstraintState() {{ return true; }},
normalizeConstraintState(_item, state) {{ return state; }},
syncPoseFromItem() {{ return true; }},
normalizeBodyState() {{ return true; }},
markBodyChanged() {{ return true; }},
}};
vm.runInContext(constraintSource, context, {{ filename: 'constraint_system.js' }});
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")
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:
data = manifest()
check_files_exist(data)
@ -1643,6 +1869,8 @@ def main() -> None:
need_action_runtime_bridge_smoke()
mechanical_collision_and_editor_smoke()
physics_tuning_smoke()
champion_inheritance_smoke()
one_way_routing_smoke()
colony_limits_and_defaults_smoke()
check_basic_action_spec_runtime()
check_social_action_spec_runtime()
@ -1651,6 +1879,7 @@ def main() -> None:
check_social_balance_guards()
check_item_bucket_polish()
check_current_save_format()
check_save_stream_deadlock_guard()
check_phase_and_input_split()
check_system_order_boundary()
simulation_boundary_smoke()
@ -1662,6 +1891,7 @@ def main() -> None:
command_dispatcher_smoke()
check_legacy_behavior_refs()
check_legacy_physics_props_removed()
check_redundant_correction_paths_removed()
if __name__ == "__main__":

View file

@ -1,11 +1,11 @@
"use strict";
const APP_VERSION = "39.15.167";
const APP_VERSION = "39.16.18";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
const coreScriptNames = [
"version", "event_bus", "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 = [
"./",