This commit is contained in:
33333-33333 2026-07-20 14:36:59 +09:00
commit f6d7412744
94 changed files with 3836 additions and 1226 deletions

View file

@ -6,7 +6,6 @@ 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`, generated `service-worker.js`, and `index.html` query params share `39.16.78`.
- 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`.
@ -84,7 +83,6 @@ This section records current boundaries only. Version-by-version cleanup history
## Current Feature Routing
- Achievement system: `js/achievements.js`, `achievement_api.php`, `js/save_schema.js`, `js/save_codec.js`, `js/snapshot_system.js`, and achievement audit scripts. Current save schema is `49`; current app/cache version is `39.16.78`.
- Achievement UI: `index.html` achievement dialog, `css/components.css` achievement rows/toast styles, and `js/achievements.js` DOM rendering.
- Achievement spell payload: `TarinaiAchievements.exportSpellState()`, `snapshot.g.s`, `save_system.js` spell export inclusion, and `save_codec.js` achievement metadata readers/writers.
- Logic board: `js/circuit_board_system.js`, `js/signal_system.js`, signal-related item definitions/metadata, and the generated circuit editor markup/styles.
@ -92,14 +90,23 @@ This section records current boundaries only. Version-by-version cleanup history
- Reset presets: `js/world_reset_presets.js`; UI reset selection is in `js/ui_layout_dialogs.js`; social archive reset/fallback routing touches `js/world_family_social.js`.
- Physics tool icons and previews: `js/ui_tools.js`, `js/render.js`, `js/placement_preview_system.js`, `js/physics_shape_editor_system.js`, and `js/item_tool_metadata.js`.
## Cleanup Candidate Audit - resolved
## Cleanup Candidate Audit - rechecked 2026-07-18
The previous cleanup candidate queue has been applied or adjusted. Current state:
Do not delete these yet. This is a current worktree audit of unused, duplicated, stale, or low-meaning processes that should be reviewed before any removal or refactor.
- Version/docs routing was aligned to `39.16.78`; `js/version.js` build identity now matches the app version, and `README.md` no longer points at an older cache-query script URL.
- One-version and overlapping audit scripts from the candidate list were removed; the broad audit suite remains `scripts/achievement_audit.js`, `scripts/playstyle_achievement_audit.js`, `scripts/achievement_integration_audit.py`, `scripts/achievement_server_state_audit.py`, and `scripts/regression_check.py`.
- Shared scalar/object/clone/hash/text helpers now live on `TarinaiCoreHelpers` in `js/deterministic_helpers.js`; action, behavior, structure, snapshot, save, geometry, physics, seesaw, placement-preview, circuit, and signal code route repeated helper logic through that shared surface or through `TarinaiGeometry`.
- UI HTML escaping is owned by `js/ui_helpers.js`; consumers bind explicitly to `TarinaiUIHelpers.htmlEscape`, and `index.html` loads `ui_helpers.js` before the consumers that need it.
- `js/achievements.js` uses `renderIfDialogOpen()` for progress refreshes instead of repeating the hidden-dialog check at each event recorder.
- `js/world_placement_log.js` uses shared close/backdrop binding for editor dialogs; deeper editor extraction can still be done as feature work, but the repeated close process from the cleanup list is removed.
- `FILES.json` now includes JavaScript audit scripts and newer core bootstrap files (`domain_ids`, deterministic helpers, item registries/definitions) in machine routing.
### Stale Current-State Docs And Labels
- `BUILD_NOTE.txt` header still says schema 51 / payload v6, while `js/save_schema.js`, `js/achievements.js`, and `scripts/achievement_save_v6_audit.js` indicate schema 53 / payload v8.
### Audit Script Candidates
- `scripts/achievement_save_v6_audit.js` is named for v6 but asserts current schema 53 and achievement v8. Rename or fold into `scripts/achievement_audit.js` / `scripts/regression_check.py`.
- The many single-purpose `*_regression_audit.js` scripts appear to overlap the broad `scripts/regression_check.py` and achievement audits. Review for consolidation before deleting: `achievement_audio_tooltip_regression_audit.js`, `colony_crisis_regression_audit.js`, `effect_runtime_regression_audit.js`, `history_binary_regression_audit.js`, `long_session_runtime_regression_audit.js`, `non_tarinai_performance_regression_audit.js`, `object_render_cursor_regression_audit.js`, `offscreen_motion_performance_regression_audit.js`, `pathfinding_regression_audit.js`, `performance_5to7_regression_audit.js`, `performance_architecture_regression_audit.js`, `performance_cleanup_regression_audit.js`, `population_recovery_regression_audit.js`, `render_stack_regression_audit.js`, `reproduction_memory_sprite_regression_audit.js`, `reproduction_regression_audit.js`, `self_zunchi_regression_audit.js`, `social_interaction_regression_audit.js`, `tooltip_nail_colony_focus_regression_audit.js`, and `ui_grass_limits_regression_audit.py`.
- The invariant `node --check js/*.js` is weak on this Windows/PowerShell workspace because wildcard expansion does not behave like POSIX shells. Prefer a script-backed check or explicit file enumeration.
### Duplicated Helper/Glue Processes
- `const num = global.TarinaiCoreHelpers?.finiteOr || ...` is repeated in `js/geometry_helpers.js`, `js/mechanical_system.js`, `js/physics_projection_system.js`, `js/physics_world_system.js`, `js/placement_preview_system.js`, `js/seesaw_system.js`, and `js/signal_system.js`. Consider a load-order-safe local import pattern or just use `TarinaiCoreHelpers.finiteOr` directly where safe.
- `const clamp = global.TarinaiCoreHelpers?.clampNumber || ...` is repeated in `js/circuit_board_system.js`, `js/physics_world_system.js`, `js/seesaw_system.js`, and `js/signal_system.js`.
- Dialog close/backdrop wiring is partly consolidated in `js/world_placement_log.js`, but `js/world_placement_log.js` signal editor and `js/circuit_board_system.js` still manually wire cancel/close/backdrop handlers. Candidate for a small shared modal-close helper after checking editor-specific behavior.
- `finiteOrNull` in `js/snapshot_system.js` and `finiteNumberOrNull` in `js/save_codec.js` are nearly identical null-preserving numeric helpers. They are persistence-local today, but can be centralized if the save/snapshot boundary remains clear.

View file

@ -1,282 +1 @@
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.
- Binary schema 51 is the current save format; schemas 49 and 50 remain readable. Achievement payload v6 stores 72 unlock bits and persistent statistician progress while v5 remains importable.
- Version: 39.16.109
- CompressionStream and DecompressionStream are drained concurrently to avoid browser backpressure stalls.
- Load confirmation uses the in-game dialog; fan wind is realtime; zunchi tool preview opacity, carried plushie depth, and blanket grass rules were corrected.
- Passive spring: removed time-driven oscillation; force now depends on extension/compression and relative endpoint velocity.
- Signal effects use the exported TarinaiEffect constructor.
- Pressure-switch placement/editor overlays display the configured detection radius.
- v39.16.105: removed redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping; unified one-way-fence and bounce corrections while retaining debug diagnostics and defensive recovery paths.
- Earlier mechanism pass: stronger passive spring, persistent detector ranges, robot-cleaner signal power, and electric shock effect.
- v39.16.105: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper.
- v39.16.105: link rendering no longer depends on midpoint spatial visibility; bounce fences launch Tarinai without shock damage; passive springs are more under-damped and elastic.
- v39.16.105: 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.105: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction.
- v39.16.105: 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.
- v39.16.105: added direct food/medicine giving by placement or pinched drop, with a give overlay, stronger food-gift personality change, and heart bursts; removed unused collision reasons and duplicate constraint projection dirty checks.
- v39.16.105: localized the direct-feeding personality reason, normalized electrocution deaths to the Japanese electrocution label, added detector-controlled gate fences, made chart legend labels fully clickable, and escaped non-ASCII runtime source.
- v39.16.105: hid pinch overlays for rope/rod/spring/wire connection items and added detector time schedules with a dual-thumb range control, overnight ranges, and persistent copy/save state.
- v39.16.105: shortened detector copy, removed the time-range explanatory hint, cascaded connection-item removal when any endpoint disappears, and repaired detached plushies so they return to a living owner or disappear with a missing owner.
- v39.16.105: excluded transient splat effects from all connection endpoint selection and renamed the user-facing feeding need from the formal feeding term to the appetite label.
- v39.16.105: added toilet-seeking before normal defecation; renamed rain shelter to parasol; added sunny +3.5C and unsheltered rain -3.5C felt-temperature corrections; parasols neutralize both and block sunbathing. Pet food, first aid, and sedative remain included.
- v39.16.105: replaced toilet-seeking with walkable toilet sand that immediately removes poop spawned on its surface; added a parasol ground shadow and explicit hot-sun/cold-rain temperature-seeking targets; fixed null candidate temperatures being interpreted as 0C.
- v39.16.105: aligned parasol placement overlay with its shade, made toilet sand absorb rain water, expanded Happy/Athletic/War presets, simplified Athletic rotators, and reserved their full rotation envelope from fences.
- v39.16.105: added a few bounce fences to War, reduced ordinary-fence restitution, restored ball collisions for visible sleeping Tarinai, moved nest-box tooltips closer, allowed movable-item placement overlap, and fixed delete-tool target lookup.
- v39.16.105: unified in-world tooltip spacing, added the Hair Trigger preset, and replaced physics-category line-like tool icons with distinct semantic silhouettes.
- v39.16.105: moved snap-mode switching to a dedicated operation control with Free/20px/40px/60px cycling, removed F/G from size badges, allowed valid placement against the field top edge, and standardized preset descriptions with Japanese full stops.
- v39.16.105: unified biological/environmental conditions into the detector, removed the standalone sensor and relay types with no save migration, folded temperature/limits, moved secondary controls into the header menu, preserved tool mode while browsing tarinai data, and added five-second mobile selection tips.
- v39.16.105: detector numeric sliders match the time-control presentation; colony settings are unified; display settings are inline; meter colors, snap control, range-delete icon, and ant chart defaults are revised.
- v39.16.105: restored the compact sticky operation palette, added exact-match panel search, reorganized colony settings/stats, and refreshed range-delete and mechanism icons.
- v39.16.105: moved partial panel search to the header with previous/next navigation and tooltip text indexing; localized snap mode labels; simplified bounce-fence and reciprocator icons; matched expanded colony settings to the chart background.
39.16.105: Added local achievements and optional self-hosted shared unlock percentage API.
39.16.105: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking.
39.16.105: Expanded achievements to fifteen conditions, changed rapid deaths to 50 in 10 seconds, moved conditions into game tooltips, added toast-to-button absorption, and added colony disease/happiness, cumulative hand feeding, ritual ignition, and laxative-starvation achievements.
39.16.105: Audited all achievements; fixed impossible slave generation tracking, persisted generation runs, queued simultaneous unlock toasts, hardened launched-zunchi stop detection, and preserved laxative-at-death timing.
39.16.105: Added seven play-style achievements for passive observation, fifty world placements, detector automation, rope/rod/spring coverage, a five-minute death-free 25-Tarinai colony, 25 live ants, and 30 cumulative ant kills; persisted world-scoped timers and placement progress.
39.16.105: Revised achievement naming and disclosure UI, replaced achievement tooltips with inline post-unlock descriptions and horizontal shared-rate bars, merged detector automation plus link coverage into 機械化産業, changed placement and ant-kill goals to persistent totals of 100, and made direct-feeding progress reset with the field.
39.16.105: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button.
39.16.105: Corrected the zunchi-slave fight-record threshold text to 30%; fixed wire placement totals, danger-kill attribution for 喧嘩両成敗, partial legacy link migration, strict player-ID validation, post-unlock storage writes, and debug-only unlock isolation from shared statistics.
39.16.105: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss.
39.16.105: Expanded achievements from 27 to 43 with history, cleanup, care, ecology, temperature, weather, medicine, combat, and ant-experiment conditions; persisted the required reset-scoped metadata in a new incompatible save schema; made nest-box and pipe occupants weather-protected; and added false-positive guards for time-zero markers and stale ant damage attribution.
39.16.105: Reorganized achievements into four collapsible categories, removed achievement tooltips, surfaced pre-unlock disclosures in the achievement UI, and replaced native browser confirmations with a shared in-game dialog.
39.16.105: Renamed the four achievement categories to 生態/実験/建築/その他, moved unlocked achievements to the top of their own categories, disclosed four additional conditions before unlock, raised 大いなる母 to 3333 births, and moved achievement unlock toasts to the upper-left.
39.16.105: Added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足; disclosed 悠久の歴史 before unlock; reduced achievement overhead by skipping closed-dialog list rebuilds, caching category mappings, debouncing pending sync, and retaining periodic full recovery sync.
39.16.105: Added 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one uninterrupted happy in-game day from play day 6; repaired shared つつく targeting, damage, plushie fling, and hover overlay; corrected manual rotator/reciprocator push direction; tightened tool/limit typography; reduced periodic achievement scans; and changed server aggregate writes to compact v2 JSON with legacy-state migration.
39.16.105: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards.
39.16.105: Compactly embeds unlocked achievements and persistent achievement progress in exported spell saves; spell loads merge achievements without erasing local progress and resync restored unlocks to the server.
39.16.105: Replaced JSON-string achievement metadata in spell/save binaries with a sparse binary schema; achievement unlock masks are raw bits, progress is bit-packed, timestamps use minute deltas, and old v39.16.105 spells remain readable.
39.16.105: Added persistent sniper, megalopolis, and love-mochi fertility achievements; changed chaos progress to fight-mochi-influenced fights only; extended compact spell progress without migrating obsolete generic fight counts; omitted completed achievement counters from spell metadata; and tightened population-limit labels/layout.
39.16.105: Expanded achievements from 54 to 59 with single-digit FPS, 24-hour continuous play, five toilet-sand placements, park-ground editing, and four ground changes per second; revised safety, observation, and empty-click conditions; moved the two observation achievements to the bottom of その他; and expanded binary achievement masks to eight bytes while retaining schemas 4143.
39.16.105: Fixed achievement progress typography and tab-switch observation continuity; added アリ観察 and 命のバトン; persisted sticky-bomb pass counts; aligned sauna/enemy-ant conditions; removed obsolete spell compatibility; current exports use schema 45 and achievement payload v4.
## v39.16.105
- Added 毎日たりない観察 and 抵抗器じゃない.
- Daily streak state is carried by achievement payload v5 and binary schema 46.
- Achievement aggregate JSON migrates compact v2/verbose input to compact v3 automatically under the API file lock.
## v39.16.105
- Re-audited the not-yet-deployed shared achievement server with generated randomized unlock data instead of a fixed production sample.
- Shared summaries are read-only; only POST session/unlock/sync/reset calls register or update players, and GET mutations are rejected.
- State writes now use a separate lock file plus atomic same-directory replacement, preserving the previous state across interrupted writes.
- Invalid or structurally unknown JSON returns `storage_corrupt` without overwriting the source file.
- `completionistEligible: false` now prevents `true_tarinai_observer` from being re-added by the same sync payload.
- The randomized server audit covers 32 v2/verbose migrations and 72 concurrent player syncs with exact semantic comparison.
## v39.16.105
- Added seesaw cooperative play and friendship growth.
- Added circuit-board editor with positioned input/output terminals and AND/OR/NOT gates.
- Added electrical control for fan, stove, dry ice, and duplicator.
- Added connectable-item and circuit-terminal highlights while wiring.
- Save snapshot/schema: 38/47.
## v39.16.105
- Circuit board editor now uses a grid canvas with direct gate placement and cursor-drawn wiring.
- Legacy circuit layouts are intentionally not converted.
- Colony state evaluates every two in-game hours; growing populations cannot be classified as Weakened.
- Seesaw action selection, fixed orientation, and placement preview were corrected.
## v39.16.105
- Seesaw leisure can start at a lower boredom threshold, and a waiting rider raises the second rider's action priority.
- Active paired play is shown as 「シーソーをしている。」 in selected Tarinai data.
- Seesaws render in the low/back item layer instead of covering Tarinai sprites.
- AND/OR gate input pins and gate outputs are snapped to the same 40px circuit-editor grid used by cursor wiring.
## v39.16.105
- Circuit-board internal wiring now uses straight grid segments with explicit branch junctions and unlimited fan-out per terminal.
- Removed OR and added a one-input/one-output rectifier; internal logic parts rotate in 90-degree steps.
- Circuit-board external terminal counts and positions are editable; the board footprint and default terminal spacing were expanded.
- Detector numeric conditions now use a dual-thumb minimum/maximum range plus direct numeric inputs.
- Seesaw riders render slightly above the seat surface.
- Save snapshot/schema: 39/48. Older saves are intentionally unsupported.
## v39.16.105
- The default circuit-board grid is now 10x6, one quarter of the previous editor area, with a correspondingly smaller world footprint.
- Circuit-board width and height are editable; the board world size follows the grid dimensions.
- Input/output terminal count is limited by board height, and every external terminal can be dragged independently onto the left, right, top, or bottom edge.
- Branch junctions can be moved directly; all connected straight segments follow the junction.
- NOT and rectifier bodies are visually smaller while their pins remain on grid intersections.
- Existing external wires follow terminal positions after a board resize or terminal move.
- Seesaw action reasons now use play-specific wording instead of inheriting unrelated fulfillment reasons such as bed demand.
- Save snapshot/schema: 40/49. Older saves are intentionally unsupported.
## v39.16.105
- Circuit-board external terminals are now placed directly with IN/OUT terminal tools; the +/- terminal-count controls were removed.
- Disconnected external terminals are dimmed in the circuit-board editor; connected terminals retain their signal colors and cannot be deleted until their world wire is removed.
- Placement previews no longer use the generic ellipse fallback: circle items use circles, obstacle/mechanical items use their collision footprints, and special items use dedicated shapes.
- Detector, wire, insulated wire, and circuit board moved from the Physics palette section to a new Electrical section.
- Clicking a detector dual-range track outside either thumb teleports the nearer thumb to the clicked value.
- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged.
## v39.16.105
- Circuit-board default grid is 10x10 and both dimensions can grow to 20x20; the world footprint scales proportionally in both axes.
- AND gate body height is two grid cells. Compact one-input parts can be placed one grid row from the top/bottom edges when their rotated pins remain in bounds.
- Placement previews prefer shared occupancy/collision footprints; the dedicated seesaw preview was removed and circuit boards use their rectangular board footprint.
- Internal combinational feedback loops are explicitly detected and forced OFF; a NOT self-loop is unstable and does not provide power.
- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged.
## v39.16.105
- Circuit-board editing shows placement ghosts for pending internal parts and terminals.
- Q/E rotates pending internal parts by 90 degrees before placement.
- The ON constant source is documented in the editor.
- Wire and insulated-wire palette silhouettes render correctly in the Electrical category.
- Save snapshot/schema remains 40/49.
## v39.16.105
- Removed the circuit-board OFF constant source.
- Added the achievement 「情報化産業」 for transmitting an active signal directly from one circuit board output to another circuit board input through wire or insulated wire.
- Extended the 8-byte achievement mask to use its final bit, supporting all 64 current achievements.
- Save snapshot/schema remains 40/49.
## v39.16.105
- Fixed 「あぁ^~ たまらねぇぜ」: burst zunchi no longer treat their spawn overlap with the owner as a collision.
- A burst zunchi now becomes eligible only after it has cleared the owner once; a later fatal return collision unlocks the achievement, including routes that reflect and accelerate on a bounce fence.
- Preserved the existing nonfatal, stopped, and player-held disqualification behavior.
- Fixed ミニマリスト placement-origin metadata so it remains saveable after 無計画都市 has already unlocked.
- Fixed サウナ水風呂 to measure 15 seconds from genuine entry into a hot state instead of refreshing the hot timestamp on every hot evaluation.
- Fixed 抵抗器じゃない so a Tarinai killed by the qualifying shock still counts before death clears its live state.
- Achievement reset now clears reset-scoped hidden per-Tarinai and per-item progress, including care counts, sauna state, held duration, fight-mochi metadata, sticky-bomb pass count, and quick-delete timestamps.
- Undo/Redo and valid ground changes now reset 監視カメラ observation progress as world interventions.
- Added route-level and secondary regression coverage for these achievement paths. Ambiguous achievement semantics identified by the renewed audit remain unchanged pending explicit specification.
39.16.105: Applied confirmed achievement semantics: birth-status slave/king appearances count; dynasty achievements require direct parent-child qualifying lineage; Overprotective treatment counts only actual beneficial health recovery; Unplanned City counts deletion operations rather than removed objects; Surveillance Camera requires 3 uninterrupted game days and resets on gameplay interventions; wire-shock partial progress remains nonpersistent; Pest Control counts only player-caused ant deaths.
## v39.16.105
- Optimized Tarinai social-neighbor scans with squared-distance filtering, nearest-N partial selection, pair-deduplicated symmetric work, cached personality profiles, and shared disease spread logic.
- Added personality-derived crowd comfort bounds. Overcrowded Tarinai flee toward less crowded space with the specified behavior text; undercrowded Tarinai become stressed and approach the nearest living Tarinai with the specified loneliness text.
## v39.16.105
- Replaced Undo/Redo object snapshots and recursive patch history with the existing synchronous binary save codec payload.
- Exposed binary snapshot encode/decode helpers from TarinaiSaveCodec for internal history reuse without Base32768 text conversion or asynchronous compression.
- Replaced JSON.stringify-based duplicate detection with a compact dual 32-bit fingerprint over the encoded history bytes.
- Unified Undo and Redo stack movement through a shared history transition path while preserving synchronous behavior and the 48-entry limit.
- Added binary-history regression coverage for storage shape, duplicate suppression, Undo restoration, and Redo restoration.
## v39.16.105
- Render stack optimization: static visible stacks are cached by static spatial generation and camera region, while dynamic stacks reuse previous-frame order before insertion sorting and are merged linearly.
- Link rendering now uses dedicated AABB spatial cells instead of per-frame full type-bucket fallback scans.
- Carried plushies render at owner-relative positions without mutating simulation coordinates during rendering.
- Fixed render culling radii are cached on eligible static entities.
- Render-layer sort keys are computed once per stack insertion.
## v39.16.105
- Reduced fixed render cost by skipping the fallback full-screen gradient whenever the transformed field fully covers the viewport.
- Cached full-screen fallback gradients and rebuilt atmospheric full-screen lighting at 8 Hz instead of constructing multiple full-screen gradients every frame.
- Reused dynamic render-layer entries, sort maps, and previous-order ID arrays to reduce per-frame garbage collection as population grows.
## v39.16.105
- Removed social crowd-retreat selection and the overcrowded colony-state distance scan.
- Removed the unconditional three-second full terrain-cache invalidation; cached terrain now refreshes only from real terrain changes, with splat fading invalidating local chunks.
- Throttled structure dependency safety sweeps, staggered item/ant compaction, and stopped timer-only item bucket rebuilds.
- Deferred periodic statistics UI refreshes and collapsed population statistics into a single pass.
- Ordinary item additions/removals no longer invalidate the full terrain cache unless cached terrain items actually changed.
## v39.16.105
- Large-save restore uses an adaptive non-damaging settling phase for dense loaded populations, preventing synthetic body-overlap and obstacle-correction impulses from cascading into impact deaths.
- Duplicator loading takes priority over direct feeding when a Tarinai overlaps the machine.
- Save schema 50 explicitly stores elapsed completed days; total simulation time is restored monotonically and seasons/climate derive from elapsed days. Schema 49 spells remain readable.
## v39.16.105
- Moved the ずんちどれい and たりない王 population-chart toggles to immediately after 死亡数.
- Removed the 12-second periodic Tarinai runtime-cache full sweep that caused large-population performance regressions.
- Runtime relationship/cache cleanup is now scheduled only after accumulated deaths reach max(20, floor(living population * 15%)), then runs once during the next Tarinai compaction and resets its counter.
## v39.16.105
- Added eight playstyle-survey achievements: 数こそ力, 少数精鋭, 踩屎感, 快適な寝床, 石に枕す, 季節を越えて, 推し, and 統計学者.
- 数こそ力 unlocks at 100 living Tarinai. 少数精鋭 requires 1-25 living Tarinai continuously for five game days and discloses its condition/progress before unlock.
- 統計学者 counts 30 statistics-tab or chart-series visibility button presses in persistent achievement state, so field resets do not clear its progress.
- Achievement spell payload v6 and binary save schema 51 expand the unlock mask from 64 to 72 achievements while retaining v5/schema 49-50 read compatibility.
- Updated the Item lifecycle pipeline regression to validate local bucket/spatial invalidation when passive serving-food decay actually changes its visual footprint.
## v39.16.105
- Removed previous-order render Maps and custom insertion sorting; dynamic visible lists now use native Array.sort after culling.
- Removed the shared route cache while retaining actor-local route throttling.
- Disabled detailed performance counters in normal play; diagnostics remain available only in explicit debug/perf mode.
- Isolated obstacle-query cache invalidation to routing-obstacle generations instead of generic spatial movement.
- Replaced per-frame full Tarinai spatial-grid rebuilds with cell-crossing incremental updates.
- Added sustained hysteresis to automatic performance-profile transitions to prevent rapid quality oscillation.
- Death-reference cleanup now executes in bounded per-frame chunks after the existing adaptive death threshold.
## v39.16.105
- Fixed dense-population birth regression caused by incremental Tarinai spatial-grid bucket ordering interacting with an early 20-candidate contact cutoff. Contact interactions now consider the complete already-fetched local candidate set while retaining only the nearest bounded interaction list.
- Moved the "季節を越えて" achievement from the ecology category to "その他".
39.16.105: UI input/limits/zoom refinements, infection chance reduced to one-third, 75 achievements, grass spatial/hot-path optimization, summer flat temperature bonus removed.
- v39.16.105: restricted in-world item tooltips to sleep furniture; delete tool now removes lodged pins after pinch-equivalent detach effects; raised confusion/breeding colony-state thresholds; clicking a Tarinai scrolls the right panel to Tarinai Data.
## v39.16.107
- Long-session runtime caches now expire independently of deaths; recurring fight resolution no longer creates permanent time-bucket keys.
- Visual effects OFF now gates effect creation/update/drawing, including rain, while ON keeps the existing visual budgets unchanged and uses broader effect pooling plus allocation-free update traversal.
- Hidden family trees no longer run full graph normalization/pruning from simulation maintenance; pending cleanup is handled when the family view is opened.
## v39.16.106
- Renamed the achievement 「メメント・モリ」 to 「多数の死は統計上の数字でしかない」.
- Replaced push-only log kinds with six shared event categories: 生命 / 危険 / 交流 / 生活 / 環境 / 観察. Category toggles now filter both the event UI and push notifications.
- Prevented family-tree layout work before first use and while the family section is inactive; tab switches reuse family caches, cancel offscreen in-progress layout work, use one activation event, and throttle scroll-driven tab detection to animation frames.
- Manual Tarinai addition now resets 監視カメラ observation progress as a player intervention.
## v39.16.109
- Repaired family-tree activation after lazy-render cancellation and added blank-view recovery rendering.
- Robot cleaners keep obstructed cleanup targets and route around nest boxes/fences instead of permanently excluding nearby zunchi.
- Added the achievement 「マッドサイエンティスト」 for administering mystery drug to 10 distinct Tarinai.
Current build metadata is defined in js/version.js and app_manifest.json.

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.16.109",
"version": "39.16.137",
"entrypoints": {
"app": "index.html",
"runtime": "js/main.js",

View file

@ -75,26 +75,19 @@ Manual save and restore use browser storage. Reset returns to field selection an
Display settings control effects, shadows, drawing detail, and quality choices for crowded scenes. Crowded fields are an intended part of the sandbox, so the game uses spatial lookup, scheduling, cached drawing, and incremental maintenance systems to keep large colonies and many objects playable.
## Achievement system (v39.16.78)
- Sixty-four achievements track reproduction, natural status changes, generation runs, rapid population events, special deaths, colony conditions, individual care, autonomous ecology, history usage, cleanup, rapid deletion, temperature transitions, weather shelter, medicine-category completion, combat reversals, circuit-board signaling, and ant-related play styles.
- Locked rows reveal only the configured condition allowlist; all others show ``. There are no achievement tooltips. 大いなる母, めっちゃたりない観察, 敵の敵は味方, 混沌ヲ希求スル者, and 豊穣ヲ希求スル者 include local progress percentages, and unlocked achievements are sorted first inside each category.
## Achievement system
- Seventy-six achievements track reproduction, natural status changes, generation runs, rapid population events, special deaths, colony conditions, individual care, autonomous ecology, history usage, cleanup, rapid deletion, temperature transitions, weather shelter, medicine-category completion, combat reversals, circuit-board signaling, and ant-related play styles.
- Locked rows reveal only the configured condition allowlist; all others show ``. There are no achievement tooltips. 母なる大地, めっちゃたりない観察, 敵の敵は味方, 混沌ヲ希求スル者, and 豊穣ヲ希求スル者 include local progress percentages, and unlocked achievements are sorted first inside each category.
- Each achievement row behaves as a horizontal bar chart. Its background fill visualizes the optional global completion percentage from 0100%, and the global achiever count is displayed beside it.
- `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets.
- `餌やりじいさん` requires 33 direct feedings in one field; resetting the field resets this progress.
- `餌やりじいさん` requires 33 cumulative direct feedings; field resets do not reset this progress.
- `機械化産業` replaces the former detector-automation and link-craftsman achievements and unlocks only when both requirements are met.
- v39.16.56 adds 混沌ヲ希求スル者 and 潔癖症, raises 清掃業者 to 200, starts ミニマリスト from day 6, excludes chain/crown from おくすり手帳全埋め, and keeps shared-rate bars visible after unlock.
- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく. ベビーブーム now requires 30 births in one minute; 管理された楽園 starts tracking from play day 6 and requires one uninterrupted happy in-game day.
- Local unlocks and persistent totals live in browser storage. Field-scoped direct-feeding, cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress follow current-format saves where applicable. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了.
- Local unlocks and persistent totals live in browser storage. Direct-feeding, Undo, and Redo totals persist across field resets. Cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress remain field-scoped where their conditions require it. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了.
- Achievement evaluation is primarily event-driven; periodic world scans skip completed or irrelevant conditions. The server writes compact v3 achievement aggregate JSON while migrating older aggregate formats on read.
- v39.16.78 fixes achievement edge paths found by a renewed 64-condition audit: Minimalist placement metadata survives unrelated unlocks, Sauna/Cold-Plunge uses true hot-entry timing, lethal qualifying wire shocks count before death cleanup, reset clears hidden reset-scoped progress, and Undo/Redo/ground changes interrupt passive observation. Ambiguous semantics are intentionally unchanged pending specification.
### Achievement spell persistence (v39.16.78)
### Achievement spell persistence
- Exported spells compactly include non-debug achievement unlocks, unlock times, and persistent progress.
- Loading a spell merges achievements and keeps the greater progress instead of erasing local achievement data.
- Restored unlocks are queued for the existing server recovery synchronization. Save slots remain world-only.
- Spell achievement metadata uses a sparse binary encoding: raw unlock bits, bit-packed persistent progress, minute-delta unlock timestamps, and sparse per-entity state. Binary save schema 51 is current; schemas 49 and 50 remain readable. Achievement payload v6 stores 72 unlock bits and statistician progress, while legacy payload v5 remains importable.
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.
- v39.16.62: Added five achievements for low FPS, uninterrupted 24-hour play, toilet-sand placement, park-ground editing, and rapid ground changes. 寿命 deaths are excluded from 安全第一 failures, empty-field clicks replace pause-button clicks for 何やってるの?, and the two long observation achievements are last in その他.
- v39.16.63: Added アリ観察 and 命のバトン; stabilized progress-row typography; continuous-play observation no longer resets on tab changes; corrected sauna wording and enemy-ant streak reset behavior; obsolete spell formats are rejected.
- Spell achievement metadata uses sparse binary encoding for unlock bits, persistent progress, minute-delta unlock timestamps, and sparse per-entity state. The current save and achievement payload formats are defined by `js/save_schema.js` and `js/achievements.js`.

View file

@ -13,19 +13,6 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
## Runtime Shape
- v39.16.37 changes: biological and environmental conditions are unified into the detector; the standalone sensor and relay are removed; temperature and limits are folded; ecology, sound, display settings, and credits are in the header menu; reset remains outside in red; tarinai-data navigation preserves the active tool; mobile tool descriptions appear at the top for five seconds. Snap mode remains Free/20px/40px/60px, top-edge placement is fixed, and preset descriptions end with punctuation.
- v39.16.38 changes: detector numeric controls use value-labelled sliders like the time control; colony temperature and limits share one Colony settings fold; display settings use an inline menu matching sound settings; HP/stress meter colors are fixed; the snap control follows Clear with a faint grid icon; area-delete has a new icon; the Ant chart series is renamed and hidden initially.
- v39.16.55 changes: expanded the achievement set to 49 with 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one happy in-game day from play day 6; restored shared poke targeting/highlights and plushie fling; corrected manual rotator/reciprocator push direction; tightened tool/limit UI; reduced periodic achievement scans; and introduced compact achievement aggregate storage.
- v39.16.52 changes: reorganized achievements into collapsible categories, removed achievement tooltips, and moved confirmation flows into the game UI. Previous v39.16.51 changes: expanded the achievement set to 43 with sixteen play-style and special-action conditions; added persistent Undo/Redo and quick-delete counters, field-local cleaner/automation-free ecology progress, individual care/temperature/fight tracking, complete medicine-category collection, protected rain shelter in nest boxes and pipes, and stricter ant-kill attribution.
- v39.16.49 changes: added additive full-state synchronization so shared achievement aggregates can be rebuilt as players return after server data loss.
- v39.16.48 changes: corrected the zunchi-slave threshold text to 30%; repaired wire placement counting, danger-kill attribution, partial legacy link migration, UUID validation, and debug-only achievement isolation.
- v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button.
- v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field.
- v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.78`.
- v39.16.31 changes: all in-world tooltips use the closer offset; Hair Trigger adds a central bounce-fence ball enclosure with Tarinai and pet-food duplicators outside; physics tools use distinct icons.
- v39.16.29 changes: parasol placement preview now uses the exact shade center and footprint; toilet sand absorbs rain-created water drops. Happy adds parasols, water balloons, and toilet sand beneath each duplicator; Athletic uses fence-safe single-bar rotators and water balloons; War adds rotating fans.
- v39.16.19 changes: food and medicine can be given directly by placement or pinched drop, with a dedicated overlay, food heart bursts, and stronger personality effects; unused collision reasons and duplicate constraint projection dirty checks were removed.
- 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`.
@ -65,48 +52,10 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
- Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All`
### v39.16.16
Colony birth/death chart lines, reverse rotators, champion-trait inheritance, reduced champion-pair mating, and directional one-way-fence routing.
- v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons.
## Achievement system (v39.16.78)
- Sixty-four local achievements cover biological events, special deaths, colony conditions, individual care, passive ecology, Undo/Redo behavior, rapid deletion, cleaner use, temperature transitions, medicine-category completion, weather shelter, combat reversals, circuit-board signaling, and ant-related play styles.
- Locked entries show the configured pre-unlock disclosure directly in the row; all other locked entries show ``. 大いなる母, めっちゃたりない観察, and 敵の敵は味方 show local progress percentages. Unlocked entries appear first within each category.
- Each entry is a horizontal completion-rate bar whose background fill reflects the optional shared global unlock percentage; the shared achiever count appears beside the percentage.
- v39.16.64 adds 毎日たりない観察7 calendar days and 抵抗器じゃない7 distinct tarinai shocked by one energized bare wire. v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく; both are event-driven and 町のお医者さん is disclosed before unlock. v39.16.56 added two achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars. v39.16.60 adds three achievements and restricts the chaos counter to fight-mochi-influenced fights.
- Persistent totals include 物でいっぱい, 害虫駆除, 大いなる母3333体, 無計画都市, やっぱなし, and やっぱあり. Field-local progress includes 餌やりじいさん, 清掃業者, 自給自足, and 敵の敵は味方; applicable field-local metadata follows current-format saves.
- `自動化の第一歩` and `リンク職人` are retired and replaced by `機械化産業`, which requires both detector-driven activation and rope/rod/spring coverage. Existing players with both retired achievements migrate automatically.
- Optional same-origin PHP API aggregates anonymous player counts and global unlock percentages. Raw aggregate data is stored under `.achievement_data/state.json` in compact v3 positional arrays. Compact v2 and the prior verbose object are accepted only as migration inputs and are automatically rewritten under a separate server lock with atomic file replacement on the next API read. UUIDs are binary/base64url encoded, achievement IDs are indexed, timestamps are delta-coded, and repeated game versions use a dictionary.
- On startup, shared-stat refresh, achievement-screen opening, and every five minutes, the client sends its complete non-debug unlock set with original local timestamps. Ordinary unlock bursts send only unsynchronized records after a short debounce. The server adds only missing records, so a lost `state.json` is rebuilt incrementally as players return without overwriting surviving records.
### Shared achievement percentage deployment
- Upload `achievement_api.php` and the hidden `.achievement_data` directory with the game.
- PHP must be enabled, and `.achievement_data` must be writable by the PHP process.
- On a static-only host, local unlocks still work; the global percentage is shown as unavailable.
- Set `window.TARINAI_ACHIEVEMENT_API` before `achievements.js` to use a different same-origin endpoint.
- The current release tracks sixty-four achievement IDs, supports per-player reset, and accepts additive bulk synchronization for automatic recovery after aggregate-data loss. A separate backup remains necessary to recover players who never return.
- The current release tracks 76 achievement IDs, supports per-player reset, and accepts additive bulk synchronization for automatic recovery after aggregate-data loss. A separate backup remains necessary to recover players who never return.
- `python3 scripts/achievement_server_state_audit.py` creates deterministic randomized achievement histories at runtime; it does not require or modify a production `state.json`.
- v39.16.60 changes: achievement metadata in spells is now encoded as sparse binary data instead of embedded JSON; unlock masks, progress, timestamps, per-entity counters, and placement metadata are compactly packed and spell data is accepted only from the current format.
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.
- v39.16.62: Added スペックがたりない, 寝ろ, 砂場, そこ公共空間だよ?, and 破格の工事費用; 寿命 deaths no longer reset 安全第一; 何やってるの? now uses empty-field clicks; and save schema 44 expands the achievement mask while decoding schemas 4143.
- v39.16.63: Fixed achievement progress text weight and continuous-play tab switching; added アリ観察 and 命のバトン; corrected サウナ水風呂 and 敵の敵は味方 condition mismatches; save/spell data accepts only schema 45 and achievement payload v4.
- v39.16.64: Added 毎日たりない観察 and 抵抗器じゃない; introduced calendar-day streak persistence; and migrated server aggregate JSON to compact v3 positional data automatically on read.
- v39.16.65: Hardened pre-deployment server migration with read-only summaries, POST-only mutations, corruption-safe decoding, atomic writes under a separate lock, completionist eligibility filtering, and seeded randomized/concurrent migration audits.
- v39.16.66: Added two-user seesaw play, an editable AND/OR/NOT circuit board with positioned terminals, signal control for fan/stove/dry ice/duplicator, and wire-target highlighting.
- v39.16.67: Replaced the circuit-board dropdown editor with direct grid placement and cursor-drawn wiring; changed colony-state evaluation to every two in-game hours and excluded growing populations from Weakened; fixed seesaw action selection, fixed its orientation, and matched the placement overlay to its shape.
- v39.16.69: Reworked circuit-board wiring into straight grid segments with branches and multi-connect terminals; removed OR and added a rectifier; made internal parts rotatable and external terminal count/positions editable; expanded board terminal spacing; changed detector numeric conditions to min/max dual sliders with number inputs; and raised seesaw riders slightly above the seats.
- v39.16.68: Fixed seesaw participation so idle/bored Tarinai can initiate play and a waiting rider strongly recruits a partner; selected data reports active play as 「シーソーをしている。」; moved seesaws behind sprite layers; and aligned all two-input circuit gate pins to the 40px wiring grid.
- v39.16.70: Reduced the default circuit-board footprint to one quarter of the previous area; made grid width/height editable; made external terminals movable on all four edges with a height-dependent count limit; made branch junctions movable; reduced NOT/rectifier body sizes; and corrected seesaw action-reason text.
- v39.16.71: Replaced circuit-board terminal +/- controls with direct IN/OUT terminal placement and erasing, dimmed terminals without external world wires, removed generic elliptical placement previews, added an Electrical tool category for detector/wires/circuit board, and made detector range-track clicks move the nearest thumb.
- v39.16.72: Circuit boards now default to 10x10 with a 20x20 maximum, AND uses a two-cell height, compact gates can sit closer to top/bottom edges, placement previews follow occupancy footprints more directly, and unstable internal logic loops are explicitly forced OFF.
- v39.16.73: Circuit-board editing now shows placement ghosts for internal parts and terminals, Q/E rotates the pending internal part by 90 degrees, ON constant sources are explained in the editor, and wire/insulated-wire palette silhouettes render correctly after the Electrical category move.
- v39.16.78: Fixed 「あぁ^~ たまらねぇぜ」 so burst zunchi ignore their spawn overlap with the owner, arm only after clearing the owner once, and unlock on a later fatal return collision including bounce-fence reflections. The renewed achievement audit also fixes ミニマリスト save metadata, サウナ水風呂 hot-entry timing, lethal wire-shock counting, reset-scoped hidden progress cleanup, and 監視カメラ intervention gaps for Undo/Redo and ground changes.
- v39.16.76: Removed the circuit-board OFF constant part and added the 「情報化産業」 achievement for carrying an active signal directly from one circuit board to another over wire or insulated wire.

View file

@ -10,6 +10,7 @@ const ACHIEVEMENT_IDS = [
'natural_zunchi_slave',
'natural_tarinai_king',
'self_zunchi_death',
'zunchi_collision_death',
'natural_zunchi_slave_5_generations',
'natural_tarinai_king_3_generations',
'death_50_in_10_seconds',
@ -188,7 +189,7 @@ function normalize_state(mixed $value): array {
];
}
foreach ($value['u'] as $achievementId => $flat) {
if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($flat)) continue;
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($flat)) continue;
$records = [];
$count = count($flat);
for ($i = 0; $i + 1 < $count; $i += 2) {
@ -210,7 +211,7 @@ function normalize_state(mixed $value): array {
}
$rawUnlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : [];
foreach ($rawUnlocks as $achievementId => $records) {
if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($records)) continue;
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue;
foreach ($records as $id => $timestamp) {
$normalizedId = strtolower((string)$id);
if (!isset($players[$normalizedId])) continue;
@ -219,20 +220,6 @@ function normalize_state(mixed $value): array {
}
}
$legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first'])
? $unlocks['signal_automation_first'] : [];
$legacyLinks = isset($unlocks['link_craftsman']) && is_array($unlocks['link_craftsman'])
? $unlocks['link_craftsman'] : [];
if ($legacySignal && $legacyLinks) {
if (!isset($unlocks['mechanized_industry']) || !is_array($unlocks['mechanized_industry'])) {
$unlocks['mechanized_industry'] = [];
}
foreach ($legacySignal as $playerId => $signalTime) {
if (!isset($legacyLinks[$playerId])) continue;
$unlocks['mechanized_industry'][$playerId] = max((int)$signalTime, (int)$legacyLinks[$playerId]);
}
}
unset($unlocks['signal_automation_first'], $unlocks['link_craftsman']);
return ['players' => $players, 'unlocks' => $unlocks];
}

View file

@ -1,5 +1,5 @@
{
"version": "39.16.109",
"version": "39.16.137",
"css": [
"css/base.css",
"css/layout.css",
@ -58,6 +58,7 @@
"js/item_dynamic_duplicator_system.js",
"js/item_dynamic_pin_system.js",
"js/item_dynamic_zunchi_system.js",
"js/item_environment_hazard_system.js",
"js/item_dynamic_system.js",
"js/item_lifecycle_decay_system.js",
"js/item_lifecycle_growth_system.js",
@ -69,6 +70,7 @@
"js/item_lifecycle_pipeline.js",
"js/item_runtime.js",
"js/structure_lifecycle.js",
"js/memorial_bell_system.js",
"js/item_render_helpers.js",
"js/item_render_runtime.js",
"js/burn_motion_util.js",

Binary file not shown.

View file

@ -91,6 +91,11 @@ h1 {
white-space: nowrap;
}
.speed-select-native { display: none; }
.speed-menu { position: relative; display: inline-block; }
.speed-menu-btn { min-width: 92px; text-align: center; white-space: nowrap; }
.top-actions {
display: flex;
gap: 6px;

View file

@ -27,6 +27,9 @@
gap: 8px;
flex-wrap: wrap;
margin: 8px 0 4px;
position: relative;
z-index: 4;
isolation: isolate;
}
.favorite-toggle {
border: 1px solid rgba(128, 102, 56, 0.32);
@ -34,8 +37,15 @@
background: rgba(255, 248, 221, 0.92);
color: #5a4630;
font-weight: 700;
padding: 5px 10px;
padding: 7px 12px;
cursor: pointer;
position: relative;
z-index: 5;
display: block;
width: 100%;
box-sizing: border-box;
pointer-events: auto;
touch-action: manipulation;
}
.favorite-toggle.active {
background: rgba(255, 229, 122, 0.95);
@ -681,6 +691,10 @@
gap: 5px;
}
.top-menu-panel.hidden { display: none; }
.speed-menu-panel { width: 116px; min-width: 116px; }
.speed-menu-panel .top-menu-item { text-align: center; }
.speed-menu-panel .top-menu-item.active,
.speed-menu-panel .top-menu-item[aria-checked="true"] { background: rgba(224,242,201,.92); }
.top-menu-item {
width: 100%;
min-height: 38px;

View file

@ -25,6 +25,7 @@
.tool[data-tool="delete"] { --tool-icon-zoom: 1.42; }
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1; }
.tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; }
.tool[data-tool="bell"].tool-emoji-icon::before { font-size: 21px; z-index: 1; }
.tool[data-tool="area_delete"].tool-emoji-icon::before {
font-size: 31px;
line-height: 1;
@ -125,6 +126,7 @@
box-shadow: 0 1px 4px rgba(42,36,29,0.16);
pointer-events: auto;
cursor: pointer;
z-index: 4;
}
.chart-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: 3px 0 8px; }
.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; }
@ -353,3 +355,7 @@ meter {
border-color: rgba(38,132,190,0.56);
box-shadow: inset 0 0 0 1px rgba(84,176,226,0.20), 0 8px 20px rgba(59,133,177,0.10);
}
.chart-range-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: -2px 0 7px; }
.chart-range-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.48); border-radius: 8px; padding: 4px 4px; font-size: 10px; cursor: pointer; color: var(--muted); }
.chart-range-tabs button.active { background: rgba(239,255,222,0.92); border-color: rgba(103,178,44,0.42); color: var(--text); font-weight: 700; }

View file

@ -119,6 +119,14 @@ body::before {
pointer-events: none;
}
.family-death-time {
display: block;
margin-top: 1px;
font-size: 10px;
line-height: 1.15;
color: rgba(93, 72, 61, 0.76);
}
.family-genetic-stats {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -260,8 +268,8 @@ body::before {
.lineage-family-tree .tree-node {
position: absolute;
width: 150px;
height: 104px;
min-height: 104px;
height: 112px;
min-height: 112px;
box-sizing: border-box;
align-items: flex-start;
background: linear-gradient(180deg, #fffdf8, #f8f1e5);
@ -280,6 +288,29 @@ body::before {
.lineage-family-tree .tree-node[data-family-tarinai-id]:hover {
transform: none;
}
.lineage-family-tree.family-hover-active .tree-node.family-hover-dim {
opacity: 0.28;
filter: saturate(0.58);
transition: opacity .12s ease, filter .12s ease, box-shadow .12s ease, border-color .12s ease;
}
.lineage-family-tree.family-hover-active .tree-node.family-hover-focus,
.lineage-family-tree.family-hover-active .tree-node.family-hover-parent,
.lineage-family-tree.family-hover-active .tree-node.family-hover-child {
opacity: 1;
filter: none;
}
.lineage-family-tree.family-hover-active .tree-node.family-hover-focus {
border-color: rgba(124, 165, 66, 0.78);
box-shadow: 0 0 0 3px rgba(124, 165, 66, 0.16), 0 5px 13px rgba(64,48,32,0.10);
}
.lineage-family-tree.family-hover-active .tree-node.family-hover-parent {
border-color: rgba(80, 132, 198, 0.72);
box-shadow: 0 0 0 2px rgba(80, 132, 198, 0.14);
}
.lineage-family-tree.family-hover-active .tree-node.family-hover-child {
border-color: rgba(87, 170, 91, 0.72);
box-shadow: 0 0 0 2px rgba(87, 170, 91, 0.14);
}
.lineage-family-tree .tree-node .family-portrait {
right: 4px;
top: 4px;
@ -319,7 +350,7 @@ body::before {
/* Restored from the original ZIP: fixed colony graph/legend sizing. */
.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; }
.colony-stats {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr;
margin-bottom: 8px;
}
.colony-chart {
@ -442,3 +473,12 @@ body::before {
line-height: 9px !important;
font-size: 6.8px !important;
}
.colony-chart { position: relative; }
.colony-chart-tooltip { position: fixed; z-index: 10020; pointer-events: none; min-width: 112px; max-width: 210px; padding: 7px 8px; border-radius: 9px; border: 1px solid rgba(80,65,48,0.18); background: rgba(255,252,245,0.98); color: #4d443a; box-shadow: 0 4px 14px rgba(64,48,30,0.16); font-size: 10px; line-height: 1.45; transform: none; }
.colony-chart-tooltip.hidden { display: none; }
.colony-chart-tooltip strong { display: block; margin-bottom: 3px; font-size: 10.5px; }
.colony-chart-tooltip-row { display: flex; justify-content: space-between; gap: 10px; white-space: nowrap; }
.colony-chart-hover-point { position: absolute; z-index: 5; width: 8px; height: 8px; border: 2px solid #5d8ee6; border-radius: 50%; background: rgba(255,255,255,0.96); box-shadow: 0 0 0 2px rgba(255,255,255,0.7); pointer-events: none; transform: translate(-50%, -50%); }
.colony-chart-hover-point.hidden { display: none; }

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.16.109" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.109" />
<link rel="shortcut icon" href="favicon.ico?v=39.16.109" />
<link rel="stylesheet" href="css/base.css?v=39.16.109" />
<link rel="stylesheet" href="css/layout.css?v=39.16.109" />
<link rel="stylesheet" href="css/panel.css?v=39.16.109" />
<link rel="stylesheet" href="css/components.css?v=39.16.109" />
<link rel="stylesheet" href="css/mobile.css?v=39.16.109" />
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.137" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.137" />
<link rel="shortcut icon" href="favicon.ico?v=39.16.137" />
<link rel="stylesheet" href="css/base.css?v=39.16.137" />
<link rel="stylesheet" href="css/layout.css?v=39.16.137" />
<link rel="stylesheet" href="css/panel.css?v=39.16.137" />
<link rel="stylesheet" href="css/components.css?v=39.16.137" />
<link rel="stylesheet" href="css/mobile.css?v=39.16.137" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -40,14 +40,27 @@
<button class="panel-search-next" type="button" disabled aria-label="&#x6B21;&#x306E;&#x8A72;&#x5F53;&#x4F4D;&#x7F6E;&#x3078;&#x79FB;&#x52D5;">&#x2193;</button>
</div>
<button id="pauseBtn" class="btn primary">&#x4E00;&#x6642;&#x505C;&#x6B62;</button>
<button id="speedBtn" class="btn">&#x901F;&#x5EA6; x1</button>
<div id="speedMenu" class="top-menu speed-menu">
<button id="speedMenuBtn" class="btn speed-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">&#x901F;&#x5EA6; &#xD7;1</button>
<div id="speedMenuPanel" class="top-menu-panel speed-menu-panel hidden" role="menu" aria-label="&#x901F;&#x5EA6;">
<button class="top-menu-item" type="button" role="menuitemradio" data-speed-value="0.5">&#xD7;0.5</button>
<button class="top-menu-item" type="button" role="menuitemradio" data-speed-value="1">&#xD7;1</button>
<button class="top-menu-item" type="button" role="menuitemradio" data-speed-value="2">&#xD7;2</button>
<button class="top-menu-item" type="button" role="menuitemradio" data-speed-value="4">&#xD7;4</button>
<button class="top-menu-item" type="button" role="menuitemradio" data-speed-value="8">&#xD7;8</button>
<button class="top-menu-item" type="button" role="menuitemradio" data-speed-value="16">&#xD7;16</button>
</div>
<select id="speedSelect" class="speed-select-native" aria-hidden="true" tabindex="-1">
<option value="0.5">0.5</option><option value="1" selected>1</option><option value="2">2</option><option value="4">4</option><option value="8">8</option><option value="16">16</option>
</select>
</div>
<button id="saveBtn" class="btn">&#x30BB;&#x30FC;&#x30D6;</button>
<button id="ecologyBtn" class="btn ecology-top-btn" type="button">&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x751F;&#x614B;</button>
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">&#x5B9F;&#x7E3E; <span id="achievementButtonCount">0/75</span></button>
<button id="resetBtn" class="btn danger reset-top-btn">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
<div id="topMenu" class="top-menu">
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="&#x30E1;&#x30CB;&#x30E5;&#x30FC;">&#x2630;</button>
<div id="topMenuPanel" class="top-menu-panel hidden" role="menu" aria-label="&#x30E1;&#x30CB;&#x30E5;&#x30FC;">
<button id="ecologyBtn" class="top-menu-item" type="button" role="menuitem">&#x751F;&#x614B;</button>
<div id="soundMenu" class="sound-menu top-menu-sound">
<button id="soundBtn" class="top-menu-item sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">&#x97F3; ON</button>
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="&#x97F3;">
@ -150,6 +163,7 @@
</div>
</details>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x500B;&#x4F53;&#x6570;</button><button data-chart="life">&#x6B32;&#x6C42;</button><button data-chart="objects">&#x74B0;&#x5883;</button></div>
<div id="colonyChartRangeTabs" class="chart-range-tabs" aria-label="&#x7D71;&#x8A08;&#x306E;&#x8868;&#x793A;&#x671F;&#x9593;"><button data-chart-range="day">1&#x65E5;</button><button data-chart-range="season" class="active">&#x5B63;&#x7BC0;</button><button data-chart-range="year">&#x5E74;</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div>
<div id="colonyChart" class="colony-chart" aria-label="&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x306E;&#x63A8;&#x79FB;&#x30B0;&#x30E9;&#x30D5;" role="img"></div>
</div>
@ -291,173 +305,175 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.16.109" defer></script>
<script src="js/event_bus.js?v=39.16.109" defer></script>
<script src="js/domain_ids.js?v=39.16.109" defer></script>
<script src="js/registry_base.js?v=39.16.109" defer></script>
<script src="js/disease_registry.js?v=39.16.109" defer></script>
<script src="js/sound_pack.js?v=39.16.109" defer></script>
<script src="js/deterministic_helpers.js?v=39.16.109" defer></script>
<script src="js/item_tool_metadata.js?v=39.16.109" defer></script>
<script src="js/item_tool_definitions.js?v=39.16.109" defer></script>
<script src="js/item_visual_definitions.js?v=39.16.109" defer></script>
<script src="js/item_food_definitions.js?v=39.16.109" defer></script>
<script src="js/item_effect_definitions.js?v=39.16.109" defer></script>
<script src="js/item_registry.js?v=39.16.109" defer></script>
<script src="js/data.js?v=39.16.109" defer></script>
<script src="js/ground_types.js?v=39.16.109" defer></script>
<script src="js/input_mode_manager.js?v=39.16.109" defer></script>
<script src="js/math.js?v=39.16.109" defer></script>
<script src="js/geometry_helpers.js?v=39.16.109" defer></script>
<script src="js/collision_footprint_system.js?v=39.16.109" defer></script>
<script src="js/physics_helpers.js?v=39.16.109" defer></script>
<script src="js/placement_preview_system.js?v=39.16.109" defer></script>
<script src="js/pin_attachment_system.js?v=39.16.109" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.16.109" defer></script>
<script src="js/mechanical_system.js?v=39.16.109" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.16.109" defer></script>
<script src="js/constraint_system.js?v=39.16.109" defer></script>
<script src="js/physics_world_system.js?v=39.16.109" defer></script>
<script src="js/physics_projection_system.js?v=39.16.109" defer></script>
<script src="js/circuit_board_system.js?v=39.16.109" defer></script>
<script src="js/signal_system.js?v=39.16.109" defer></script>
<script src="js/assets.js?v=39.16.109" defer></script>
<script src="js/audio.js?v=39.16.109" defer></script>
<script src="js/perf_profiler.js?v=39.16.109" defer></script>
<script src="js/display_helpers.js?v=39.16.109" defer></script>
<script src="js/render.js?v=39.16.109" defer></script>
<script src="js/sim_core.js?v=39.16.109" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.16.109" defer></script>
<script src="js/structures.js?v=39.16.109" defer></script>
<script src="js/item_type_catalog.js?v=39.16.109" defer></script>
<script src="js/items.js?v=39.16.109" defer></script>
<script src="js/item_type_initializers.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_support.js?v=39.16.109" defer></script>
<script src="js/item_update_policy.js?v=39.16.109" defer></script>
<script src="js/fire_runtime_system.js?v=39.16.109" defer></script>
<script src="js/robot_cleaner_system.js?v=39.16.109" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.16.109" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.16.109" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.16.109" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.16.109" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.16.109" defer></script>
<script src="js/item_dynamic_system.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.16.109" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.16.109" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.16.109" defer></script>
<script src="js/item_runtime.js?v=39.16.109" defer></script>
<script src="js/structure_lifecycle.js?v=39.16.109" defer></script>
<script src="js/item_render_helpers.js?v=39.16.109" defer></script>
<script src="js/item_render_runtime.js?v=39.16.109" defer></script>
<script src="js/burn_motion_util.js?v=39.16.109" defer></script>
<script src="js/ants.js?v=39.16.109" defer></script>
<script src="js/health.js?v=39.16.109" defer></script>
<script src="js/tarinai.js?v=39.16.109" defer></script>
<script src="js/tarinai_action_spec.js?v=39.16.109" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.16.109" defer></script>
<script src="js/tarinai_identity_social.js?v=39.16.109" defer></script>
<script src="js/tarinai_action_state.js?v=39.16.109" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.16.109" defer></script>
<script src="js/tarinai_item_effects.js?v=39.16.109" defer></script>
<script src="js/tarinai_needs_core.js?v=39.16.109" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.16.109" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.16.109" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.16.109" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.16.109" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.16.109" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.16.109" defer></script>
<script src="js/seesaw_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.16.109" defer></script>
<script src="js/tarinai_needs_items.js?v=39.16.109" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.109" defer></script>
<script src="js/tarinai_direct_feeding_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.16.109" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.16.109" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.16.109" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.16.109" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.16.109" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.16.109" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.16.109" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.16.109" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.16.109" defer></script>
<script src="js/tarinai_runtime.js?v=39.16.109" defer></script>
<script src="js/tarinai_render.js?v=39.16.109" defer></script>
<script src="js/world.js?v=39.16.109" defer></script>
<script src="js/world_view.js?v=39.16.109" defer></script>
<script src="js/family_graph.js?v=39.16.109" defer></script>
<script src="js/world_reset_presets.js?v=39.16.109" defer></script>
<script src="js/world_family_social.js?v=39.16.109" defer></script>
<script src="js/impact_core_system.js?v=39.16.109" defer></script>
<script src="js/impact_response_system.js?v=39.16.109" defer></script>
<script src="js/collision_response_system.js?v=39.16.109" defer></script>
<script src="js/world_combat_effects.js?v=39.16.109" defer></script>
<script src="js/world_environment.js?v=39.16.109" defer></script>
<script src="js/world_temperature_system.js?v=39.16.109" defer></script>
<script src="js/world_pathfinding_system.js?v=39.16.109" defer></script>
<script src="js/world_grass_placement_system.js?v=39.16.109" defer></script>
<script src="js/world_spatial_budget.js?v=39.16.109" defer></script>
<script src="js/world_ants_system.js?v=39.16.109" defer></script>
<script src="js/weather_system.js?v=39.16.109" defer></script>
<script src="js/item_update_scheduler.js?v=39.16.109" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.16.109" defer></script>
<script src="js/tarinai_update_policy.js?v=39.16.109" defer></script>
<script src="js/simulation_environment_system.js?v=39.16.109" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.16.109" defer></script>
<script src="js/simulation_effects_system.js?v=39.16.109" defer></script>
<script src="js/simulation_creature_system.js?v=39.16.109" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.16.109" defer></script>
<script src="js/simulation_ambient_system.js?v=39.16.109" defer></script>
<script src="js/simulation_systems.js?v=39.16.109" defer></script>
<script src="js/system_order.js?v=39.16.109" defer></script>
<script src="js/simulation.js?v=39.16.109" defer></script>
<script src="js/colony_situation_system.js?v=39.16.109" defer></script>
<script src="js/world_update.js?v=39.16.109" defer></script>
<script src="js/world_tool_actions.js?v=39.16.109" defer></script>
<script src="js/world_placement_log.js?v=39.16.109" defer></script>
<script src="js/world_event_effects.js?v=39.16.109" defer></script>
<script src="js/command_dispatcher.js?v=39.16.109" defer></script>
<script src="js/text_catalog.js?v=39.16.109" defer></script>
<script src="js/ui.js?v=39.16.109" defer></script>
<script src="js/game_dialogs.js?v=39.16.109" defer></script>
<script src="js/achievements.js?v=39.16.109" defer></script>
<script src="js/ui_helpers.js?v=39.16.109" defer></script>
<script src="js/ui_log.js?v=39.16.109" defer></script>
<script src="js/ui_selected.js?v=39.16.109" defer></script>
<script src="js/save_schema.js?v=39.16.109" defer></script>
<script src="js/snapshot_system.js?v=39.16.109" defer></script>
<script src="js/restore_coordinator.js?v=39.16.109" defer></script>
<script src="js/history_system.js?v=39.16.109" defer></script>
<script src="js/save_codec.js?v=39.16.109" defer></script>
<script src="js/save_storage.js?v=39.16.109" defer></script>
<script src="js/save_system.js?v=39.16.109" defer></script>
<script src="js/ui_tooltips.js?v=39.16.109" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.16.109" defer></script>
<script src="js/ui_ground.js?v=39.16.109" defer></script>
<script src="js/ui_tools.js?v=39.16.109" defer></script>
<script src="js/ui_input_shared.js?v=39.16.109" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.16.109" defer></script>
<script src="js/ui_input_touch.js?v=39.16.109" defer></script>
<script src="js/ui_input_mouse.js?v=39.16.109" defer></script>
<script src="js/ui_bind.js?v=39.16.109" defer></script>
<script src="js/ui_charts.js?v=39.16.109" defer></script>
<script src="js/ui_family_data.js?v=39.16.109" defer></script>
<script src="js/ui_family_async.js?v=39.16.109" defer></script>
<script src="js/ui_family_layout.js?v=39.16.109" defer></script>
<script src="js/ui_family_paths.js?v=39.16.109" defer></script>
<script src="js/ui_family_render.js?v=39.16.109" defer></script>
<script src="js/main.js?v=39.16.109" defer></script>
<script src="js/debug_tools.js?v=39.16.109" defer></script>
<script src="js/version.js?v=39.16.137" defer></script>
<script src="js/event_bus.js?v=39.16.137" defer></script>
<script src="js/domain_ids.js?v=39.16.137" defer></script>
<script src="js/registry_base.js?v=39.16.137" defer></script>
<script src="js/disease_registry.js?v=39.16.137" defer></script>
<script src="js/sound_pack.js?v=39.16.137" defer></script>
<script src="js/deterministic_helpers.js?v=39.16.137" defer></script>
<script src="js/item_tool_metadata.js?v=39.16.137" defer></script>
<script src="js/item_tool_definitions.js?v=39.16.137" defer></script>
<script src="js/item_visual_definitions.js?v=39.16.137" defer></script>
<script src="js/item_food_definitions.js?v=39.16.137" defer></script>
<script src="js/item_effect_definitions.js?v=39.16.137" defer></script>
<script src="js/item_registry.js?v=39.16.137" defer></script>
<script src="js/data.js?v=39.16.137" defer></script>
<script src="js/ground_types.js?v=39.16.137" defer></script>
<script src="js/input_mode_manager.js?v=39.16.137" defer></script>
<script src="js/math.js?v=39.16.137" defer></script>
<script src="js/geometry_helpers.js?v=39.16.137" defer></script>
<script src="js/collision_footprint_system.js?v=39.16.137" defer></script>
<script src="js/physics_helpers.js?v=39.16.137" defer></script>
<script src="js/placement_preview_system.js?v=39.16.137" defer></script>
<script src="js/pin_attachment_system.js?v=39.16.137" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.16.137" defer></script>
<script src="js/mechanical_system.js?v=39.16.137" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.16.137" defer></script>
<script src="js/constraint_system.js?v=39.16.137" defer></script>
<script src="js/physics_world_system.js?v=39.16.137" defer></script>
<script src="js/physics_projection_system.js?v=39.16.137" defer></script>
<script src="js/circuit_board_system.js?v=39.16.137" defer></script>
<script src="js/signal_system.js?v=39.16.137" defer></script>
<script src="js/assets.js?v=39.16.137" defer></script>
<script src="js/audio.js?v=39.16.137" defer></script>
<script src="js/perf_profiler.js?v=39.16.137" defer></script>
<script src="js/display_helpers.js?v=39.16.137" defer></script>
<script src="js/render.js?v=39.16.137" defer></script>
<script src="js/sim_core.js?v=39.16.137" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.16.137" defer></script>
<script src="js/structures.js?v=39.16.137" defer></script>
<script src="js/item_type_catalog.js?v=39.16.137" defer></script>
<script src="js/items.js?v=39.16.137" defer></script>
<script src="js/item_type_initializers.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_support.js?v=39.16.137" defer></script>
<script src="js/item_update_policy.js?v=39.16.137" defer></script>
<script src="js/fire_runtime_system.js?v=39.16.137" defer></script>
<script src="js/robot_cleaner_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.16.137" defer></script>
<script src="js/item_environment_hazard_system.js?v=39.16.137" defer></script>
<script src="js/item_dynamic_system.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.16.137" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.16.137" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.16.137" defer></script>
<script src="js/item_runtime.js?v=39.16.137" defer></script>
<script src="js/structure_lifecycle.js?v=39.16.137" defer></script>
<script src="js/memorial_bell_system.js?v=39.16.137" defer></script>
<script src="js/item_render_helpers.js?v=39.16.137" defer></script>
<script src="js/item_render_runtime.js?v=39.16.137" defer></script>
<script src="js/burn_motion_util.js?v=39.16.137" defer></script>
<script src="js/ants.js?v=39.16.137" defer></script>
<script src="js/health.js?v=39.16.137" defer></script>
<script src="js/tarinai.js?v=39.16.137" defer></script>
<script src="js/tarinai_action_spec.js?v=39.16.137" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.16.137" defer></script>
<script src="js/tarinai_identity_social.js?v=39.16.137" defer></script>
<script src="js/tarinai_action_state.js?v=39.16.137" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_effects.js?v=39.16.137" defer></script>
<script src="js/tarinai_needs_core.js?v=39.16.137" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.16.137" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.16.137" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.16.137" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.16.137" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.16.137" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.16.137" defer></script>
<script src="js/seesaw_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.16.137" defer></script>
<script src="js/tarinai_needs_items.js?v=39.16.137" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.137" defer></script>
<script src="js/tarinai_direct_feeding_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.16.137" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.16.137" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.16.137" defer></script>
<script src="js/tarinai_runtime.js?v=39.16.137" defer></script>
<script src="js/tarinai_render.js?v=39.16.137" defer></script>
<script src="js/world.js?v=39.16.137" defer></script>
<script src="js/world_view.js?v=39.16.137" defer></script>
<script src="js/family_graph.js?v=39.16.137" defer></script>
<script src="js/world_reset_presets.js?v=39.16.137" defer></script>
<script src="js/world_family_social.js?v=39.16.137" defer></script>
<script src="js/impact_core_system.js?v=39.16.137" defer></script>
<script src="js/impact_response_system.js?v=39.16.137" defer></script>
<script src="js/collision_response_system.js?v=39.16.137" defer></script>
<script src="js/world_combat_effects.js?v=39.16.137" defer></script>
<script src="js/world_environment.js?v=39.16.137" defer></script>
<script src="js/world_temperature_system.js?v=39.16.137" defer></script>
<script src="js/world_pathfinding_system.js?v=39.16.137" defer></script>
<script src="js/world_grass_placement_system.js?v=39.16.137" defer></script>
<script src="js/world_spatial_budget.js?v=39.16.137" defer></script>
<script src="js/world_ants_system.js?v=39.16.137" defer></script>
<script src="js/weather_system.js?v=39.16.137" defer></script>
<script src="js/item_update_scheduler.js?v=39.16.137" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.16.137" defer></script>
<script src="js/tarinai_update_policy.js?v=39.16.137" defer></script>
<script src="js/simulation_environment_system.js?v=39.16.137" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.16.137" defer></script>
<script src="js/simulation_effects_system.js?v=39.16.137" defer></script>
<script src="js/simulation_creature_system.js?v=39.16.137" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.16.137" defer></script>
<script src="js/simulation_ambient_system.js?v=39.16.137" defer></script>
<script src="js/simulation_systems.js?v=39.16.137" defer></script>
<script src="js/system_order.js?v=39.16.137" defer></script>
<script src="js/simulation.js?v=39.16.137" defer></script>
<script src="js/colony_situation_system.js?v=39.16.137" defer></script>
<script src="js/world_update.js?v=39.16.137" defer></script>
<script src="js/world_tool_actions.js?v=39.16.137" defer></script>
<script src="js/world_placement_log.js?v=39.16.137" defer></script>
<script src="js/world_event_effects.js?v=39.16.137" defer></script>
<script src="js/command_dispatcher.js?v=39.16.137" defer></script>
<script src="js/text_catalog.js?v=39.16.137" defer></script>
<script src="js/ui.js?v=39.16.137" defer></script>
<script src="js/game_dialogs.js?v=39.16.137" defer></script>
<script src="js/achievements.js?v=39.16.137" defer></script>
<script src="js/ui_helpers.js?v=39.16.137" defer></script>
<script src="js/ui_log.js?v=39.16.137" defer></script>
<script src="js/ui_selected.js?v=39.16.137" defer></script>
<script src="js/save_schema.js?v=39.16.137" defer></script>
<script src="js/snapshot_system.js?v=39.16.137" defer></script>
<script src="js/restore_coordinator.js?v=39.16.137" defer></script>
<script src="js/history_system.js?v=39.16.137" defer></script>
<script src="js/save_codec.js?v=39.16.137" defer></script>
<script src="js/save_storage.js?v=39.16.137" defer></script>
<script src="js/save_system.js?v=39.16.137" defer></script>
<script src="js/ui_tooltips.js?v=39.16.137" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.16.137" defer></script>
<script src="js/ui_ground.js?v=39.16.137" defer></script>
<script src="js/ui_tools.js?v=39.16.137" defer></script>
<script src="js/ui_input_shared.js?v=39.16.137" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.16.137" defer></script>
<script src="js/ui_input_touch.js?v=39.16.137" defer></script>
<script src="js/ui_input_mouse.js?v=39.16.137" defer></script>
<script src="js/ui_bind.js?v=39.16.137" defer></script>
<script src="js/ui_charts.js?v=39.16.137" defer></script>
<script src="js/ui_family_data.js?v=39.16.137" defer></script>
<script src="js/ui_family_async.js?v=39.16.137" defer></script>
<script src="js/ui_family_layout.js?v=39.16.137" defer></script>
<script src="js/ui_family_paths.js?v=39.16.137" defer></script>
<script src="js/ui_family_render.js?v=39.16.137" defer></script>
<script src="js/main.js?v=39.16.137" defer></script>
<script src="js/debug_tools.js?v=39.16.137" defer></script>
</body>
</html>

View file

@ -20,7 +20,7 @@
const WIRE_SHOCK_TARGET = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
const ROBOT_CLEAN_TARGET = 200;
const ROBOT_ALL_TARGET_MASK = 0x3f;
const ROBOT_ALL_TARGET_MASK = 0x7f;
const SYNC_DEBOUNCE_MS = 450;
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
const SPELL_STATE_VERSION = 8;
@ -65,7 +65,10 @@
"elite_few",
"lively_making",
"memento_mori",
"mad_scientist",
"rain_shelter_all",
"self_zunchi_death",
"zunchi_collision_death",
"megalopolis",
]);
const ACHIEVEMENT_CATEGORIES = Object.freeze([
Object.freeze({
@ -95,7 +98,7 @@
id: "experiment",
title: "\u5b9f\u9a13",
ids: Object.freeze([
"self_zunchi_death", "death_50_in_10_seconds", "soccer_ball_death",
"self_zunchi_death", "zunchi_collision_death", "death_50_in_10_seconds", "soccer_ball_death",
"fight_pair_danger_kill", "ignite_during_birth_ritual", "laxative_starvation",
"ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge",
"mercury_lifespan", "revolution", "fuel_to_fire",
@ -134,11 +137,12 @@
Object.freeze({ id: "natural_zunchi_slave", title: "\u305a\u3093\u3061\u3069\u308c\u3044", description: "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u51fa\u73fe\u3059\u308b\u3002" }),
Object.freeze({ id: "natural_tarinai_king", title: "\u305f\u308a\u306a\u3044\u738b", description: "\u305f\u308a\u306a\u3044\u738b\u304c\u51fa\u73fe\u3059\u308b\u3002" }),
Object.freeze({ id: "self_zunchi_death", title: "\u3042\u3041\uff3e\uff5e \u305f\u307e\u3089\u306d\u3047\u305c", description: "\u304a\u3057\u308a\u92f2\u3092\u629c\u3044\u305f\u6642\u3001\u81ea\u5206\u306e\u305a\u3093\u3061\u3067\u81ea\u7206\u3059\u308b\u3002" }),
Object.freeze({ id: "zunchi_collision_death", title: "\u304f\u3061\u3083\u3044\u306e\u3060\uff01", description: "\u305a\u3093\u3061\u3068\u306e\u885d\u7a81\u3067\u6b7b\u4ea1\u3059\u308b\u500b\u4f53\u304c\u51fa\u73fe\u3059\u308b\u3002" }),
Object.freeze({ id: "natural_zunchi_slave_5_generations", title: "\u672b\u4ee3\u307e\u3067\u306e\u6065", description: "5\u4ee3\u9023\u7d9a\u3067\u305a\u3093\u3061\u3069\u308c\u3044\uff08\u9396\u306a\u3057\uff09" }),
Object.freeze({ id: "natural_tarinai_king_3_generations", title: "\u83ef\u9e97\u306a\u308b\u738b\u65cf", description: "3\u4ee3\u9023\u7d9a\u3067\u305f\u308a\u306a\u3044\u738b\uff08\u738b\u51a0\u306a\u3057\uff09" }),
Object.freeze({ id: "death_50_in_10_seconds", title: "6\u756a\u76ee\u306e\u5927\u91cf\u7d76\u6ec5", description: "10\u79d2\u4ee5\u5185\u306b50\u4f53\u306e\u305f\u308a\u306a\u3044\u304c\u6b7b\u4ea1\u3059\u308b\u3002" }),
Object.freeze({ id: "birth_50_in_60_seconds", title: "\u30d9\u30d3\u30fc\u30d6\u30fc\u30e0", description: "1\u5206\u4ee5\u5185\u306b30\u4f53\u306e\u305f\u308a\u306a\u3044\u304c\u8a95\u751f\u3059\u308b\u3002" }),
Object.freeze({ id: "great_mother_1000_births", title: "\u5927\u3044\u306a\u308b\u6bcd", description: "\u305f\u308a\u306a\u3044\u3092\u7e41\u6b96\u3067\u7d2f\u8a083333\u4f53\u8a95\u751f\u3055\u305b\u308b\u3002" }),
Object.freeze({ id: "great_mother_1000_births", title: "\u6bcd\u306a\u308b\u5927\u5730", description: "\u305f\u308a\u306a\u3044\u3092\u7e41\u6b96\u3067\u7d2f\u8a083333\u4f53\u8a95\u751f\u3055\u305b\u308b\u3002" }),
Object.freeze({ id: "lifespan_completed", title: "\u5929\u5bff", description: "\u305f\u308a\u306a\u30441\u4f53\u304c\u5929\u5bff\u3092\u5168\u3046\u3059\u308b\u3002" }),
Object.freeze({ id: "soccer_ball_death", title: "\u8c46\u91ce\u3001\u6bba\u30c3\u30ab\u30fc\u3084\u308d\u3046\u305c\uff01", description: "\u30b5\u30c3\u30ab\u30fc\u30dc\u30fc\u30eb\u3068\u306e\u885d\u7a81\u3067\u3001\u305f\u308a\u306a\u30441\u4f53\u304c\u6b7b\u4ea1\u3059\u308b\u3002" }),
Object.freeze({ id: "fight_pair_danger_kill", title: "\u55a7\u5629\u4e21\u6210\u6557", description: "\u55a7\u5629\u4e2d\u306e\u305f\u308a\u306a\u3044\u3092\u3001\u4e21\u65b9\u3068\u3082\u5371\u967a\u30ab\u30c6\u30b4\u30ea\u306e\u30a2\u30a4\u30c6\u30e0\u3067\u5012\u3059\u3002" }),
@ -249,7 +253,7 @@
let scheduledProgressSaveTimer = 0;
function blankState() {
return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0, mysteryDrugTarinaiIds: [] } };
return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, directFeedCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0, mysteryDrugTarinaiIds: [] } };
}
function loadState() {
@ -261,15 +265,6 @@
for (const [id, timestamp] of Object.entries(unlockedSource)) {
if (definitionById.has(id) && Number(timestamp) > 0) unlocked[id] = Number(timestamp);
}
const legacyMechanizedAt = Math.max(
Number(unlockedSource.signal_automation_first || 0) || 0,
Number(unlockedSource.link_craftsman || 0) || 0
);
if (!unlocked.mechanized_industry
&& Number(unlockedSource.signal_automation_first || 0) > 0
&& Number(unlockedSource.link_craftsman || 0) > 0) {
unlocked.mechanized_industry = legacyMechanizedAt || Date.now();
}
const debugUnlockedSource = Array.isArray(parsed.debugUnlocked) ? parsed.debugUnlocked : [];
const debugUnlocked = [...new Set(debugUnlockedSource.filter(id => definitionById.has(id)))];
if (!DEBUG_MODE) {
@ -277,21 +272,17 @@
}
const pendingSource = Array.isArray(parsed.pending) ? parsed.pending : [];
const pending = pendingSource.filter(id => definitionById.has(id) && !debugUnlocked.includes(id));
if (unlocked.mechanized_industry && !pending.includes("mechanized_industry")
&& (pendingSource.includes("signal_automation_first") || pendingSource.includes("link_craftsman"))) {
pending.push("mechanized_industry");
}
const progressSource = parsed.progress && typeof parsed.progress === "object" ? parsed.progress : {};
const legacyLinkTypes = Number(unlockedSource.link_craftsman || 0) > 0 ? ["rope", "rod", "spring"] : [];
const progress = {
placementCount: Math.max(0, Math.floor(Number(progressSource.placementCount || 0) || 0)),
linkTypes: [...new Set([...(Array.isArray(progressSource.linkTypes) ? progressSource.linkTypes : []), ...legacyLinkTypes].filter(type => ["rope", "rod", "spring"].includes(type)))],
signalActivated: Boolean(progressSource.signalActivated || unlockedSource.signal_automation_first),
linkTypes: [...new Set((Array.isArray(progressSource.linkTypes) ? progressSource.linkTypes : []).filter(type => ["rope", "rod", "spring"].includes(type)))],
signalActivated: Boolean(progressSource.signalActivated),
antKills: Math.max(0, Math.floor(Number(progressSource.antKills || 0) || 0)),
birthCount: Math.max(0, Math.floor(Number(progressSource.birthCount || 0) || 0)),
quickDeleteCount: Math.max(0, Math.floor(Number(progressSource.quickDeleteCount || 0) || 0)),
undoCount: Math.max(0, Math.floor(Number(progressSource.undoCount || 0) || 0)),
redoCount: Math.max(0, Math.floor(Number(progressSource.redoCount || 0) || 0)),
directFeedCount: Math.min(33, Math.max(0, Math.floor(Number(progressSource.directFeedCount || 0) || 0))),
medicineTypes: [...new Set((Array.isArray(progressSource.medicineTypes) ? progressSource.medicineTypes : []).filter(type => MEDICINE_LEDGER_TYPES.includes(type)))],
firstAidHeals: Math.max(0, Math.floor(Number(progressSource.firstAidHeals || 0) || 0)),
fightMochiFightCount: Math.max(0, Math.floor(Number(progressSource.fightMochiFightCount || 0) || 0)),
@ -495,6 +486,7 @@
Math.min(STATISTICIAN_TARGET, Math.max(0, Number(state.progress?.statsButtonPressCount || 0) || 0)),
Math.min(MEMENTO_MORI_TARGET, Math.max(0, Number(state.progress?.totalDeathCount || 0) || 0)),
Math.min(LIVELY_MAKING_TARGET, Math.max(0, Number(state.progress?.manualTarinaiAddedCount || 0) || 0)),
Math.min(33, Math.max(0, Number(state.progress?.directFeedCount || 0) || 0)),
Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0),
Math.floor(Math.max(0, (Number(global.world?.time || 0) || 0) - Math.max(0, Number(global.world?.achievementEliteFewStartAt || 0) || 0)) / Math.max(1, Number(global.world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120) * 10),
].join(":");
@ -918,6 +910,7 @@
state.progress.quickDeleteCount = Math.max(0, Math.floor(Number(state.progress.quickDeleteCount || 0) || 0));
state.progress.undoCount = Math.max(0, Math.floor(Number(state.progress.undoCount || 0) || 0));
state.progress.redoCount = Math.max(0, Math.floor(Number(state.progress.redoCount || 0) || 0));
state.progress.directFeedCount = Math.min(33, Math.max(0, Math.floor(Number(state.progress.directFeedCount || 0) || 0)));
if (!Array.isArray(state.progress.medicineTypes)) state.progress.medicineTypes = [];
state.progress.medicineTypes = [...new Set(state.progress.medicineTypes.filter(type => MEDICINE_LEDGER_TYPES.includes(type)))];
state.progress.firstAidHeals = Math.max(0, Math.floor(Number(state.progress.firstAidHeals || 0) || 0));
@ -1003,6 +996,7 @@
state.unlocked.memento_mori ? 0 : Math.min(MEMENTO_MORI_TARGET, progress.totalDeathCount),
state.unlocked.lively_making ? 0 : Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount),
state.unlocked.mad_scientist ? 0 : Math.min(MAD_SCIENTIST_TARGET, progress.mysteryDrugTarinaiIds.length),
state.unlocked.direct_feed_33 ? 0 : Math.min(33, progress.directFeedCount),
],
];
}
@ -1100,6 +1094,7 @@
while (progress.mysteryDrugTarinaiIds.length < importedMysteryDrugCount) {
progress.mysteryDrugTarinaiIds.push(`imported:${progress.mysteryDrugTarinaiIds.length + 1}`);
}
progress.directFeedCount = Math.max(progress.directFeedCount, Math.min(33, Math.max(0, Math.floor(Number(packed[18] || 0) || 0))));
const progressChanged = previousProgress !== JSON.stringify(progress);
changed = changed || progressChanged;
@ -1127,7 +1122,9 @@
recordIntervention({ ...detail, world });
const item = detail.item || null;
if (item) {
if (!item._achievementPlayerPlaced || item._achievementPlayerPlacedCounted === false) world.achievementPlayerPlacedActiveCount = Math.max(0, Number(world.achievementPlayerPlacedActiveCount || 0) || 0) + 1;
item._achievementPlayerPlaced = true;
item._achievementPlayerPlacedCounted = true;
item._achievementPlacedAt = Math.max(0, Number(world.time || 0) || 0);
}
let changed = evaluateCleanFreak(world, detail);
@ -1226,7 +1223,7 @@
function evaluateAntNestWithoutTarinai(worldRef = global.world, detail = {}) {
if (!worldRef || state.unlocked.ant_nest_without_tarinai) return false;
const hasNest = (worldRef.items || []).some(item => item && !item.dead && item.type === "ant_nest");
const hasNest = playstyleItemCount(worldRef, "ant_nest") > 0;
if (!hasNest) return false;
const aliveTarinai = (worldRef.tarinai || []).some(tarinai => tarinai && !tarinai.dead);
if (aliveTarinai) return false;
@ -1439,7 +1436,7 @@
function evaluateSandbox(world = global.world, detail = {}) {
if (!world || state.unlocked.sandbox_five_toilets) return false;
const count = (world.items || []).reduce((sum, item) => sum + (item && !item.dead && item.type === "toilet" ? 1 : 0), 0);
const count = playstyleItemCount(world, "toilet");
if (count < 5) return false;
return unlock("sandbox_five_toilets", { ...detail, world, count });
}
@ -1500,11 +1497,30 @@
function recordBelowAbsoluteZeroItem(worldRef = global.world, detail = {}) {
if (state.unlocked.below_absolute_zero_item || !worldRef) return false;
for (const item of worldRef.items || []) {
if (!item || item.dead) continue;
worldRef.ensureItemBuckets?.("achievement-absolute-zero");
const testItem = (item) => {
if (!item || item.dead) return false;
const temperature = Number(worldRef.temperatureAt?.(item.x, item.y, null));
if (!Number.isFinite(temperature) || temperature >= ABSOLUTE_ZERO_C) continue;
if (!Number.isFinite(temperature) || temperature >= ABSOLUTE_ZERO_C) return false;
return unlock("below_absolute_zero_item", { ...detail, world: worldRef, item, temperature });
};
const ambient = Number(worldRef.currentTemperature);
if (Number.isFinite(ambient) && ambient < ABSOLUTE_ZERO_C) {
for (const bucket of worldRef.itemTypeBuckets?.values?.() || []) {
for (const item of bucket || []) if (testItem(item)) return true;
}
return false;
}
const seen = new Set();
for (const sourceType of ["dry_ice", "fan"]) {
for (const source of worldRef.itemsOfType?.(sourceType) || []) {
if (!source || source.dead) continue;
for (const item of worldRef.nearbyNonGrassItems?.(source.x, source.y, 360) || []) {
if (!item || item.dead || seen.has(item)) continue;
seen.add(item);
if (testItem(item)) return true;
}
}
}
return false;
}
@ -1527,8 +1543,10 @@
function evaluateCleanFreak(world = global.world, detail = {}) {
if (!world || state.unlocked.clean_freak_robot_only) return false;
const liveItems = (world.items || []).filter(item => item && !item.dead);
if (!liveItems.length || liveItems.some(item => item.type !== "robot_cleaner")) return false;
const activeObjects = Math.max(0, Number(world.activeObjectCount?.() ?? (world.items || []).reduce((count, item) => count + (item && !item.dead ? 1 : 0), 0)) || 0);
const robotCount = Math.max(0, Number(world.itemCounts?.robot_cleaner || 0) || 0);
if (!activeObjects || robotCount !== activeObjects) return false;
const liveItems = world.itemsOfType?.("robot_cleaner") || [];
if ((world.tarinai || []).some(tarinai => tarinai && !tarinai.dead)) return false;
if ((world.ants || []).some(ant => ant && !ant.dead)) return false;
const qualifies = liveItems.some(robot => {
@ -1706,7 +1724,6 @@
if (!world) return false;
runtimeByWorld.delete(world);
dangerFightDeathsByWorld.delete(world);
world.achievementDirectFeedCount = 0;
world.achievementRobotCleanCount = 0;
world.achievementEnemyAntKills = 0;
world.achievementLastTarinaiDamageAt = -Infinity;
@ -1734,6 +1751,14 @@
}
world.achievementNaturalSlaveGenerations = [];
world.achievementNaturalKingGenerations = [];
if (!state.unlocked.mad_scientist) {
const progress = ensureProgress();
if (progress.mysteryDrugTarinaiIds.length) {
progress.mysteryDrugTarinaiIds = [];
saveState();
renderIfDialogOpen();
}
}
return true;
}
@ -1852,9 +1877,11 @@
if (!world) return false;
recordDirectCare({ ...detail, world, foodGift: true });
if (state.unlocked.direct_feed_33) return false;
world.achievementDirectFeedCount = Math.min(33, Math.max(0, Math.floor(Number(world.achievementDirectFeedCount || 0) || 0)) + 1);
const progress = ensureProgress();
progress.directFeedCount = Math.min(33, progress.directFeedCount + 1);
saveState();
renderIfDialogOpen();
if (world.achievementDirectFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: world.achievementDirectFeedCount });
if (progress.directFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: progress.directFeedCount });
return false;
}
@ -2009,7 +2036,7 @@
const moodId = String(mood?.id || worldRef.colonyMood?.id || "");
const day = Math.max(1, Math.floor(Number(worldRef.day || 1) || 1));
if (alive.length > 0 && moodId === "happy" && day >= 6 && !state.unlocked.minimalist_happy) {
const placedAlive = (worldRef.items || []).reduce((count, item) => count + (item && !item.dead && item._achievementPlayerPlaced ? 1 : 0), 0);
const placedAlive = Math.max(0, Number(worldRef.achievementPlayerPlacedActiveCount ?? worldRef.achievementPlayerPlacementCount ?? 0) || 0);
if (placedAlive <= 10) unlock("minimalist_happy", { world: worldRef, mood: mood || worldRef.colonyMood, placedAlive });
}
if (!state.unlocked.colony_happy) {
@ -2027,7 +2054,7 @@
}
if (!state.unlocked.self_sufficient) {
if (alive.length >= 20 && !(worldRef.items || []).some(item => item && !item.dead && item.type === "duplicator")) {
if (alive.length >= 20 && playstyleItemCount(worldRef, "duplicator") === 0) {
let selfStart = Number(worldRef.achievementSelfSufficientStartAt);
if (!Number.isFinite(selfStart) || selfStart < 0 || selfStart > now) {
selfStart = now;
@ -2264,6 +2291,7 @@
evaluateWorld,
evaluateEvent,
recordSelfZunchiDeath(detail = {}) { return unlock("self_zunchi_death", detail); },
recordZunchiCollisionDeath(detail = {}) { return unlock("zunchi_collision_death", detail); },
recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); },
});
global.TarinaiAchievements = api;

View file

@ -77,7 +77,7 @@ let imageLoadTimeoutsTotal = 0;
const SUNBATH_IMAGE_IDS = new Set(SPRITES
.map(asset => asset?.id || "")
.filter(id => String(id).startsWith("sunbath")));
const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
const INITIAL_IMAGE_IDS = new Set(["smile", "sleep", "hungry_70", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "hot_1", "hot_2", "hot_3", "cold_1", "cold_2", "cold_3", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
function trimCanvasCache(cache, limit = 128) {

View file

@ -314,6 +314,7 @@ const audio = {
waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); },
shoot() { this.play("sfx_shoot", { category: "ops", minGap: 0.045 }); },
boltAction() { this.play("sfx_bolt_action", { category: "ops", minGap: 0.10 }); },
bell() { if (!this.playSample("sfx_bell", 0.16, "ops")) this.play("sfx_bell", { category: "ops", minGap: 0.16 }); },
ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); },
stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); },
genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); },

View file

@ -565,8 +565,6 @@
return null;
}
const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
function wirePath(config, wire) {
const a = endpointPosition(config, wire.a);
const b = endpointPosition(config, wire.b);
@ -579,7 +577,7 @@
for (let i = config.wires.length - 1; i >= 0; i -= 1) {
const path = wirePath(config, config.wires[i]);
if (path.length < 2) continue;
const d = distanceToSegment(x, y, path[0].x, path[0].y, path[1].x, path[1].y);
const d = global.TarinaiGeometry.pointSegmentDistance(x, y, path[0].x, path[0].y, path[1].x, path[1].y);
if (d <= bestD) { bestD = d; best = { wire: config.wires[i], index: i, distance: d }; }
}
return best;
@ -608,7 +606,7 @@
}
function pointOnSegment(point, a, b) {
return distanceToSegment(point.x, point.y, a.x, a.y, b.x, b.y) < 0.6
return global.TarinaiGeometry.pointSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) < 0.6
&& point.x >= Math.min(a.x, b.x) - 0.6 && point.x <= Math.max(a.x, b.x) + 0.6
&& point.y >= Math.min(a.y, b.y) - 0.6 && point.y <= Math.max(a.y, b.y) + 0.6;
}

View file

@ -52,7 +52,7 @@
function isSoftPlacementIgnoredType(type = "") {
const key = String(type || "");
return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water";
return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water" || key === "grave";
}
function placementRectsFor(worldRef, item) {

View file

@ -102,7 +102,6 @@
function normalizeToolPickMode(mode = "free") {
const value = String(mode || "free").toLowerCase();
if (value === "grid") return "grid40"; // compatibility with v39.16.34
return DEFAULT_PICK_MODE_ORDER.includes(value) ? value : "free";
}
@ -139,6 +138,15 @@
return ok({ speed: worldRef.speed });
}
function setSpeed(worldRef, speed, allowed = [0.5, 1, 2, 4, 8, 16]) {
if (!worldRef) return fail("missing_world");
const value = Number(speed);
const list = Array.isArray(allowed) ? allowed.map(Number).filter(Number.isFinite) : [0.5, 1, 2, 4, 8, 16];
if (!Number.isFinite(value) || !list.includes(value)) return fail("invalid_speed", { speed });
worldRef.speed = value;
return ok({ speed: worldRef.speed });
}
function primaryPointer(worldRef, x, y) {
if (!worldRef) return fail("missing_world");
const px = Number(x);
@ -171,6 +179,8 @@
return togglePaused(worldRef);
case "simulation.speed.cycle":
return cycleSpeed(worldRef, command.speeds);
case "simulation.speed.set":
return setSpeed(worldRef, command.speed, command.allowed);
case "pointer.primary":
return primaryPointer(worldRef, command.x, command.y);
case "history.undo": {

View file

@ -51,7 +51,7 @@ const CONFIG = {
hungerPerMinute: 36.0,
lonelinessPerMinute: 7.6,
energyPerMinute: 8.2,
reproductionCooldown: 30,
reproductionCooldown: 39,
dayLength: 120,
standardTemperature: 15,
summerTemperatureBoost: 0,

View file

@ -68,7 +68,6 @@
birthHash32,
isEffectCauseText,
});
global.isTarinaiEffectCauseText = global.isTarinaiEffectCauseText || isEffectCauseText;
})(typeof window !== "undefined" ? window : globalThis);
// Keep the historical bare high-level helper names available to legacy scripts while the

View file

@ -34,6 +34,8 @@ const HEALTH = (() => {
heatstroke: "\u71B1\u4E2D\u75C7",
cleaning: "\u6383\u9664",
electrocution: "\u611f\u96fb",
trap: "\u7f60\u306b\u6355\u307e\u3063\u305f",
poisonGas: "\u6bd2\u30ac\u30b9",
});
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
@ -41,6 +43,8 @@ const HEALTH = (() => {
const text = String(reason || "");
if (!text) return "";
if (/\u6383\u9664|\u30ed\u30dc\u6383\u9664\u6a5f|robot.?cleaner|cleaned/.test(text)) return CAUSES.cleaning;
if (/\u7f60\u306b\u6355\u307e\u3063\u305f|bear.?trap|trapped/i.test(text)) return CAUSES.trap;
if (/\u6bd2\u30ac\u30b9|poison.?gas/i.test(text)) return CAUSES.poisonGas;
if (/\u611f\u96fb|\u901a\u96fb\u4e2d\u306e\u96fb\u7dda|electrocution|electric.?shock|shocked/i.test(text)) return CAUSES.electrocution;
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
if (/\u51CD\u7D50|\u4F4E\u6E29|\u5BD2\u3055|cold|freeze|freezing/i.test(text)) return CAUSES.freeze;

View file

@ -82,6 +82,14 @@
}
if (ballHit > 0) worldRef.markSpatialDirty?.("genkotsu-ball-impact");
}
for (const grave of worldRef.itemsOfType?.("grave") || (worldRef.items || []).filter(item => item && item.type === "grave")) {
if (!grave || grave.dead) continue;
const d = Math.max(1, distXY(grave.x, grave.y, it.x, it.y));
const p = clamp(1 - d / (radius + (grave.r || 18)), 0, 1);
if (p <= 0) continue;
const force = push * (0.35 + p * 1.15);
global.TarinaiMemorialBellSystem?.applyExternalForce?.(worldRef, grave, force, it, "drop-impact");
}
const antHit = damage > 0 ? worldRef.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
push: p => push * (0.62 + p * 1.25),
source: it,

View file

@ -30,7 +30,8 @@
resolveObstacleCollisions(item, dt, 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);
const oilMul = global.TarinaiItemEnvironmentHazardSystem?.groundFrictionMultiplier?.(worldRef, item.x, item.y) ?? 1;
const groundFriction = Math.pow(1 - (1 - 0.72) * oilMul, dt);
item.vx *= groundFriction;
item.vy *= groundFriction;
}
@ -216,7 +217,7 @@
const now = worldRef?.time || 0;
if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return;
item.lastObstacleBounceAt = now;
const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"));
const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(235,104,165,0.50)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"));
worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color });
}

View file

@ -7,6 +7,12 @@
function update(item, dt, worldRef) {
if (!item || item.dead) return false;
switch (item.type) {
case "oil":
case "bear_trap":
case "leakage_device":
case "poison_gas_can":
case "poison_gas":
return global.TarinaiItemEnvironmentHazardSystem.update(item, dt, worldRef) !== false;
case "firecracker":
case "flame_firecracker":
case "sticky_bomb":

View file

@ -93,6 +93,9 @@
&& item.achievementBurstNeverStopped !== false
);
worldRef.applyImpactDamage?.(t, damage, "\u885d\u7a81", { source: item }) || t.damage?.(damage, "\u885d\u7a81");
if (t.dead) {
globalThis.TarinaiAchievements?.recordZunchiCollisionDeath?.({ world: worldRef, tarinai: t, zunchi: item, speed });
}
if (t.dead && eligibleSelfZunchiDeath) {
globalThis.TarinaiAchievements?.recordSelfZunchiDeath?.({ world: worldRef, tarinai: t, zunchi: item, speed });
}

View file

@ -0,0 +1,382 @@
"use strict";
(function(global){
const LOOSE_DYNAMIC_TYPES = new Set([
"ball","balloon","zunchi","pushpin","oshibyo","genkotsu",
"firecracker","flame_firecracker","sticky_bomb","water"
]);
function nearby(world,x,y,r){ return world?.nearbyItems?.(x,y,r,true) || world?.items || []; }
function oilAt(world,x,y){
for(const o of (world?.itemsOfType?.("oil")||[])) {
if(o&&!o.dead&&distXY(x,y,o.x,o.y)<=Math.max(12,o.r||58)) return o;
}
return null;
}
function groundFrictionMultiplier(world,x,y){ return oilAt(world,x,y)?0:1; }
function spawnOilFire(item,world,initial=false){
if(!item||!world)return null;
const r=Math.max(24,item.r||58);
const angle=initial ? global.deterministicAngle?.(world,"oil-ignite-fire",item,item.oilBurnTimer||0) ?? 0 : Math.random()*Math.PI*2;
const distance=initial ? 0 : Math.random()*r*.58;
return global.TarinaiFireRuntimeSystem?.spawnFireAt?.(
world,
item.x+Math.cos(angle)*distance,
item.y+Math.sin(angle)*distance*.58,
{radius:initial?20:12+Math.random()*5,amount:initial?72:30+Math.random()*24,life:initial?4.4:2.8+Math.random()*2.0,source:item,mergeRadius:initial?48:42}
) || null;
}
function igniteOil(item,world){
if(!item||item.dead||item.type!=="oil"||item.oilBurning)return false;
item.oilBurning=true;
item.oilBurnTimer=0;
item.nextOilFireAt=Number(world?.time||0);
spawnOilFire(item,world,true);
if(world){ world.drawListDirty=true; world.markSpatialDirty?.("oil-ignited"); }
return true;
}
function igniteOilNear(world,x,y,r=120){
let n=0;
for(const o of (world?.itemsOfType?.("oil")||[])){
if(o&&!o.dead&&distXY(x,y,o.x,o.y)<=r+Math.max(12,o.r||58)) n+=igniteOil(o,world)?1:0;
}
return n;
}
function igniteFromBurningOilContact(item,world){
if(!item||!world||!item.oilBurning)return 0;
const radius=Math.max(24,Number(item.r||58));
let ignited=0;
const tarinai=world.nearbyTarinai?.(item.x,item.y,radius+48,true)||world.tarinai||[];
for(const t of tarinai){
if(!t||t.dead||t.insideNestBoxId||world.isTarinaiHiddenInNestBox?.(t))continue;
if(distXY(item.x,item.y,t.x,t.y)>radius+Math.max(12,Number(t.radius||18))*.72)continue;
const wasBurning=Boolean(t.burning||(t.burnTimer||0)>.02);
if(typeof t.igniteFire==="function")t.igniteFire(item,false);
if(!wasBurning&&Boolean(t.burning||(t.burnTimer||0)>.02))ignited++;
}
const ants=world.nearbyAnts?.(item.x,item.y,radius+24,true)||world.ants||[];
for(const ant of ants){
if(!ant||ant.dead||distXY(item.x,item.y,ant.x,ant.y)>radius+Math.max(4,Number(ant.r||5)))continue;
const wasBurning=Boolean(ant.burning||(ant.burnTimer||0)>.02);
global.TarinaiFireRuntimeSystem?.igniteAntByFire?.(ant,item,world);
if(!wasBurning&&Boolean(ant.burning||(ant.burnTimer||0)>.02))ignited++;
}
return ignited;
}
function updateOil(item,dt,world){
if(!item.oilBurning){
for(const f of (world?.itemsOfType?.("fire")||[])){
if(f&&!f.dead&&distXY(item.x,item.y,f.x,f.y)<=Math.max(item.r||58,24)+Math.max(f.r||14,10)){
igniteOil(item,world); break;
}
}
return true;
}
const now=Number(world?.time||0);
igniteFromBurningOilContact(item,world);
item.oilBurnTimer=(item.oilBurnTimer||0)+dt;
item.amount=Math.max(0,(item.amount||100)-dt*5.2);
if(now>=Number(item.nextOilFireAt||0)){
item.nextOilFireAt=now+0.36+Math.random()*0.34;
spawnOilFire(item,world,false);
}
if(item.amount<=0){
item.amount=0;
world?.markItemBucketsDirty?.("oil-burnout");
world?.markSpatialDirty?.("oil-burnout");
world && (world.drawListDirty=true);
}
return true;
}
function isTrappableDynamic(it,world){
if(!it||it.dead||it.type==="bear_trap"||it.type==="poison_gas"||it.type==="fire") return false;
if(it.isStructure) return false;
if(["rope","rod","spring","wire","insulated_wire","rotator","reciprocator","poison_block"].includes(it.type)) return false;
if(LOOSE_DYNAMIC_TYPES.has(it.type)) return true;
if((it.dropTimer||0)>0 || Math.hypot(it.vx||0,it.vy||0)>0.2) return true;
return Boolean(world?.spatial?.isDynamicItem?.(it) && !it.isStructure);
}
function trapTarget(item,world){
const ref=item?.trapTargetRef;
if(ref&&!ref.dead&&(!item.trapTargetId||ref.id===item.trapTargetId)) return ref;
if(!item?.trapTargetId) return null;
const found=world?.itemById?.(item.trapTargetId)
|| (world?.items||[]).find(it=>it&&!it.dead&&it.id===item.trapTargetId)
|| (world?.tarinai||[]).find(t=>t&&!t.dead&&t.id===item.trapTargetId)
|| null;
item.trapTargetRef=found;
return found;
}
function hasTrapDamageStat(target){
if(!target||target.dead)return false;
if(Number.isFinite(Number(target.hp))||Number.isFinite(Number(target.health)))return true;
if(Number.isFinite(Number(target.maxHp))||Number.isFinite(Number(target.maxHealth)))return true;
if(typeof target.damage==="function"&&Number.isFinite(Number(target.energy)))return true;
return false;
}
function damageHeld(target,world,trap){
if(!target||target.dead||!hasTrapDamageStat(target))return false;
if(typeof target.damage==="function"){
try {
target.damage(2,"\u7f60\u306b\u6355\u307e\u3063\u305f",{source:trap});
} catch (_) {
target.damage(2,"\u7f60\u306b\u6355\u307e\u3063\u305f",{source:trap});
}
if (target.world && Number.isFinite(Number(target.radius))) target.hurtTimer=Math.max(Number(target.hurtTimer||0),.62);
return true;
}
if(Number.isFinite(Number(target.hp))){
target.hp=Math.max(0,Number(target.hp)-2);
target.hpBarTimer=Math.max(Number(target.hpBarTimer||0),.8);
if(target.hp<=0) global.TarinaiStructureLifecycle?.deleteItem?.(world,target,{reason:"bear-trap",breaker:trap});
return true;
}
if(Number.isFinite(Number(target.health))){
target.health=Math.max(0,Number(target.health)-2);
return true;
}
return false;
}
function holdTrapTarget(trap,target,world){
if(!trap||!target||target.dead)return false;
const now=Number(world?.time||0);
target._bearTrapHoldUntil=Math.max(Number(target._bearTrapHoldUntil||0),Number(trap.trapUntil||now));
target._bearTrapOwnerId=trap.id;
target._bearTrapHoldX=trap.x;
target._bearTrapHoldY=trap.y;
target.x=trap.x;
target.y=trap.y;
target.prevX=trap.x;
target.prevY=trap.y;
target.vx=0; target.vy=0; target.impulseVx=0; target.impulseVy=0; target.spinVelocity=0;
const body=global.TarinaiPhysicsBodySystem?.isBodyType?.(target) ? global.TarinaiPhysicsBodySystem.ensureBody?.(target,world,{syncFromLegacy:true}) : null;
if(body?.pose){ body.pose.x=trap.x; body.pose.y=trap.y; }
if(body?.velocity){ body.velocity.x=0; body.velocity.y=0; body.velocity.linear=0; body.velocity.angular=0; }
target._physicsExternalPoseDirty=true;
return true;
}
function triggerBearTrap(item,target,world){
if(!item||!target||target.dead)return false;
const now=Number(world?.time||0);
item.trapTargetId=target.id||"";
item.trapTargetRef=target;
item.trapTriggeredAt=now;
item.trapDamageableTarget=hasTrapDamageStat(target);
item.trapHoldDuration=item.trapDamageableTarget?10:5;
item.trapUntil=now+item.trapHoldDuration;
item.nextTrapDamageAt=now+.5;
item.trapClosed=true;
holdTrapTarget(item,target,world);
world?.queueEffect?.("ring",item.x,item.y,{size:Math.max(34,(item.r||34)*1.2),life:.22,color:"rgba(190,204,214,0.58)"});
if(world){ world.drawListDirty=true; world.markSpatialDirty?.("bear-trap-trigger"); }
return true;
}
function updateBearTrap(item,dt,world){
const now=Number(world?.time||0);
if(item.trapClosed || item.trapUntil>0){
const target=trapTarget(item,world);
if(now>=Number(item.trapUntil||0)){
if(target){
target._bearTrapHoldUntil=0;
target._bearTrapOwnerId="";
target._bearTrapHoldX=undefined;
target._bearTrapHoldY=undefined;
}
item.trapTargetRef=null;
item.trapTargetId="";
const removed=global.TarinaiStructureLifecycle?.deleteItem?.(world,item,{reason:"bear-trap-expired"});
if(!removed)item.amount=0;
world?.markItemBucketsDirty?.("bear-trap-expired");
world?.markSpatialDirty?.("bear-trap-expired");
return true;
}
if(target){
holdTrapTarget(item,target,world);
if(item.trapDamageableTarget&&now>=Number(item.nextTrapDamageAt||0)){
item.nextTrapDamageAt=now+.5;
damageHeld(target,world,item);
}
}
return true;
}
const captureRadius=Math.max(46,(item.r||34)*1.45);
let best=null,bestD=Infinity;
for(const it of nearby(world,item.x,item.y,captureRadius)){
if(it===item||!isTrappableDynamic(it,world))continue;
if(Number(it._bearTrapHoldUntil||0)>now)continue;
const d=distXY(item.x,item.y,it.x,it.y);
const rr=Math.max(12,(item.r||34)*.72)+Math.max(7,(it.r||12)*.62);
if(d<=rr&&d<bestD){ best=it;bestD=d; }
}
for(const t of (world?.nearbyTarinai?.(item.x,item.y,captureRadius+36,true)||world?.tarinai||[])){
if(!t||t.dead||Number(t._bearTrapHoldUntil||0)>now)continue;
const d=distXY(item.x,item.y,t.x,t.y);
const rr=Math.max(12,(item.r||34)*.72)+Math.max(10,(t.radius||18)*.62);
if(d<=rr&&d<bestD){ best=t;bestD=d; }
}
if(best) triggerBearTrap(item,best,world);
return true;
}
function enforceTrapHolds(world){
const now=Number(world?.time||0);
let held=0;
for(const trap of (world?.itemsOfType?.("bear_trap")||[])){
if(!trap||trap.dead||!trap.trapClosed||now>=Number(trap.trapUntil||0))continue;
const target=trapTarget(trap,world);
if(target&&holdTrapTarget(trap,target,world)) held++;
}
if(held) world?.markSpatialDirty?.("bear-trap-hold");
return held;
}
function leakageRadius(item){ return Math.max(260,(Number(item?.r)||34)*8.2); }
function pulseLeakage(item,world){
if(!item||item.dead||!world)return 0;
const powered=Boolean(item._signalDriven&&item._signalOn);
if(!powered)return 0;
const now=Number(world.time||0);
if(now<Number(item.nextLeakagePulseAt||0))return 0;
item.nextLeakagePulseAt=now+.24;
const radius=leakageRadius(item);
let hit=0;
for(const t of world.tarinai||[]){
if(!t||t.dead||world.isTarinaiHiddenInNestBox?.(t))continue;
const d=distXY(item.x,item.y,t.x,t.y);
if(d>radius+(t.radius||18))continue;
const q=Math.max(.2,1-d/Math.max(1,radius));
t.damage?.(1.5+.9*q,"\u6f0f\u96fb",{source:item});
t.hurtTimer=Math.max(t.hurtTimer||0,.22);
t.electricShockUntil=Math.max(Number(t.electricShockUntil||0),now+.48);
t.setActionState?.("panic",{target:null,reason:"\u611f\u96fb\u3057\u3066\u3044\u308b\u3002",wake:true,sleeping:false});
world.queueEffect?.("electric_shock",t.x,t.y-Math.max(2,(t.radius||18)*.15),{size:Math.max(18,(t.radius||18)*1.35),life:.32,color:"rgba(122,224,255,0.98)",importance:3,forceVisual:true});
hit++;
}
for(const ant of world.ants||[]){
if(!ant||ant.dead)continue;
const d=distXY(item.x,item.y,ant.x,ant.y);
if(d>radius+(ant.r||6))continue;
ant.hp=Math.max(0,Number(ant.hp??ant.maxHp??32)-1.4);
ant.hpBarTimer=Math.max(Number(ant.hpBarTimer||0),.7);
hit++;
}
world.queueEffect?.("ring",item.x,item.y,{size:radius*.62,life:.22,color:"rgba(112,206,255,0.38)"});
world.drawListDirty=true;
return hit;
}
function updateLeakage(item,dt,world){
// Electrical damage is applied after the signal graph has been resolved for
// this simulation step. Keep the per-item update as a lightweight no-op.
return true;
}
function updatePoweredHazards(world,dt){
let hit=0;
for(const item of (world?.itemsOfType?.("leakage_device")||[])) hit+=pulseLeakage(item,world);
return hit;
}
function spawnPoisonGas(world,can){
if(!world||!can||can._gasReleased)return 0;
can._gasReleased=true;
let n=0;
const sizeScale=Math.max(.55,Math.min(1.8,Number(can.r||28)/28));
const count=Math.max(7,Math.round(10*sizeScale));
for(let i=0;i<count;i++){
const a=Math.PI*2*i/count+Math.random()*.28;
const d=(10+Math.random()*56)*sizeScale;
const g=new Item("poison_gas",can.x+Math.cos(a)*d,can.y+Math.sin(a)*d*.72);
g.r=(82+Math.random()*48)*sizeScale;
g.gasConcentration=.82+Math.random()*.28;
g.gasLife=(18+Math.random()*8)*Math.sqrt(sizeScale);
g.gasExpansion=(58+Math.random()*42)*sizeScale;
g.vx=Math.cos(a)*(10+Math.random()*20)*sizeScale;
g.vy=Math.sin(a)*(10+Math.random()*20)*sizeScale;
if(world.addItem?.(g,"poison-gas",{terrain:false}))n++;
}
world.drawListDirty=true;
return n;
}
function burstPoisonGasCan(item,world,reason="fuse"){
if(!item||item.dead||item._gasReleased)return false;
const x=item.x,y=item.y;
const sizeScale=Math.max(.55,Math.min(1.8,Number(item.r||28)/28));
spawnPoisonGas(world,item);
world?.spawnEffect?.("explosion",x,y,{size:72*sizeScale,life:.58,color:"rgba(136,74,170,0.78)"});
world?.spawnEffect?.("ring",x,y,{size:112*sizeScale,life:.54,color:"rgba(184,108,216,0.52)"});
world?.applyExplosionExternalForces?.(x,y,105*sizeScale,item,.42*sizeScale,"poison-gas-can");
item.amount=0;
item.hp=0;
world?.markItemBucketsDirty?.("poison-gas-can-burst");
world?.markSpatialDirty?.("poison-gas-can-burst");
world && (world.drawListDirty=true);
global.audio?.explode?.();
world?.log?.("\u6bd2\u30ac\u30b9\u7f36\u304c\u7834\u88c2\u3057\u3001\u6bd2\u30ac\u30b9\u304c\u5e83\u304c\u3063\u305f\u3002","accident");
return true;
}
function updatePoisonGasCan(item,dt,world){
item.fuseTimer=Math.max(0,Number(item.fuseTimer??5)-Math.max(0,Number(dt||0)));
if(item.fuseTimer<=0) burstPoisonGasCan(item,world,"fuse");
return true;
}
function updateGas(item,dt,world){
item.gasAge=(item.gasAge||0)+dt;
const life=Math.max(1,item.gasLife||22);
item.gasConcentration=Math.max(0,1-item.gasAge/life);
item.r+=(Math.max(36,Number(item.gasExpansion||72))/life)*dt;
for(const fan of (world?.itemsOfType?.("fan")||[])){
const wind=global.TarinaiItemDynamicBallSystem?.fanWindAt?.(fan,item.x,item.y,{range:430});
if(wind){
const a=itemAngleFor(fan),f=82*wind.falloff*dt;
item.vx=(item.vx||0)+Math.cos(a)*f;
item.vy=(item.vy||0)+Math.sin(a)*f;
}
}
item.x+=(item.vx||0)*dt;
item.y+=(item.vy||0)*dt;
item.vx*=Math.pow(.62,dt);
item.vy*=Math.pow(.62,dt);
for(const t of world?.tarinai||[]){
if(!t||t.dead)continue;
if(distXY(item.x,item.y,t.x,t.y)<=item.r+(t.radius||18)) t.damage?.(.16*item.gasConcentration*dt,"\u6bd2\u30ac\u30b9",{source:item});
}
if(item.gasConcentration<=.01)item.amount=0;
return true;
}
function update(item,dt,world){
if(!item||item.dead)return false;
if(item.type==="oil")return updateOil(item,dt,world);
if(item.type==="bear_trap")return updateBearTrap(item,dt,world);
if(item.type==="leakage_device")return updateLeakage(item,dt,world);
if(item.type==="poison_gas_can")return updatePoisonGasCan(item,dt,world);
if(item.type==="poison_gas")return updateGas(item,dt,world);
return false;
}
global.TarinaiItemEnvironmentHazardSystem=Object.freeze({
update,
updatePoweredHazards,
enforceTrapHolds,
groundFrictionMultiplier,
igniteOil,
igniteOilNear,
spawnPoisonGas,
burstPoisonGasCan,
leakageRadius,
});
})(typeof window!=="undefined"?window:globalThis);

View file

@ -629,37 +629,50 @@ draw(ctx, t, lighting = null) {
const r = this.r || 60;
const tilt = Number(this.seesawTilt || 0) || 0;
ctx.save();
ctx.fillStyle = styleNight ? "rgba(81,68,57,0.96)" : "rgba(130,99,69,0.98)";
ctx.strokeStyle = styleNight ? "rgba(41,34,29,0.86)" : "rgba(78,55,37,0.82)";
ctx.lineWidth = 2.4;
const standGrad = ctx.createLinearGradient(-r * 0.30, -r * 0.05, r * 0.30, r * 0.55);
standGrad.addColorStop(0, styleNight ? "#667078" : "#a9b6bd");
standGrad.addColorStop(0.55, styleNight ? "#3f474d" : "#718089");
standGrad.addColorStop(1, styleNight ? "#2b3135" : "#4a555c");
ctx.fillStyle = standGrad;
ctx.strokeStyle = styleNight ? "rgba(22,25,28,0.94)" : "rgba(48,57,63,0.92)";
ctx.lineWidth = 2.6;
ctx.beginPath();
ctx.moveTo(-r * 0.22, r * 0.42);
ctx.lineTo(0, -r * 0.05);
ctx.lineTo(r * 0.22, r * 0.42);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.moveTo(-r * 0.34, r * 0.52);
ctx.lineTo(-r * 0.12, -r * 0.04);
ctx.quadraticCurveTo(0, -r * 0.20, r * 0.12, -r * 0.04);
ctx.lineTo(r * 0.34, r * 0.52);
ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#31383c" : "#526069";
roundedRect(ctx, -r * 0.52, r * 0.45, r * 1.04, r * 0.16, r * 0.07);
ctx.fill(); ctx.stroke();
ctx.save();
ctx.rotate(tilt);
const grad = ctx.createLinearGradient(0, -r * 0.14, 0, r * 0.14);
grad.addColorStop(0, styleNight ? "#c5895c" : "#f0ad6f");
grad.addColorStop(1, styleNight ? "#8d5d3f" : "#ba7447");
ctx.fillStyle = grad;
ctx.strokeStyle = styleNight ? "rgba(57,37,29,0.9)" : "rgba(104,59,35,0.85)";
roundedRect(ctx, -r * 1.08, -r * 0.13, r * 2.16, r * 0.26, r * 0.08);
const boardGrad = ctx.createLinearGradient(0, -r * 0.17, 0, r * 0.17);
boardGrad.addColorStop(0, styleNight ? "#c58a27" : "#ffc94f");
boardGrad.addColorStop(0.48, styleNight ? "#9e681c" : "#eaa52d");
boardGrad.addColorStop(1, styleNight ? "#774b17" : "#b96c1c");
ctx.fillStyle = boardGrad;
ctx.strokeStyle = styleNight ? "#4b2f12" : "#734315";
ctx.lineWidth = 3;
roundedRect(ctx, -r * 1.16, -r * 0.14, r * 2.32, r * 0.28, r * 0.10);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.28)";
roundedRect(ctx, -r * 1.05, -r * 0.095, r * 2.10, r * 0.055, r * 0.025);
ctx.fill();
for (const side of [-1, 1]) {
ctx.fillStyle = side < 0 ? "rgba(88,177,231,0.98)" : "rgba(239,111,116,0.98)";
roundedRect(ctx, side * r * 0.79 - r * 0.18, -r * 0.26, r * 0.36, r * 0.22, r * 0.07);
ctx.fillStyle = side < 0 ? (styleNight ? "#6a314a" : "#df5f8d") : (styleNight ? "#31577a" : "#5d9ee0");
ctx.strokeStyle = styleNight ? "#1f2428" : "#3c4650";
ctx.lineWidth = 2.2;
roundedRect(ctx, side * r * 0.78 - r * 0.22, -r * 0.22, r * 0.44, r * 0.24, r * 0.09);
ctx.fill(); ctx.stroke();
ctx.strokeStyle = "rgba(70,55,45,0.75)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(side * r * 0.90, -r * 0.22);
ctx.lineTo(side * r * 0.90, -r * 0.52);
ctx.stroke();
}
ctx.fillStyle = styleNight ? "#b8c0c4" : "#dce3e5";
ctx.strokeStyle = styleNight ? "#3a4145" : "#59656b";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, r * 0.13, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.arc(0, 0, r * 0.045, 0, Math.PI * 2); ctx.fillStyle = "#596168"; ctx.fill();
ctx.restore();
ctx.fillStyle = "rgba(70,58,48,0.9)";
ctx.beginPath(); ctx.arc(0, 0, r * 0.13, 0, Math.PI * 2); ctx.fill();
ctx.restore();
} else if (this.type === "circuit_board") {
const circuit = globalThis.TarinaiCircuitBoardSystem;
@ -823,33 +836,57 @@ draw(ctx, t, lighting = null) {
} else if (this.type === "stove") {
const r = this.r || 24;
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
const timeNow = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0);
ctx.save();
if (powered && itemShadowsEnabled()) {
ctx.shadowColor = "rgba(255, 120, 43, 0.34)";
ctx.shadowBlur = 9;
ctx.shadowColor = "rgba(255, 116, 36, 0.42)";
ctx.shadowBlur = 13;
}
ctx.fillStyle = styleNight ? "rgba(96, 72, 58, 0.96)" : "rgba(150, 104, 76, 0.96)";
ctx.strokeStyle = styleNight ? "rgba(49, 35, 29, 0.78)" : "rgba(92, 62, 45, 0.72)";
ctx.lineWidth = 2.2;
roundedRect(ctx, -r * 0.78, -r * 0.62, r * 1.56, r * 1.24, r * 0.20);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(42, 32, 28, 0.92)";
roundedRect(ctx, -r * 0.48, -r * 0.34, r * 0.96, r * 0.62, r * 0.14);
ctx.fill();
const iron = ctx.createLinearGradient(-r, -r, r, r);
iron.addColorStop(0, styleNight ? "#4a4d4e" : "#737778");
iron.addColorStop(0.45, styleNight ? "#292c2d" : "#484d4e");
iron.addColorStop(1, styleNight ? "#17191a" : "#292d2e");
ctx.fillStyle = iron;
ctx.strokeStyle = "rgba(18,20,20,0.94)";
ctx.lineWidth = 2.6;
roundedRect(ctx, -r * 0.22, -r * 1.34, r * 0.44, r * 0.72, r * 0.08);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#26292a" : "#3f4445";
roundedRect(ctx, -r * 0.84, -r * 0.70, r * 1.68, r * 1.38, r * 0.18);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#101213" : "#242829";
ctx.beginPath(); ctx.ellipse(0, -r * 0.68, r * 0.62, r * 0.18, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.strokeStyle = powered ? "rgba(255,136,54,0.76)" : "rgba(112,116,115,0.62)";
ctx.lineWidth = 1.8;
for (const rr of [0.44, 0.28]) { ctx.beginPath(); ctx.ellipse(0, -r * 0.68, r * rr, r * rr * 0.29, 0, 0, Math.PI * 2); ctx.stroke(); }
ctx.fillStyle = "#0d1010";
ctx.strokeStyle = styleNight ? "#6a6f70" : "#8b9293";
ctx.lineWidth = 2.4;
roundedRect(ctx, -r * 0.54, -r * 0.30, r * 1.08, r * 0.68, r * 0.10);
ctx.fill(); ctx.stroke();
if (powered) {
const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0));
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
const flicker = 0.88 + Math.sin(timeNow * 5.1 + (this.seed || 0)) * 0.08;
const fireGrad = ctx.createRadialGradient(0, r * 0.16, 1, 0, r * 0.10, r * 0.54);
fireGrad.addColorStop(0, "rgba(255,246,150,0.98)");
fireGrad.addColorStop(0.35, "rgba(255,169,53,0.96)");
fireGrad.addColorStop(1, "rgba(194,49,20,0.28)");
ctx.fillStyle = fireGrad;
ctx.beginPath();
ctx.moveTo(0, r * 0.16);
ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame);
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16);
ctx.fill();
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
ctx.beginPath();
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
ctx.fill();
ctx.moveTo(-r * 0.42, r * 0.30);
ctx.bezierCurveTo(-r * 0.48, r * 0.02, -r * 0.28, -r * 0.20 * flicker, -r * 0.10, r * 0.03);
ctx.bezierCurveTo(0, -r * 0.34 * flicker, r * 0.25, -r * 0.12, r * 0.16, r * 0.05);
ctx.bezierCurveTo(r * 0.42, -r * 0.04, r * 0.46, r * 0.17, r * 0.40, r * 0.30);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = "rgba(255,106,36,0.90)"; ctx.lineWidth = r * 0.11; ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(-r * 0.31, r * 0.25); ctx.lineTo(r * 0.26, r * 0.13); ctx.moveTo(-r * 0.25, r * 0.10); ctx.lineTo(r * 0.31, r * 0.26); ctx.stroke();
}
ctx.fillStyle = styleNight ? "#858b8d" : "#aeb5b6";
ctx.beginPath(); ctx.arc(r * 0.42, r * 0.02, r * 0.065, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = styleNight ? "#686e70" : "#9da4a5"; ctx.lineWidth = 2;
for (const sx of [-1, 1]) { ctx.beginPath(); ctx.moveTo(sx * r * 0.70, -r * 0.15); ctx.lineTo(sx * r * 0.70, r * 0.30); ctx.stroke(); }
ctx.fillStyle = styleNight ? "#1a1c1d" : "#303435";
roundedRect(ctx, -r * 0.68, r * 0.58, r * 0.28, r * 0.30, r * 0.05); ctx.fill();
roundedRect(ctx, r * 0.40, r * 0.58, r * 0.28, r * 0.30, r * 0.05); ctx.fill();
ctx.restore();
} else if (this.type === "ant_nest") {
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
@ -944,6 +981,319 @@ draw(ctx, t, lighting = null) {
ctx.stroke();
}
ctx.restore();
} else if (this.type === "oil") {
const r = this.r || 58;
const seed = Number(this.seed || 0);
ctx.save();
ctx.scale(1.42, 0.70);
const puddle = ctx.createRadialGradient(-r * 0.28, -r * 0.30, r * 0.08, 0, 0, r * 1.05);
if (this.oilBurning) {
puddle.addColorStop(0, "rgba(92,65,25,0.96)");
puddle.addColorStop(0.48, "rgba(54,42,23,0.96)");
puddle.addColorStop(1, "rgba(22,20,16,0.94)");
} else {
puddle.addColorStop(0, "rgba(118,94,42,0.94)");
puddle.addColorStop(0.42, "rgba(58,51,34,0.96)");
puddle.addColorStop(1, "rgba(20,22,19,0.94)");
}
ctx.fillStyle = puddle;
ctx.strokeStyle = "rgba(16,17,14,0.78)";
ctx.lineWidth = 2.2;
ctx.beginPath();
const points = 18;
for (let i = 0; i <= points; i++) {
const a = Math.PI * 2 * i / points;
const wobble = 0.86 + 0.10 * Math.sin(seed * 0.37 + i * 1.73) + 0.06 * Math.cos(seed * 0.19 + i * 2.41);
const px = Math.cos(a) * r * wobble;
const py = Math.sin(a) * r * wobble;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.globalAlpha *= 0.62;
ctx.strokeStyle = this.oilBurning ? "rgba(255,151,45,0.46)" : "rgba(142,184,149,0.44)";
ctx.lineWidth = 3.2;
ctx.beginPath();
ctx.ellipse(-r * 0.20, -r * 0.18, r * 0.50, r * 0.17, -0.18, 3.42, 5.82);
ctx.stroke();
ctx.strokeStyle = this.oilBurning ? "rgba(255,207,92,0.32)" : "rgba(180,135,214,0.32)";
ctx.lineWidth = 2.1;
ctx.beginPath();
ctx.ellipse(r * 0.22, r * 0.16, r * 0.34, r * 0.10, 0.24, 0.15, 2.82);
ctx.stroke();
ctx.restore();
} else if (this.type === "bear_trap") {
const r = this.r || 34;
const closed = Boolean(this.trapClosed || this.trapTargetId);
ctx.save();
ctx.rotate((this.angle || 0) + 0.02 * Math.sin(this.seed || 0));
if (itemShadowsEnabled()) {
ctx.shadowColor = "rgba(0,0,0,0.22)";
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 3;
}
// Chain and anchor ring.
ctx.strokeStyle = "rgba(72,66,61,0.95)";
ctx.lineWidth = 3.3;
for (let i = 0; i < 5; i++) {
const x = r * 0.78 + i * r * 0.28;
ctx.beginPath();
ctx.ellipse(x, r * 0.52 + Math.sin(i * 1.8) * 2, r * 0.16, r * 0.10, i % 2 ? 0.9 : -0.9, 0, Math.PI * 2);
ctx.stroke();
}
ctx.beginPath();
ctx.arc(r * 2.05, r * 0.52, r * 0.23, 0, Math.PI * 2);
ctx.stroke();
// Heavy base ring.
const metal = ctx.createLinearGradient(0, -r, 0, r);
metal.addColorStop(0, "#c6c8c6");
metal.addColorStop(0.48, "#757a78");
metal.addColorStop(1, "#3f4543");
ctx.fillStyle = "rgba(63,68,66,0.94)";
ctx.strokeStyle = "rgba(38,42,41,0.98)";
ctx.lineWidth = 5;
ctx.beginPath();
ctx.ellipse(0, 0, r * 0.88, r * 0.68, 0, 0, Math.PI * 2);
ctx.stroke();
// Side springs.
for (const sx of [-1, 1]) {
ctx.save();
ctx.translate(sx * r * 0.76, 0);
ctx.strokeStyle = "rgba(84,88,86,0.98)";
ctx.lineWidth = 3;
ctx.beginPath();
for (let i = 0; i <= 7; i++) {
const yy = -r * 0.30 + i * r * 0.086;
const xx = (i % 2 ? 1 : -1) * r * 0.09;
if (i === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
}
ctx.stroke();
ctx.restore();
}
const gap = closed ? r * 0.14 : r * 0.46;
// Two opposing toothed jaws.
for (const side of [-1, 1]) {
ctx.save();
ctx.translate(0, side * gap);
ctx.scale(1, side);
ctx.fillStyle = metal;
ctx.strokeStyle = "rgba(52,57,55,0.98)";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(-r * 0.80, 0);
ctx.quadraticCurveTo(0, -r * 0.36, r * 0.80, 0);
ctx.lineTo(r * 0.70, r * 0.14);
ctx.quadraticCurveTo(0, -r * 0.16, -r * 0.70, r * 0.14);
ctx.closePath();
ctx.fill();
ctx.stroke();
for (let i = -4; i <= 4; i++) {
const x = i * r * 0.16;
const toothH = r * (closed ? 0.18 : 0.25);
ctx.fillStyle = "#b8bbb8";
ctx.strokeStyle = "rgba(54,58,56,0.96)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(x - r * 0.055, -r * 0.03);
ctx.lineTo(x + r * 0.055, -r * 0.03);
ctx.lineTo(x, -toothH);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
if (!closed) {
ctx.fillStyle = "#737873";
ctx.strokeStyle = "#454a47";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(0, 0, r * 0.34, r * 0.24, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(220,224,218,0.26)";
ctx.beginPath();
ctx.ellipse(-r * 0.08, -r * 0.06, r * 0.18, r * 0.08, -0.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "leakage_device") {
const r = this.r || 34;
const powered = Boolean(this._signalDriven && this._signalOn);
ctx.save();
if (itemShadowsEnabled()) {
ctx.shadowColor = "rgba(0,0,0,0.24)";
ctx.shadowBlur = 5;
ctx.shadowOffsetY = 4;
}
const baseGrad = ctx.createLinearGradient(-r, -r, r, r);
baseGrad.addColorStop(0, "#58636a");
baseGrad.addColorStop(0.5, "#30383e");
baseGrad.addColorStop(1, "#1c2429");
ctx.fillStyle = baseGrad;
ctx.strokeStyle = "#151b1f";
ctx.lineWidth = 2.6;
ctx.beginPath();
ctx.roundRect(-r * 0.92, r * 0.18, r * 1.84, r * 0.72, r * 0.12);
ctx.fill();
ctx.stroke();
// Porcelain insulators.
for (const sx of [-1, 1]) {
ctx.save();
ctx.translate(sx * r * 0.52, -r * 0.18);
const porcelain = ctx.createLinearGradient(-r * 0.22, 0, r * 0.22, 0);
porcelain.addColorStop(0, "#8e9697");
porcelain.addColorStop(0.45, "#eef0e8");
porcelain.addColorStop(1, "#747d7e");
ctx.fillStyle = porcelain;
ctx.strokeStyle = "rgba(65,72,73,0.94)";
ctx.lineWidth = 1.6;
ctx.fillRect(-r * 0.13, -r * 0.58, r * 0.26, r * 0.70);
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.ellipse(0, -r * 0.50 + i * r * 0.17, r * 0.24, r * 0.075, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.fillStyle = "#9f713d";
ctx.fillRect(-r * 0.07, -r * 0.76, r * 0.14, r * 0.18);
ctx.restore();
}
// Copper transformer coil.
ctx.strokeStyle = powered ? "#ffc763" : "#b4773b";
ctx.lineWidth = 3.2;
for (let i = 0; i < 7; i++) {
const yy = -r * 0.35 + i * r * 0.10;
ctx.beginPath();
ctx.ellipse(0, yy, r * 0.27, r * 0.08, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = "rgba(241,194,62,0.95)";
ctx.strokeStyle = "rgba(80,61,18,0.95)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-r * 0.18, r * 0.46);
ctx.lineTo(0, r * 0.22);
ctx.lineTo(r * 0.18, r * 0.46);
ctx.closePath();
ctx.fill();
ctx.stroke();
if (powered) {
ctx.shadowColor = "rgba(112,220,255,0.78)";
ctx.shadowBlur = 10;
ctx.strokeStyle = "rgba(142,232,255,0.96)";
ctx.lineWidth = 2.2;
for (let arc = 0; arc < 3; arc++) {
const phase = t * 13 + (this.seed || 0) * 0.17 + arc * 1.9;
ctx.beginPath();
let x = -r * 0.48;
let y = -r * 0.73;
ctx.moveTo(x, y);
for (let j = 1; j <= 6; j++) {
x = -r * 0.48 + j * r * 0.16;
y = -r * 0.73 + Math.sin(phase + j * 2.1) * r * 0.16;
ctx.lineTo(x, y);
}
ctx.stroke();
}
}
ctx.restore();
} else if (this.type === "poison_gas_can") {
const r = this.r || 28;
const fuseRatio = clamp((this.fuseTimer ?? 5) / Math.max(0.1, this.fuseMax || 5), 0, 1);
ctx.save();
if (itemShadowsEnabled()) {
ctx.shadowColor = "rgba(0,0,0,0.26)";
ctx.shadowBlur = 5;
ctx.shadowOffsetY = 4;
}
const body = ctx.createLinearGradient(-r * 0.72, 0, r * 0.72, 0);
body.addColorStop(0, "#493552");
body.addColorStop(0.24, "#80588f");
body.addColorStop(0.54, "#9c72a7");
body.addColorStop(0.78, "#64446f");
body.addColorStop(1, "#382d41");
ctx.fillStyle = body;
ctx.strokeStyle = "rgba(49,36,56,0.98)";
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.roundRect(-r * 0.62, -r * 0.82, r * 1.24, r * 1.64, r * 0.28);
ctx.fill();
ctx.stroke();
// Reinforcing bands.
for (const yy of [-r * 0.44, r * 0.38]) {
ctx.fillStyle = "rgba(49,43,52,0.88)";
ctx.fillRect(-r * 0.67, yy, r * 1.34, r * 0.12);
ctx.fillStyle = "rgba(190,151,72,0.72)";
for (let i = -2; i <= 2; i++) ctx.fillRect(i * r * 0.25 - r * 0.05, yy, r * 0.10, r * 0.12);
}
// Valve, cap and pressure gauge.
ctx.fillStyle = "#6d7471";
ctx.strokeStyle = "#303633";
ctx.lineWidth = 2;
ctx.fillRect(-r * 0.18, -r * 1.02, r * 0.36, r * 0.23);
ctx.strokeRect(-r * 0.18, -r * 1.02, r * 0.36, r * 0.23);
ctx.beginPath();
ctx.arc(r * 0.35, -r * 0.91, r * 0.18, 0, Math.PI * 2);
ctx.fillStyle = "#d9d6c5";
ctx.fill();
ctx.stroke();
ctx.strokeStyle = fuseRatio < 0.28 && Math.sin(t * 20) > 0 ? "#ff4f5f" : "#6e2d37";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(r * 0.35, -r * 0.91);
ctx.lineTo(r * 0.35 + Math.cos(-2.5 + fuseRatio * 2.0) * r * 0.12, -r * 0.91 + Math.sin(-2.5 + fuseRatio * 2.0) * r * 0.12);
ctx.stroke();
// Corrosion and vent dots.
ctx.fillStyle = "rgba(66,42,31,0.54)";
for (let i = 0; i < 7; i++) {
const a = (this.seed || 0) * 0.21 + i * 1.7;
ctx.beginPath();
ctx.arc(Math.sin(a) * r * 0.43, Math.cos(a * 1.3) * r * 0.58, r * (0.035 + (i % 3) * 0.018), 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "rgba(225,205,118,0.88)";
ctx.beginPath();
ctx.moveTo(0, -r * 0.18);
ctx.lineTo(-r * 0.24, r * 0.18);
ctx.lineTo(r * 0.24, r * 0.18);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "rgba(73,45,82,0.95)";
ctx.beginPath();
ctx.arc(0, r * 0.04, r * 0.06, 0, Math.PI * 2);
ctx.fill();
const timerText = String(Math.max(0, Math.ceil(this.fuseTimer ?? 5)));
ctx.fillStyle = "rgba(255,249,230,0.98)";
ctx.strokeStyle = "rgba(53,31,61,0.98)";
ctx.lineWidth = Math.max(2.4, r * 0.09);
ctx.font = `900 ${Math.max(12, r * 0.68)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.strokeText(timerText, 0, r * 0.08);
ctx.fillText(timerText, 0, r * 0.08);
ctx.restore();
} else if (this.type === "poison_gas") {
const r = this.r || 96;
const c = Math.max(0, Math.min(1, this.gasConcentration ?? 1));
ctx.save();
ctx.globalAlpha *= 0.16 + 0.46 * c;
for (let i = 0; i < 7; i++) {
const a = (this.seed || 0) * 0.13 + i * 2.17;
const ox = Math.cos(a) * r * (0.16 + (i % 3) * 0.07);
const oy = Math.sin(a * 1.21) * r * (0.12 + (i % 2) * 0.07);
const rr = r * (0.48 + (i % 4) * 0.08);
const g = ctx.createRadialGradient(ox - rr * 0.18, oy - rr * 0.18, rr * 0.10, ox, oy, rr);
g.addColorStop(0, `rgba(198,112,226,${0.60 + c * 0.22})`);
g.addColorStop(0.54, `rgba(132,62,166,${0.34 + c * 0.28})`);
g.addColorStop(1, "rgba(83,39,112,0.02)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(ox, oy, rr, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "fan") {
const r = this.r || 26;
const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this);
@ -1163,34 +1513,73 @@ draw(ctx, t, lighting = null) {
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;
const timerText = String(Math.max(0, Math.ceil(this.fuseTimer ?? 5)));
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
ctx.fillStyle = flash > 0.65 ? "#ffed77" : (flameMode ? "#d8241f" : "#5a4744");
ctx.strokeStyle = flameMode ? "rgba(96,18,18,0.88)" : "rgba(35,30,29,0.88)";
ctx.lineWidth = 2;
roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
ctx.fill();
ctx.stroke();
ctx.fillStyle = flameMode ? "#ff6b32" : "#897065";
ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
ctx.strokeStyle = "rgba(48,38,31,0.86)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(0, -this.r * 0.88);
ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
ctx.stroke();
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : (flameMode ? "rgba(255,80,40,0.78)" : "rgba(188,148,88,0.62)");
ctx.beginPath();
ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,246,214,0.92)";
ctx.strokeStyle = flameMode ? "rgba(84,18,14,0.92)" : "rgba(24,22,21,0.92)";
ctx.save();
ctx.rotate(-0.18 + Math.sin(this.seed) * 0.10);
const bodyGrad = ctx.createLinearGradient(-this.r * 0.74, 0, this.r * 0.74, 0);
if (flameMode) {
bodyGrad.addColorStop(0, flash > 0.72 ? "#ffe36c" : "#6c1516");
bodyGrad.addColorStop(0.28, "#d52c25");
bodyGrad.addColorStop(0.56, "#f2522d");
bodyGrad.addColorStop(1, "#7f1817");
} else {
bodyGrad.addColorStop(0, flash > 0.72 ? "#ffef91" : "#5e221f");
bodyGrad.addColorStop(0.28, "#a73a30");
bodyGrad.addColorStop(0.56, "#c74b3c");
bodyGrad.addColorStop(1, "#67241f");
}
ctx.fillStyle = bodyGrad;
ctx.strokeStyle = flameMode ? "rgba(80,16,14,0.96)" : "rgba(66,25,22,0.96)";
ctx.lineWidth = 2.6;
ctx.font = `bold ${Math.max(10, this.r * 0.72)}px ui-rounded, sans-serif`;
roundedRect(ctx, -this.r * 0.66, -this.r * 0.88, this.r * 1.32, this.r * 1.76, this.r * 0.16);
ctx.fill(); ctx.stroke();
ctx.fillStyle = flameMode ? "#7f1717" : "#6d2822";
for (const sy of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(-this.r * 0.58, sy * this.r * 0.80);
for (let i = 0; i <= 6; i++) {
const xx = -this.r * 0.58 + i * this.r * 0.193;
const yy = sy * this.r * (0.80 + (i % 2 ? 0.11 : 0.02));
ctx.lineTo(xx, yy);
}
ctx.lineTo(this.r * 0.58, sy * this.r * 0.80);
ctx.closePath(); ctx.fill();
}
ctx.fillStyle = flameMode ? "rgba(255,190,48,0.94)" : "rgba(245,222,180,0.90)";
roundedRect(ctx, -this.r * 0.52, -this.r * 0.38, this.r * 1.04, this.r * 0.76, this.r * 0.08);
ctx.fill();
if (flameMode) {
ctx.fillStyle = "#d43a22";
ctx.beginPath();
ctx.moveTo(0, this.r * 0.24);
ctx.bezierCurveTo(-this.r * 0.24, this.r * 0.03, -this.r * 0.08, -this.r * 0.19, 0, -this.r * 0.29);
ctx.bezierCurveTo(this.r * 0.28, -this.r * 0.06, this.r * 0.25, this.r * 0.12, 0, this.r * 0.24);
ctx.fill();
} else {
ctx.strokeStyle = "#8a342b"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(-this.r * 0.28, 0); ctx.lineTo(this.r * 0.28, 0); ctx.moveTo(0, -this.r * 0.22); ctx.lineTo(0, this.r * 0.22); ctx.stroke();
}
ctx.strokeStyle = "rgba(63,52,37,0.98)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(this.r * 0.10, -this.r * 0.90);
ctx.bezierCurveTo(this.r * 0.30, -this.r * 1.18, this.r * 0.58, -this.r * 1.34, this.r * 0.88, -this.r * 1.25);
ctx.stroke();
ctx.strokeStyle = "rgba(196,151,73,0.82)"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(this.r * 0.13, -this.r * 0.92); ctx.bezierCurveTo(this.r * 0.34, -this.r * 1.16, this.r * 0.60, -this.r * 1.27, this.r * 0.86, -this.r * 1.23); ctx.stroke();
ctx.fillStyle = flash > 0.1 ? "rgba(255,235,104,0.98)" : "rgba(240,104,46,0.88)";
ctx.shadowColor = flash > 0.1 ? "rgba(255,186,44,0.85)" : "transparent";
ctx.shadowBlur = flash > 0.1 ? 8 : 0;
ctx.beginPath(); ctx.arc(this.r * 0.90, -this.r * 1.24, 3 + flash * 2.3, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(255,250,230,0.98)";
ctx.strokeStyle = flameMode ? "rgba(92,20,15,0.98)" : "rgba(65,25,22,0.98)";
ctx.lineWidth = 3;
ctx.font = `900 ${Math.max(11, this.r * 0.74)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.strokeText(timerText, 0, this.r * 0.02);
ctx.fillText(timerText, 0, this.r * 0.02);
ctx.restore();
} 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";
@ -1202,9 +1591,9 @@ draw(ctx, t, lighting = null) {
const open = gate && this.gateOpen;
ctx.save();
ctx.rotate(angle);
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 wood = bounce ? (styleNight ? "#9b4f76" : "#ef76aa") : oneWay ? (styleNight ? "#6b5a86" : "#9b78c8") : gate ? (styleNight ? "#5d675f" : "#6f8b68") : (styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"));
const edge = bounce ? (styleNight ? "#52253d" : "#a93f70") : oneWay ? (styleNight ? "#352943" : "#593a80") : gate ? (styleNight ? "#334038" : "#375737") : (styleNight ? "#4d3d2f" : "#5f3f25");
const hi = bounce ? (styleNight ? "rgba(255,213,235,0.24)" : "rgba(255,238,248,0.58)") : 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;

View file

@ -20,6 +20,10 @@ const TOOL_DEFINITIONS = Object.freeze({
wire: { id: "wire", label: "\u96fb\u7dda", placeable: false, scalable: false, iconText: "\u2301", tooltip: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3092\u96fb\u6c17\u3067\u4f1d\u3048\u308b\u3002\u89e6\u308b\u3068\u611f\u96fb\u3002" },
insulated_wire: { id: "insulated_wire", label: "\u7d76\u7e01\u96fb\u7dda", placeable: false, scalable: false, iconText: "\u2301", tooltip: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3092\u96fb\u6c17\u3067\u4f1d\u3048\u308b\u3002\u611f\u96fb\u3057\u306a\u3044\u3002" },
shoot: { id: "shoot", label: "\u5c04\u6483", placeable: false, scalable: false, icon: "assets/ui/tool_shoot.png", tooltip: "\u9283\u3067\u653b\u6483\u3059\u308b\u3002" },
oil: { id: "oil", itemType: "oil", label: "\u6cb9", placeable: true, scalable: true, radius: 58, amount: 100, iconText: "\u6cb9", tooltip: "\u6469\u64e6\u3092\u5927\u304d\u304f\u4e0b\u3052\u308b\u3002\u706b\u3084\u7206\u767a\u3067\u5f15\u706b\u3057\u3001\u71c3\u3048\u5c3d\u304d\u308b\u3068\u6d88\u3048\u308b\u3002" },
bear_trap: { id: "bear_trap", itemType: "bear_trap", label: "\u30c8\u30e9\u30d0\u30b5\u30df", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "\u7f60", tooltip: "\u52d5\u304f\u3082\u306e\u3092\u6355\u3089\u3048\u3066\u3057\u3070\u3089\u304f\u96e2\u3055\u306a\u3044\u3002" },
leakage_device: { id: "leakage_device", itemType: "leakage_device", label: "\u6f0f\u96fb\u5668", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "\u6f0f", tooltip: "\u901a\u96fb\u4e2d\u3001\u5e83\u7bc4\u56f2\u306b\u96fb\u6483\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
poison_gas_can: { id: "poison_gas_can", itemType: "poison_gas_can", label: "\u6bd2\u30ac\u30b9\u7f36", placeable: true, scalable: true, radius: 28, amount: 999, iconText: "\u6bd2", tooltip: "\u6bd2\u30ac\u30b9\u3092\u653e\u51fa\u3059\u308b\u3002" },
robot_cleaner: { id: "robot_cleaner", itemType: "robot_cleaner", label: "\u30ed\u30dc\u6383\u9664\u6a5f", placeable: true, scalable: false, radius: 22, amount: 999, iconText: "\u25c9", tooltip: "\u30b4\u30df\u3084\u6c5a\u308c\u3092\u7247\u4ed8\u3051\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
@ -78,6 +82,7 @@ const TOOL_DEFINITIONS = Object.freeze({
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
toilet: { id: "toilet", itemType: "toilet", label: "\u30c8\u30a4\u30ec\u306e\u7802", placeable: true, scalable: true, radius: 48, amount: 999, iconText: "\u25a3", tooltip: "\u4e0a\u306b\u6392\u6cc4\u3055\u308c\u305f\u305a\u3093\u3061\u304c\u76f4\u3061\u306b\u6d88\u6ec5\u3059\u308b\u3002" },
rain_shelter: { id: "rain_shelter", itemType: "rain_shelter", label: "\u30d1\u30e9\u30bd\u30eb", placeable: true, scalable: true, radius: 50, amount: 999, iconText: "\u26f1", tooltip: "\u96e8\u3068\u65e5\u5dee\u3057\u3092\u9632\u304e\u3001\u4f53\u611f\u6c17\u6e29\u3092\u7a4f\u3084\u304b\u306b\u3059\u308b\u3002" },
bell: { id: "bell", label: "呼び鈴", placeable: false, scalable: true, iconText: "🔔", tooltip: "周りのたりないを集める" },
first_aid: { id: "first_aid", itemType: "first_aid", label: "\u6551\u6025\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, iconText: "\u271a", tooltip: "\u98df\u3079\u308b\u3068HP\u3092\u5373\u6642\u56de\u5fa9\u3059\u308b\u3002" },
sedative: { id: "sedative", itemType: "sedative", label: "\u93ae\u9759\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, iconText: "\u25cc", tooltip: "\u98df\u3079\u308b\u3068\u6050\u6016\u3084\u3051\u3093\u304b\u6c17\u5206\u3092\u843d\u3061\u7740\u304b\u305b\u308b\u3002" },
});

View file

@ -10,13 +10,13 @@
kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]),
freeStackingMovable: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi", "water", "zunda_juice", "mercury", "genkotsu", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "ant_corpse"]),
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", "rain_shelter", "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", "seesaw", "circuit_board"]),
draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "rain_shelter", "signboard", "duplicator", "robot_cleaner", "oil", "bear_trap", "leakage_device", "poison_gas_can", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "seesaw", "circuit_board"]),
});
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 ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board"]);
const ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board", "leakage_device"]);
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", "circuit_board"]);
const PHYSICS_CATEGORY_TOOL_IDS = Object.freeze(PHYSICS_TOOL_IDS.filter(id => !ELECTRIC_TOOL_IDS.includes(id)));
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan", "circuit_board"]));
@ -27,13 +27,13 @@
});
const TOOL_CATEGORIES = Object.freeze([
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] },
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "clear_tool", "water_hose", "bell"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["food", "sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] },
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "toilet", "rain_shelter", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "seesaw", "signboard"] },
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_CATEGORY_TOOL_IDS },
{ id: "electric", label: "\u96fb\u6c17", themeClass: "tool-category-electric", order: 46, toolIds: ELECTRIC_TOOL_IDS },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "oil", "bear_trap", "poison_gas_can", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] },
]);
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);

View file

@ -41,7 +41,7 @@ function initializeItemTypeState(item, type, x, y) {
if (type === "water" || type === "zunda_juice") item.roles.drink = true;
if (["sweet", "water", "zunda_juice", "slave_chain", "golden_crown"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true;
if (type === "bed" || type === "nest_box" || type === "pipe") item.roles.sleepPlace = true;
if (["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true;
if (["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "splat", "oil", "bear_trap", "leakage_device", "poison_gas_can", "poison_gas"].includes(type)) item.roles.danger = true;
if (type === "grass") item.roles.grassMaterial = true;
if (type === "grass") {
item.grassStage = Math.floor(rand(0, 2.999));
@ -305,6 +305,12 @@ function initializeItemTypeState(item, type, x, y) {
item.amount = 999;
}
if (type === "oil") { item.r = 58; item.amount = 100; item.oilBurning = false; item.oilBurnTimer = 0; item.nextOilFireAt = 0; item.deletable = true; }
if (type === "bear_trap") { item.r = 34; item.amount = 999; item.trapTargetId = ""; item.trapTargetRef = null; item.trapTriggeredAt = 0; item.trapUntil = 0; item.trapClosed = false; item.trapDamageableTarget = false; item.trapHoldDuration = 0; item.nextTrapDamageAt = 0; item.deletable = true; }
if (type === "leakage_device") { item.r = 34; item.amount = 999; item.nextLeakagePulseAt = 0; item.deletable = true; }
if (type === "poison_gas_can") { item.r = 28; item.amount = 999; item.fuseMax = 5; item.fuseTimer = 5; item._gasReleased = false; item.deletable = true; }
if (type === "poison_gas") { item.r = 96; item.amount = 100; item.gasConcentration = 1; item.gasAge = 0; item.gasLife = 22; item.gasExpansion = 72; item.vx = 0; item.vy = 0; item.deletable = false; }
if (globalThis.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
globalThis.TarinaiPhysicsBodySystem.invalidateItem(item, "item-initialized");
}

View file

@ -5,10 +5,10 @@
// helpers and the item scheduler both read this single policy instead of
// duplicating item-type timing rules.
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "robot_cleaner"]);
const REALTIME_ITEM_TYPES = new Set(["oil", "bear_trap", "leakage_device", "poison_gas_can", "poison_gas", "ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "robot_cleaner"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = Object.freeze([
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "pushpin", "oshibyo",
"oil", "bear_trap", "leakage_device", "poison_gas_can", "poison_gas", "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", "first_aid", "sedative", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "pipe", "toilet", "rain_shelter", "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",
@ -38,9 +38,11 @@
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;
// Fan wind and bear-trap contact are continuous interactions. Scheduling
// them as idle items can turn movement/contact into periodic samples and
// let a moving object pass completely through between checks.
if (item.type === "fan" || item.type === "bear_trap") return true;
if (item.type === "poison_gas_can") return Number(item.fuseTimer ?? 5) > 0;
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");
@ -50,6 +52,7 @@
function idleRealtimeInterval(item) {
if (!item) return Infinity;
if (item.type === "oil" || item.type === "bear_trap" || item.type === "leakage_device" || item.type === "poison_gas_can" || item.type === "poison_gas") return 0.18;
if (item.type === "ball" || item.type === "balloon") return 0.24;
if (item.type === "fan") return 0.60;
if (item.type === "genkotsu") return 0.45;
@ -89,8 +92,8 @@
if (item.type === "grass") return Infinity;
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 (item.type === "trace" || item.type === "splat") return adjustedInterval(2.8);
if (item.type === "ant_corpse") return adjustedInterval(5.5);
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(item.type)) return adjustedInterval(5.0);
if (isServingFoodType(item.type)) return adjustedInterval(5.0);
return Infinity;

View file

@ -108,6 +108,20 @@
return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().scheduledItemBudget || 30));
}
function beginTouchedFrame(worldRef, state) {
if (!state.touchedFrame) state.touchedFrame = [];
else state.touchedFrame.length = 0;
if (!state.touchedSet) state.touchedSet = new Set();
else state.touchedSet.clear();
worldRef._itemUpdateTouched = state.touchedFrame;
}
function recordTouched(state, item) {
if (!item || item.dead || state.touchedSet.has(item)) return;
state.touchedSet.add(item);
state.touchedFrame.push(item);
}
function runRealtime(worldRef, state, dt) {
let ran = 0;
const now = worldRef.time || 0;
@ -123,6 +137,7 @@
}
if (interval <= 0) {
global.TarinaiItemRuntime.updateOne(item, dt, worldRef);
recordTouched(state, item);
item._schedulerLastAt = now;
ran += 1;
const nextInterval = itemUpdateInterval(item);
@ -155,6 +170,7 @@
}
const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval)));
global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef);
recordTouched(state, item);
item._schedulerLastAt = now;
ran += 1;
dueRuns += 1;
@ -197,6 +213,7 @@
const end = global.TarinaiPerf.begin("update.items");
try {
const state = ensure(worldRef);
beginTouchedFrame(worldRef, state);
const realtimeEnd = global.TarinaiPerf.begin("update.items.realtime");
let realtimeRan = runRealtime(worldRef, state, dt);
let ran = realtimeRan;
@ -207,9 +224,11 @@
ran += dueRan;
if (dueEnd) dueEnd();
global.TarinaiSignalSystem?.updateWorld?.(worldRef, dt);
global.TarinaiItemEnvironmentHazardSystem?.updatePoweredHazards?.(worldRef, dt);
const physicsEnd = global.TarinaiPerf.begin("update.physicsWorld");
const physicsBudget = physicsBudgetFor(worldRef);
const physicsWorld = global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget);
const trapHeld = global.TarinaiItemEnvironmentHazardSystem?.enforceTrapHolds?.(worldRef) || 0;
const mechanicalWorld = physicsWorld.mechanical || null;
const constraintWorld = physicsWorld.constraintRan || 0;
const postConstraintPairs = physicsWorld.postConstraintPairs || 0;
@ -232,6 +251,7 @@
physicsBudget,
poisonContactBudget,
poisonContactRemoved,
trapHeld,
physics: physicsWorld || mechanicalWorld,
constraints: constraintWorld,
postConstraintPairs,
@ -245,7 +265,7 @@
}
function trackedDynamicItems(worldRef) {
return worldRef?._itemUpdateRealtime || [];
return worldRef?._itemUpdateTouched || [];
}
global.TarinaiItemUpdateScheduler = Object.freeze({ run, trackedDynamicItems, wakeItem });

View file

@ -100,6 +100,7 @@ function loop() {
window.TarinaiPerf.beginFrame();
const endUpdate = window.TarinaiPerf.begin("update.total");
world.update(dt);
if (typeof tickColonyHistorySampling === "function") tickColonyHistorySampling();
if (endUpdate) endUpdate();
const endRender = window.TarinaiPerf.begin("render.total");
render();
@ -146,3 +147,14 @@ if ("serviceWorker" in navigator) {
navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined);
});
}
// Expose the active build on the root element so stale-cache reports can be verified immediately.
(function markActiveBuild() {
const apply = () => {
if (!document?.documentElement) return;
document.documentElement.dataset.tarinaiBuild = String(window.TARINAI_APP?.build || window.TARINAI_VERSION || "unknown");
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", apply, { once: true });
else apply();
})();

View file

@ -636,6 +636,78 @@
return applied;
}
function applyExternalImpulse(item, contactX, contactY, fx, fy, worldRef = null, scale = 1) {
if (!item || item.dead || !isMechanicalType(item)) return false;
const sx = num(fx) * num(scale, 1);
const sy = num(fy) * num(scale, 1);
let changed = applyImpulse(item, contactX, contactY, sx, sy, item.type === "rotator" ? 1.05 : 0.72);
if (item.type === "rotator" || item.type === "reciprocator") {
const translationGain = item.type === "rotator" ? 0.28 : 0.18;
item.externalVx = clamp(num(item.externalVx) + sx * translationGain, -560, 560);
item.externalVy = clamp(num(item.externalVy) + sy * translationGain, -560, 560);
wakeItem(item, "external-impulse");
changed = true;
}
if (changed) {
worldRef?.markSpatialDirty?.("mechanical-external-impulse");
if (worldRef) worldRef.drawListDirty = true;
}
return changed;
}
function integrateExternalTranslation(item, dt = 0.016, worldRef = null) {
if (!item || item.dead || !isMechanicalType(item) || (item.type !== "rotator" && item.type !== "reciprocator")) return false;
const step = Math.max(0.001, Math.min(0.05, num(dt, 0.016)));
let vx = num(item.externalVx);
let vy = num(item.externalVy);
if (Math.hypot(vx, vy) < 0.05) { item.externalVx = 0; item.externalVy = 0; return false; }
const dx = vx * step;
const dy = vy * step;
if (item.type === "reciprocator") {
resetAnchor(item);
const rail = bodyRail(item);
if (rail) {
rail.anchorX = num(rail.anchorX, item.x) + dx;
rail.anchorY = num(rail.anchorY, item.y) + dy;
pset(item, "railAnchorX", rail.anchorX, "external-translation");
pset(item, "railAnchorY", rail.anchorY, "external-translation");
}
const pos = positionFromPhase(item);
item.x = pos.x;
item.y = pos.y;
} else {
item.x = num(item.x) + dx;
item.y = num(item.y) + dy;
if (item.physicsBody?.pose) { item.physicsBody.pose.x = item.x; item.physicsBody.pose.y = item.y; }
}
const pad = Number(global.CONFIG?.worldPadding || 16) || 16;
const rr = Math.max(18, Math.min(120, reach(item) * 0.24));
if (worldRef) {
item.x = clamp(item.x, pad + rr, num(worldRef.w, 1000) - pad - rr);
item.y = clamp(item.y, rr, num(worldRef.h, 800) - rr);
if (item.type === "reciprocator") {
const rail = bodyRail(item);
const pos = positionFromPhase(item);
const fixX = item.x - pos.x;
const fixY = item.y - pos.y;
if (rail && (Math.abs(fixX) > 0.001 || Math.abs(fixY) > 0.001)) {
rail.anchorX = num(rail.anchorX) + fixX;
rail.anchorY = num(rail.anchorY) + fixY;
pset(item, "railAnchorX", rail.anchorX, "external-boundary");
pset(item, "railAnchorY", rail.anchorY, "external-boundary");
}
}
}
const damp = Math.pow(0.12, step);
item.externalVx = vx * damp;
item.externalVy = vy * damp;
if (Math.abs(item.externalVx) < 0.05) item.externalVx = 0;
if (Math.abs(item.externalVy) < 0.05) item.externalVy = 0;
invalidateGeometry(item);
commitBody(item);
return true;
}
function applyRailCorrection(item, nx, ny, overlap, scale = 1) {
if (!item || item.dead || item.type !== "reciprocator" || item.playerHeld || item._heldByPlayer) return false;
resetAnchor(item);
@ -752,8 +824,31 @@
}
function isPassiveDriveBlocker(other) {
if (!other || other.dead) return false;
if (other.type !== "rotator" && other.type !== "reciprocator") return false;
return !isPowered(other);
}
function hasPassiveDriveContactAhead(item, worldRef) {
if (!item || item.dead || item.type !== "reciprocator" || !worldRef) return false;
const source = worldRef.nearbyNonGrassItems?.(item.x, item.y, reach(item) + 110, true) || worldRef.items || [];
const a = axis(item);
const dir = Math.sign(ps(item, "railDir", 1)) || 1;
const driveX = a.x * dir, driveY = a.y * dir;
for (const other of source) {
if (!other || other === item || !isPassiveDriveBlocker(other)) continue;
const contact = strongestRectContact(obstacleRects(item), obstacleRects(other), 0.85);
if (!contact) continue;
const ahead = driveX * num(contact.info.nx) + driveY * num(contact.info.ny);
if (ahead >= 0.12) return true;
}
return false;
}
function noteReciprocatorBlocked(item, dt = 0.016, worldRef = null, reason = "obstacle") {
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false;
if (reason === "rail-stall-blocked" && hasPassiveDriveContactAhead(item, worldRef)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
const step = Math.max(0.012, Math.min(0.08, num(dt, 0.016)));
if ((item._reciprocatorBlockedAt || -999) + 0.18 < now) item._reciprocatorBlockedTimer = 0;
@ -998,8 +1093,8 @@
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");
if (a.type === "reciprocator" && !isPassiveDriveBlocker(b)) reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
if (b.type === "reciprocator" && !isPassiveDriveBlocker(a)) 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
@ -1117,6 +1212,7 @@
return runMechanicalStep(item, () => {
item.amount = 999;
if (item.playerHeld || item._heldByPlayer) return false;
const translated = integrateExternalTranslation(item, dt, worldRef);
const powered = isPowered(item);
const passiveSpeed = ps(item, "spin", 0);
const speed = (powered ? ps(item, "motorSpeed", 0) : 0) + passiveSpeed;
@ -1125,7 +1221,7 @@
const ext = extent(item);
item.r = Math.max(item.r || 64, Math.min(460, ext));
const skipInteractions = opts.skipInteractions === true || opts.centralStep === true;
if (!speed || !Number.isFinite(speed)) return (!skipInteractions && hasInteractionCandidates(item, worldRef, ext + 90)) ? resolveInteractions(item, dt, worldRef) : false;
if (!speed || !Number.isFinite(speed)) return translated || ((!skipInteractions && hasInteractionCandidates(item, worldRef, ext + 90)) ? resolveInteractions(item, dt, worldRef) : false);
const totalDelta = speed * dt;
if (skipInteractions || !hasInteractionCandidates(item, worldRef, ext + 100)) {
item.prevAngle = num(item.angle);
@ -1223,6 +1319,7 @@
item.amount = 999;
resetAnchor(item);
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
const translated = integrateExternalTranslation(item, dt, worldRef);
const powered = isPowered(item);
const prevX = num(item.x, ps(item, "railAnchorX", 0)), prevY = num(item.y, ps(item, "railAnchorY", 0));
item.prevX = prevX; item.prevY = prevY;
@ -1259,7 +1356,7 @@
if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("reciprocator-motion"); }
return true;
}
return false;
return translated;
});
}
@ -1865,6 +1962,7 @@
updateReciprocator,
updatePoisonBlock,
applyPokeImpulse,
applyExternalImpulse,
hitTest,
invalidateGeometry,
wakeItem,

629
js/memorial_bell_system.js Normal file
View file

@ -0,0 +1,629 @@
"use strict";
(function (global) {
const BUILD_TEXT = "親しいたりないの墓を作っている。";
const MOURN_TEXT = "親しいたりないの墓が壊されて泣いている。";
const BELL_TEXT = "呼び鈴の鳴った場所に集まっている。";
const GRAVE_VISIT_APPROACH_TEXT = "親しいたりないの墓参りに向かっている。";
const GRAVE_VISIT_TEXT = "親しいたりないの墓参りをしている。";
const BELL_RANGES = Object.freeze({ small: 120, medium: 210, large: 350 });
const BELL_DURATIONS = Object.freeze({ small: 12, medium: 18, large: 26 });
function familyKeyOf(t) {
return String(t?.familyKey || t?.id || "");
}
function ensureRequests(worldRef) {
if (!worldRef) return [];
if (!Array.isArray(worldRef.memorialRequests)) worldRef.memorialRequests = [];
return worldRef.memorialRequests;
}
function requestById(worldRef, id) {
return ensureRequests(worldRef).find(req => req && req.id === id) || null;
}
function liveByFamilyKey(worldRef, key) {
if (!worldRef || !key) return null;
return (worldRef.tarinai || []).find(t => t && !t.dead && familyKeyOf(t) === key) || null;
}
function activeGraveFor(worldRef, deceasedFamilyKey) {
if (!worldRef || !deceasedFamilyKey) return null;
return (worldRef.items || []).find(item => item && !item.dead && item.type === "grave" && item.memorialFamilyKey === deceasedFamilyKey) || null;
}
function relatedGravesFor(worldRef, tarinai) {
if (!worldRef || !tarinai) return [];
const key = familyKeyOf(tarinai);
if (!key) return [];
return (worldRef.items || []).filter(item => item && !item.dead && item.type === "grave"
&& Array.isArray(item.memorialEligibleFamilyKeys) && item.memorialEligibleFamilyKeys.includes(key));
}
function graveById(worldRef, id) {
if (!worldRef || !id) return null;
const item = worldRef.itemById?.(id) || (worldRef.items || []).find(entry => entry?.id === id) || null;
return item && !item.dead && item.type === "grave" ? item : null;
}
function calmEnoughForGraveVisit(t) {
if (!t || t.dead || isCritical(t)) return false;
if (!["idle", "wander"].includes(String(t.state || "idle"))) return false;
if ((t.hunger || 0) >= 62 || (t.energy || 100) <= 38 || (t.stress || 0) >= 72) return false;
const needValues = ["food", "sleep", "health", "safety", "social", "fulfill"]
.map(key => Number(t.needRaw?.[key] ?? t.needs?.[key] ?? 0) || 0);
return Math.max(0, ...needValues) < 58;
}
function clearGraveVisit(t, reason = "") {
if (!t) return;
const wasVisiting = t.state === "visit_grave";
t.graveVisitTargetId = "";
t.graveVisitUntil = 0;
t.graveVisitPhase = "";
t.graveVisitReliefApplied = false;
t.nextGraveVisitCheckAt = Math.max(Number(t.nextGraveVisitCheckAt || 0), Number(t.world?.time || 0) + 28);
if (wasVisiting) {
t.target = null;
t.goIdle?.(reason || "墓参りを終えた。");
}
}
function graveVisitHasUrgentNeed(t) {
if (!t) return true;
if ((t.hunger || 0) >= 78 || (t.energy || 100) <= 24 || (t.stress || 0) >= 84) return true;
const needValues = ["food", "sleep", "health", "safety"]
.map(key => Number(t.needRaw?.[key] ?? t.needs?.[key] ?? 0) || 0);
return Math.max(0, ...needValues) >= 76;
}
function maintainGraveVisit(t) {
if (!t || !t.graveVisitTargetId) return false;
if (t.state !== "visit_grave") { clearGraveVisit(t); return false; }
const worldRef = t.world;
const grave = graveById(worldRef, t.graveVisitTargetId);
if (!grave || isCritical(t) || graveVisitHasUrgentNeed(t)) { clearGraveVisit(t, grave ? "別の用事を優先した。" : "墓がなくなった。"); return false; }
const now = Number(worldRef?.time || 0) || 0;
if (t.graveVisitPhase === "visiting") {
if (now >= Number(t.graveVisitUntil || 0)) { clearGraveVisit(t, "墓参りを終えた。"); return false; }
const mournText = `${grave.memorialName || "親しいたりない"}を悼んでいる。`;
setSpecialBehavior(t, "visit_grave", mournText, grave);
return true;
}
const approachText = `${grave.memorialName || "親しいたりない"}の墓参りに向かっている。`;
setSpecialBehavior(t, "visit_grave", approachText, grave);
return true;
}
function maybeStartGraveVisit(t) {
const worldRef = t?.world;
if (!worldRef || !calmEnoughForGraveVisit(t)) return false;
const now = Number(worldRef.time || 0) || 0;
if (now < Number(t.nextGraveVisitCheckAt || 0)) return false;
t.nextGraveVisitCheckAt = now + 10;
const chance = 0.045; // checked at most once per 10 seconds while otherwise idle
const hit = typeof global.deterministicChance === "function"
? global.deterministicChance(worldRef, "idle-grave-visit", chance, t, Math.floor(now / 10))
: Math.random() < chance;
if (!hit) return false;
const graves = relatedGravesFor(worldRef, t);
if (!graves.length) return false;
graves.sort((a, b) => Math.hypot(a.x - t.x, a.y - t.y) - Math.hypot(b.x - t.x, b.y - t.y));
const shortlist = graves.slice(0, Math.min(3, graves.length));
const unit = typeof global.stableUnit === "function" ? global.stableUnit(familyKeyOf(t), `grave-visit-${Math.floor(now / 10)}`) : Math.random();
const grave = shortlist[Math.min(shortlist.length - 1, Math.floor(unit * shortlist.length))] || shortlist[0];
if (!grave) return false;
t.graveVisitTargetId = grave.id || "";
t.graveVisitPhase = "approach";
t.graveVisitUntil = 0;
t.graveVisitReliefApplied = false;
setSpecialBehavior(t, "visit_grave", `${grave.memorialName || "親しいたりない"}の墓参りに向かっている。`, grave);
return true;
}
function relationAffinity(a, b) {
if (!a || !b) return -Infinity;
const aToB = Number(a.relationships?.[b.id]?.affinity);
const bToA = Number(b.relationships?.[a.id]?.affinity);
return Math.max(Number.isFinite(aToB) ? aToB : -Infinity, Number.isFinite(bToA) ? bToA : -Infinity);
}
function eligibleFamilyKeysAtDeath(worldRef, dead) {
const related = new Set([...(dead?.parents || []), ...(dead?.children || [])].filter(Boolean));
const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14;
for (const other of worldRef?.tarinai || []) {
if (!other || other.dead || other === dead) continue;
const key = familyKeyOf(other);
if (!key) continue;
if (related.has(key) || relationAffinity(dead, other) >= threshold) related.add(key);
}
related.delete(familyKeyOf(dead));
return Array.from(related).filter(key => liveByFamilyKey(worldRef, key));
}
const MEMORIAL_BUILD_CHANCE = 0.35;
const GRAVE_BREAK_FORCE = 720;
function createGraveFromRequest(worldRef, req, builderFamilyKey = "", reason = "memorial-built") {
if (!worldRef || !req || activeGraveFor(worldRef, req.deceasedFamilyKey)) return activeGraveFor(worldRef, req.deceasedFamilyKey);
const grave = global.StructureRegistry?.create?.("grave", null, req.x, req.y, worldRef) || null;
if (!grave) return null;
grave.memorialFamilyKey = req.deceasedFamilyKey;
grave.memorialName = req.deceasedName;
grave.memorialType = req.deceasedType;
grave.memorialDeathReason = req.deathReason;
grave.memorialDeathTime = req.deathTime;
grave.memorialBuilderFamilyKey = String(builderFamilyKey || "");
grave.memorialEligibleFamilyKeys = Array.from(new Set(req.eligibleFamilyKeys || []));
grave.memorialFavorite = Boolean(req.deceasedFavorite);
grave.memorialTarinaiKing = Boolean(req.deceasedTarinaiKing);
grave.memorialZunchiSlave = Boolean(req.deceasedZunchiSlave);
grave.createdAt = Number(worldRef.time || 0) || 0;
grave._naturalDecayLastWorldTime = grave.createdAt;
const added = worldRef.addItem?.(grave, reason) || null;
if (!added) return null;
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.markTerrainDirtyAt?.(grave.x, grave.y, 52, reason);
worldRef.drawListDirty = true;
return grave;
}
function captureDeath(worldRef, dead, reason = "") {
if (!worldRef || !dead) return null;
const deceasedFamilyKey = familyKeyOf(dead);
if (!deceasedFamilyKey) return null;
const requests = ensureRequests(worldRef);
const existing = requests.find(req => req?.deceasedFamilyKey === deceasedFamilyKey && req.status !== "cancelled");
if (existing) return existing;
const eligibleFamilyKeys = eligibleFamilyKeysAtDeath(worldRef, dead);
const deceasedFavorite = Boolean(dead.favorite);
if (!deceasedFavorite && !eligibleFamilyKeys.length) return null;
if (!deceasedFavorite) {
const chanceHit = typeof global.deterministicChance === "function"
? global.deterministicChance(worldRef, "memorial-build-at-death", MEMORIAL_BUILD_CHANCE, dead, deceasedFamilyKey)
: Math.random() < MEMORIAL_BUILD_CHANCE;
if (!chanceHit) return null;
}
const req = {
id: `memorial-${deceasedFamilyKey}-${Math.round(Number(worldRef.time || 0) * 10)}`,
deceasedFamilyKey,
deceasedName: String(dead.name || "たりない"),
deceasedType: String(dead.type || "smile"),
deathReason: String(reason || dead.deathReason || ""),
deathTime: dead.deathTime != null && Number.isFinite(Number(dead.deathTime)) ? Number(dead.deathTime) : Number(worldRef.time || 0),
deceasedFavorite,
deceasedTarinaiKing: Boolean(dead.isTarinaiChampion),
deceasedZunchiSlave: Boolean(dead.isZunchiSlave),
x: Number(dead.x || 0) || 0,
y: Number(dead.y || 0) || 0,
eligibleFamilyKeys,
builderFamilyKey: "",
splatId: "",
status: deceasedFavorite ? "building_direct" : "pending",
createdAt: Number(worldRef.time || 0) || 0,
};
requests.push(req);
if (deceasedFavorite) {
const grave = createGraveFromRequest(worldRef, req, "", "favorite-memorial-direct");
if (grave) {
req.status = "complete";
worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 44, life: 0.48, color: "rgba(171,154,118,0.44)" });
} else req.status = "cancelled";
}
return req;
}
function liveSplatForRequest(worldRef, req) {
if (!worldRef || !req) return null;
let best = null;
let bestD = Infinity;
for (const item of worldRef.residuesOfType?.("splat") || []) {
if (!item || item.dead || Number(item.amount || 0) <= 0) continue;
if (item._memorialReservedBy && item._memorialReservedBy !== req.id) continue;
const d = Math.hypot((Number(item.x || 0) || 0) - req.x, (Number(item.y || 0) || 0) - req.y);
if (d <= 190 && d < bestD) { best = item; bestD = d; }
}
return best;
}
function isCritical(t) {
if (!t || t.dead) return true;
if (t.burning || (t.burnTimer || 0) > 0.02 || t.sleepDisease || t.fightDisease) return true;
if ((t.fightTimer || 0) > 0.04 || (t.birthRitualTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04) return true;
return ["panic", "fight", "birth_ritual", "intimidate", "ant_attack", "frozen"].includes(String(t.state || ""));
}
function setSpecialBehavior(t, state, text, target = null) {
t.setActionState?.(state, { target, reason: text, wake: true, sleeping: false });
t.thought = text;
t.target = target;
if (typeof global.setBehaviorText === "function") {
global.setBehaviorText(t, {
need: (state === "mourn_grave" || state === "visit_grave") ? "social" : "fulfill",
actionId: state,
actionLabel: text,
reasonText: text,
target,
phase: "perform",
source: "behavior",
});
}
}
function clearBuilderAssignment(t, req = null) {
if (req && req.builderFamilyKey === familyKeyOf(t)) req.builderFamilyKey = "";
const worldRef = t?.world;
const splat = req?.splatId ? worldRef?.residueByRuntimeId?.(req.splatId) : null;
if (splat && splat._memorialReservedBy === req?.id) splat._memorialReservedBy = "";
if (t) {
t.memorialRequestId = "";
t.memorialBuildProgress = 0;
if (t.state === "build_memorial") t.goIdle?.("墓作りをやめた");
}
}
function assignMemorial(t) {
const worldRef = t?.world;
if (!worldRef || !t || isCritical(t)) return false;
const key = familyKeyOf(t);
if (!key) return false;
for (const req of ensureRequests(worldRef)) {
if (!req || req.status !== "pending" || !req.eligibleFamilyKeys?.includes?.(key)) continue;
if (activeGraveFor(worldRef, req.deceasedFamilyKey)) { req.status = "complete"; continue; }
if (req.builderFamilyKey) {
const builder = liveByFamilyKey(worldRef, req.builderFamilyKey);
if (builder && builder.memorialRequestId === req.id) continue;
req.builderFamilyKey = "";
}
const splat = liveSplatForRequest(worldRef, req);
if (!splat) continue;
req.builderFamilyKey = key;
req.splatId = splat.id || "";
splat._memorialReservedBy = req.id;
t.memorialRequestId = req.id;
t.memorialBuildProgress = 0;
setSpecialBehavior(t, "build_memorial", BUILD_TEXT, splat);
return true;
}
return false;
}
function completeMemorial(t, req, splat) {
const worldRef = t?.world;
if (!worldRef || !req || !splat) return false;
const grave = createGraveFromRequest(worldRef, req, familyKeyOf(t), "memorial-built");
if (!grave) return false;
splat.amount = 0;
splat.hp = 0;
splat._memorialReservedBy = "";
worldRef.removeResidue?.(splat, "memorial-material-consumed");
req.status = "complete";
req.builderFamilyKey = "";
req.splatId = "";
t.memorialRequestId = "";
t.memorialBuildProgress = 0;
t.target = null;
if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -48, fulfill: -8 });
t.goIdle?.("親しいたりないの墓を作った。");
worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 38, life: 0.42, color: "rgba(128,120,112,0.42)" });
return true;
}
function updateMemorialMotion(t, dt) {
const worldRef = t?.world;
const req = requestById(worldRef, t?.memorialRequestId);
if (!req || req.status !== "pending" || req.builderFamilyKey !== familyKeyOf(t)) {
if (t?.memorialRequestId) clearBuilderAssignment(t, req);
return false;
}
if (isCritical(t) && t.state !== "build_memorial") return false;
let splat = req.splatId ? worldRef.residueByRuntimeId?.(req.splatId) : null;
if (!splat || splat.dead || splat.type !== "splat" || Number(splat.amount || 0) <= 0) {
splat = liveSplatForRequest(worldRef, req);
if (!splat) { clearBuilderAssignment(t, req); return false; }
req.splatId = splat.id || "";
splat._memorialReservedBy = req.id;
}
setSpecialBehavior(t, "build_memorial", BUILD_TEXT, splat);
const d = Math.hypot((splat.x || 0) - t.x, (splat.y || 0) - t.y);
const reach = Math.max(28, (t.radius || 22) + (splat.r || 20) * 0.55);
if (d > reach) return false;
t.vx *= Math.pow(0.18, Math.max(0.016, dt) * 60);
t.vy *= Math.pow(0.18, Math.max(0.016, dt) * 60);
t.memorialBuildProgress = Math.max(0, Number(t.memorialBuildProgress || 0)) + dt;
if (t.memorialBuildProgress >= 9.0) completeMemorial(t, req, splat);
return true;
}
function maintainMourning(t, dt = 0) {
const now = Number(t?.world?.time || 0) || 0;
if (!t || !Number.isFinite(Number(t.graveMourningUntil)) || now >= Number(t.graveMourningUntil)) {
if (t?.state === "mourn_grave") t.goIdle?.("泣き止んだ");
return false;
}
setSpecialBehavior(t, "mourn_grave", MOURN_TEXT, null);
if (dt > 0) {
t.vx *= Math.pow(0.15, dt * 60);
t.vy *= Math.pow(0.15, dt * 60);
}
return true;
}
function maintainBellCall(t) {
const now = Number(t?.world?.time || 0) || 0;
if (!t || !t.bellCallTarget || now >= Number(t.bellCallUntil || 0)) {
if (t?.state === "bell_call") t.goIdle?.("呼び鈴の音が止んだ");
t && (t.bellCallTarget = null);
return false;
}
if (isCritical(t) && t.state !== "bell_call") return false;
const target = t.bellCallTarget;
const d = Math.hypot(target.x - t.x, target.y - t.y);
if (d <= Math.max(32, (t.radius || 22) * 1.15)) {
t.bellCallTarget = null;
t.target = null;
t.goIdle?.("呼び鈴の鳴った場所に集まった。");
return false;
}
setSpecialBehavior(t, "bell_call", BELL_TEXT, target);
return true;
}
function updateAi(t) {
if (!t || t.dead) return false;
if (maintainMourning(t, 0)) return true;
if (t.memorialRequestId) {
const req = requestById(t.world, t.memorialRequestId);
if (req?.status === "pending") {
setSpecialBehavior(t, "build_memorial", BUILD_TEXT, req.splatId ? t.world?.residueByRuntimeId?.(req.splatId) : t.target);
return true;
}
clearBuilderAssignment(t, req);
}
if (maintainBellCall(t)) return true;
if (assignMemorial(t)) return true;
if (maintainGraveVisit(t)) return true;
return maybeStartGraveVisit(t);
}
function updateMotion(t, dt) {
if (!t || t.dead) return { handled: false };
if (maintainMourning(t, dt)) return { handled: true };
if (t.memorialRequestId || t.state === "build_memorial") {
const handled = updateMemorialMotion(t, dt);
return { handled };
}
if (maintainBellCall(t)) return { handled: false };
if (t.graveVisitTargetId || t.state === "visit_grave") {
const grave = graveById(t.world, t.graveVisitTargetId);
if (!grave) { clearGraveVisit(t, "墓がなくなった。"); return { handled: false }; }
const d = Math.hypot((grave.x || 0) - t.x, (grave.y || 0) - t.y);
const reach = Math.max(38, (t.radius || 22) + (grave.r || 18) * 0.82);
if (t.graveVisitPhase === "visiting" || d <= reach) {
if (t.graveVisitPhase !== "visiting") {
const now = Number(t.world?.time || 0) || 0;
const unit = typeof global.stableUnit === "function" ? global.stableUnit(familyKeyOf(t), `grave-visit-duration-${grave.id || "grave"}-${Math.floor(now)}`) : Math.random();
t.graveVisitPhase = "visiting";
t.graveVisitUntil = now + 4.5 + unit * 4.0;
if (!t.graveVisitReliefApplied && typeof applyNeedRelief === "function") {
applyNeedRelief(t, { social: -42, fulfill: -6 });
t.graveVisitReliefApplied = true;
}
setSpecialBehavior(t, "visit_grave", `${grave.memorialName || "親しいたりない"}を悼んでいる。`, grave);
}
t.vx *= Math.pow(0.12, Math.max(0.016, dt) * 60);
t.vy *= Math.pow(0.12, Math.max(0.016, dt) * 60);
maintainGraveVisit(t);
return { handled: true };
}
maintainGraveVisit(t);
return { handled: false };
}
return { handled: false };
}
function ringAt(worldRef, x, y, requestedSize = "medium") {
if (!worldRef) return 0;
const size = ["small", "medium", "large"].includes(requestedSize) ? requestedSize : "medium";
const range = BELL_RANGES[size];
const duration = BELL_DURATIONS[size];
const now = Number(worldRef.time || 0) || 0;
global.TarinaiAudio?.bell?.();
const px = Number(x || 0) || 0;
const py = Number(y || 0) || 0;
const target = { id: `bell-call-${Math.round(now * 1000)}-${Math.round(px)}-${Math.round(py)}`, type: "bell_call", x: px, y: py, dead: false };
let affected = 0;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead) continue;
if (Math.hypot(t.x - px, t.y - py) > range) continue;
t.bellCallTarget = target;
t.bellCallUntil = Math.max(Number(t.bellCallUntil || 0), now + duration);
if (t.graveVisitTargetId) {
t.graveVisitTargetId = "";
t.graveVisitUntil = 0;
t.graveVisitPhase = "";
}
if (!isCritical(t)) setSpecialBehavior(t, "bell_call", BELL_TEXT, target);
affected += 1;
}
worldRef.spawnEffect?.("ring", px, py, { size: range, life: 0.62, color: "rgba(219,164,70,0.52)" });
worldRef.spawnEffect?.("ring", px, py, { size: Math.min(92, 34 + range * 0.08), life: 0.48, color: "rgba(244,197,100,0.58)" });
worldRef.drawListDirty = true;
return affected;
}
const NATURAL_ASCENSION_SPRITES = Object.freeze(["sleep", "normal_happy"]);
const REMOVAL_ASCENSION_SPRITES = Object.freeze(["hurt", "flee", "flee_fear2", "cold_1"]);
function ascensionSpriteFor(worldRef, grave, sprites, salt = "grave-ascension") {
const list = Array.isArray(sprites) && sprites.length ? sprites : ["sleep"];
const unit = typeof global.stableUnit === "function"
? global.stableUnit(grave?.memorialFamilyKey || grave?.id || "grave", `${salt}-${Math.floor(Number(worldRef?.time || 0) || 0)}`)
: Math.random();
return list[Math.min(list.length - 1, Math.floor(Math.max(0, Math.min(0.999999, unit)) * list.length))] || list[0];
}
function spawnGraveAscension(worldRef, grave, options = {}) {
if (!worldRef || !grave) return false;
const natural = options.natural === true;
const spriteId = ascensionSpriteFor(worldRef, grave, natural ? NATURAL_ASCENSION_SPRITES : REMOVAL_ASCENSION_SPRITES, natural ? "grave-natural-ascension" : "grave-removal-ascension");
worldRef.spawnEffect?.("ascend_soul", grave.x, grave.y - Math.max(2, (grave.r || 14) * 0.25), {
vx: 0,
vy: -34,
size: Math.max(18, (grave.r || 14) * 1.45),
life: 2.4,
importance: 3,
allowOffscreen: true,
forceVisual: true,
spriteId,
});
return true;
}
function applyExternalForce(worldRef, grave, magnitude = 0, breaker = null, reason = "external-force") {
if (!worldRef || !grave || grave.dead || grave.type !== "grave") return false;
const force = Math.max(0, Number(magnitude || 0) || 0);
if (force < GRAVE_BREAK_FORCE) return false;
const removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, grave, {
reason: `grave-${reason}`,
userReason: "強い衝撃で墓が壊れた",
wake: true,
breaker,
}));
if (removed) {
worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 42, life: 0.28, color: "rgba(130,124,116,0.46)" });
worldRef.spawnEffect?.("fight", grave.x, grave.y, { size: 18, life: 0.24, color: "rgba(118,108,98,0.52)" });
}
return removed;
}
function onGraveDestroyed(worldRef, grave, breaker = null) {
if (!worldRef || !grave || grave.type !== "grave" || grave._memorialDestroyedHandled) return false;
grave._memorialDestroyedHandled = true;
if (grave._naturalDecaySilent) return false;
spawnGraveAscension(worldRef, grave, { natural: false });
const eligible = new Set(Array.isArray(grave.memorialEligibleFamilyKeys) ? grave.memorialEligibleFamilyKeys : []);
const now = Number(worldRef.time || 0) || 0;
let count = 0;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead || !eligible.has(familyKeyOf(t))) continue;
if (t.graveVisitTargetId === grave.id) {
t.graveVisitTargetId = "";
t.graveVisitUntil = 0;
t.graveVisitPhase = "";
}
t.graveMourningUntil = Math.max(Number(t.graveMourningUntil || 0), now + 5.5);
t.bellCallTarget = null;
t.memorialRequestId = "";
setSpecialBehavior(t, "mourn_grave", MOURN_TEXT, null);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.75);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.5);
t.stress = Math.min(100, (Number(t.stress || 0) || 0) + 12);
worldRef.spawnBubble?.(t.x, t.y - (t.radius || 20) * 1.15, "……", "rgba(84,112,166,0.82)");
count += 1;
}
if (count) worldRef.log?.(`${grave.memorialName || "親しいたりない"}の墓が壊され、親しいたりないたちが泣いている。`, "death");
void breaker;
return count > 0;
}
function removeGraveNaturally(worldRef, grave, day) {
if (!worldRef || !grave || grave.dead || grave.type !== "grave" || grave.memorialFavorite) return false;
const disappears = typeof global.deterministicChance === "function"
? global.deterministicChance(worldRef, "grave-natural-decay-0444", 0.25, grave.memorialFamilyKey || grave.id, day)
: Math.random() < 0.25;
if (!disappears) return false;
grave._naturalDecaySilent = true;
spawnGraveAscension(worldRef, grave, { natural: true });
const removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, grave, {
reason: "grave-natural-decay",
userReason: "いつの間にか消えた",
wake: false,
panicOwner: false,
}));
if (removed) worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 30, life: 0.36, color: "rgba(164,156,143,0.26)", importance: 2 });
return removed;
}
function updateWorldNaturalDecay(worldRef) {
if (!worldRef) return 0;
const now = Math.max(0, Number(worldRef.time || 0) || 0);
const hasPrevious = Number.isFinite(Number(worldRef._graveNaturalDecayLastWorldTime));
const previous = hasPrevious ? Number(worldRef._graveNaturalDecayLastWorldTime) : now;
worldRef._graveNaturalDecayLastWorldTime = now;
if (!hasPrevious || now <= previous) return 0;
const dayLength = Math.max(1, Number((typeof CONFIG !== "undefined" ? CONFIG.dayLength : 120) || 120) || 120);
const targetFraction = (4 * 60 + 44) / (24 * 60);
const firstDay = Math.max(0, Math.floor(previous / dayLength));
const lastDay = Math.max(firstDay, Math.floor(now / dayLength));
let removedCount = 0;
for (let day = firstDay; day <= lastDay; day += 1) {
if (Number(worldRef._graveNaturalDecayLastCheckedDay) === day) continue;
const targetTime = day * dayLength + dayLength * targetFraction;
if (!(previous < targetTime && now >= targetTime)) continue;
worldRef._graveNaturalDecayLastCheckedDay = day;
const graveBucket = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("grave") : null;
const graves = graveBucket && typeof graveBucket.length === "number"
? Array.from(graveBucket)
: (worldRef.items || []).filter(item => item && !item.dead && item.type === "grave");
for (const grave of graves) if (removeGraveNaturally(worldRef, grave, day)) removedCount += 1;
}
return removedCount;
}
// Kept for compatibility with older direct callers. Grave decay is now
// world-scheduled, so adding more graves does not add per-grave update work.
function updateGraveNaturalDecay(grave, worldRef) {
void grave;
void worldRef;
return false;
}
function formatWorldTime(timeValue) {
const value = Number(timeValue);
if (!Number.isFinite(value)) return "";
const dayLength = Math.max(1, Number((typeof CONFIG !== "undefined" ? CONFIG.dayLength : 120) || 120) || 120);
const completedDays = Math.max(0, Math.floor(value / dayLength));
const season = ["春", "夏", "秋", "冬"][Math.floor((completedDays % 20) / 5)] || "春";
const seasonDay = (completedDays % 5) + 1;
const progress = (((value % dayLength) + dayLength) % dayLength) / dayLength;
const totalMinutes = Math.floor(progress * 24 * 60 + 1e-7) % (24 * 60);
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
const mm = String(totalMinutes % 60).padStart(2, "0");
return `${season}${seasonDay}${hh}:${mm}`;
}
function graveTooltipLines(grave) {
if (!grave) return [];
const lines = ["墓"];
if (grave.memorialName) lines.push(`故人: ${grave.memorialName}`);
const died = formatWorldTime(grave.memorialDeathTime);
if (died) lines.push(`死亡日時: ${died}`);
if (grave.memorialDeathReason) lines.push(`死因: ${grave.memorialDeathReason}`);
return lines;
}
global.TarinaiMemorialBellSystem = Object.freeze({
BUILD_TEXT,
MOURN_TEXT,
BELL_TEXT,
GRAVE_VISIT_APPROACH_TEXT,
GRAVE_VISIT_TEXT,
BELL_RANGES,
MEMORIAL_BUILD_CHANCE,
GRAVE_BREAK_FORCE,
captureDeath,
ringAt,
updateAi,
updateMotion,
applyExternalForce,
onGraveDestroyed,
updateGraveNaturalDecay,
updateWorldNaturalDecay,
graveTooltipLines,
formatWorldTime,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -18,7 +18,6 @@
let frameCount = 0;
let lastSmoothAt = 0;
const storageKey = "tarinai_visual_settings_v2";
const legacyStorageKey = "tarinai_visual_settings_v1";
const allowed = {
effects: new Set(["off", "high"]),
shadows: new Set(["off", "high"]),
@ -32,32 +31,34 @@
dprScale: 1,
simulationThrottled: false,
creatureCadenceScale: 0,
creatureBackgroundBudget: 1000000000,
pressureCreatureFullBudget: 1000000000,
pressureCreatureMotionBudget: 1000000000,
pressureObstacleContactBudget: 1000000000,
collisionSourceBudget: 1000000000,
collisionImpactBudget: 1000000000,
// High quality is still intentionally generous, but no subsystem is
// allowed to explode to effectively-unbounded work in a single frame.
creatureBackgroundBudget: 180,
pressureCreatureFullBudget: 240,
pressureCreatureMotionBudget: 360,
pressureObstacleContactBudget: 96,
collisionSourceBudget: 220,
collisionImpactBudget: 160,
itemCadenceScale: 0,
scheduledItemBudget: 1000000000,
aiBudget: 1000000000,
envBudget: 1000000000,
effectCap: 1000000000,
effectUpdateBudget: 1000000000,
scheduledItemBudget: 180,
aiBudget: 96,
envBudget: 32,
effectCap: 520,
effectUpdateBudget: 280,
effectDrawBudget: 160,
fireInteractionBudget: 1000000000,
activeFireLimit: 1000000000,
fireInteractionBudget: 96,
activeFireLimit: 260,
physicsBudget: Object.freeze({
maxItems: 1000000000,
maxPairs: 1000000000,
maxLinks: 1000000000,
postMaxPairs: 1000000000,
maxSubsteps: 8,
focusThreshold: 1000000000,
maxSubstepPairs: 1000000000,
maxSubstepFenceItems: 1000000000,
maxItems: 560,
maxPairs: 960,
maxLinks: 520,
postMaxPairs: 300,
maxSubsteps: 6,
focusThreshold: 220,
maxSubstepPairs: 620,
maxSubstepFenceItems: 220,
}),
poisonContactBudget: Object.freeze({ maxPoison: 1000000000, maxTargets: 1000000000, skip: false }),
poisonContactBudget: Object.freeze({ maxPoison: 72, maxTargets: 320, skip: false }),
});
const PROFILE_BALANCED = Object.freeze({
id: "balanced",
@ -130,6 +131,7 @@
let visualSettings = loadVisualSettings();
let resizeRequested = false;
let autoProfileId = "full";
const FPS_UPDATE_INTERVAL_SECONDS = 0.75;
let fpsAccumSeconds = 0;
let fpsAccumFrames = 0;
let smoothedFps = 0;
@ -213,14 +215,7 @@
function loadVisualSettings() {
try {
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;
return current ? sanitizeVisualSettings(JSON.parse(current)) : { ...defaults };
} catch (_) {
return { ...defaults };
}
@ -271,10 +266,10 @@
if (!Number.isFinite(dt) || dt <= 0 || dt > 0.25 || global.document?.hidden) return;
fpsAccumSeconds += dt;
fpsAccumFrames += 1;
if (fpsAccumSeconds < 0.75) return;
if (fpsAccumSeconds < FPS_UPDATE_INTERVAL_SECONDS) return;
const sampleSeconds = fpsAccumSeconds;
const sampledFps = fpsAccumFrames / Math.max(0.001, sampleSeconds);
smoothedFps = smoothedFps > 0 ? smoothedFps * 0.65 + sampledFps * 0.35 : sampledFps;
smoothedFps = sampledFps;
global.__tarinaiFps = Math.round(smoothedFps);
fpsAccumSeconds = 0;
fpsAccumFrames = 0;

View file

@ -215,8 +215,10 @@
bounceInsideWorld(item, worldRef, opts);
resolveFenceBounce(item, worldRef, dt, opts);
const ice = (worldRef?.groundType || "soil") === "ice";
const baseFriction = ice ? Math.max(Number(opts.frictionBase ?? 0.68), 0.93) : (opts.frictionBase ?? 0.68);
const frictionRate = ice ? Math.min(Number(opts.frictionRate ?? 1), 0.55) : (opts.frictionRate ?? 1);
let baseFriction = ice ? Math.max(Number(opts.frictionBase ?? 0.68), 0.93) : (opts.frictionBase ?? 0.68);
let frictionRate = ice ? Math.min(Number(opts.frictionRate ?? 1), 0.55) : (opts.frictionRate ?? 1);
const oilMul = global.TarinaiItemEnvironmentHazardSystem?.groundFrictionMultiplier?.(worldRef, item.x, item.y) ?? 1;
if (oilMul < 1) baseFriction = 1 - (1 - baseFriction) * oilMul;
const friction = Math.pow(baseFriction, dt * frictionRate);
item.vx *= friction;
item.vy *= friction;

View file

@ -23,7 +23,6 @@
}
function pickMode(worldRef) {
const value = String(worldRef?.toolPickMode || "free").toLowerCase();
if (value === "grid") return "grid40"; // compatibility with v39.16.34
return new Set(["grid20", "grid40", "grid60"]).has(value) ? value : "free";
}
function gridStep(worldRef) {
@ -225,6 +224,9 @@
if (type === "dry_ice" || type === "stove") {
const range = Math.max(24, global.CONFIG?.temperatureItemRange ?? 190);
extra.influence = { kind: "temperatureEffectRange", shape: "circle", x, y, radius: range, type };
} else if (type === "leakage_device") {
const range = global.TarinaiItemEnvironmentHazardSystem?.leakageRadius?.(tmp) ?? Math.max(260, (tmp.r || 34) * 8.2);
extra.influence = { kind: "leakageDamageRange", shape: "circle", x, y, radius: range, type };
}
if (type === "grass") {
const overlaps = global.TarinaiPhysicsSoftClear.collectForPlacement(worldRef, tmp) || [];

View file

@ -510,8 +510,9 @@ function drawDeviceInfluenceShape(ctx, influence, blocked = false, alphaScale =
ctx.lineWidth = 2.0;
const tempRange = influence?.kind === "temperatureEffectRange";
const hotRange = tempRange && influence?.type === "stove";
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.72)" : (tempRange ? (hotRange ? "rgba(230,112,48,0.80)" : "rgba(74,166,230,0.82)") : "rgba(80,174,222,0.82)");
ctx.fillStyle = blocked ? "rgba(232,84,72,0.055)" : (tempRange ? (hotRange ? "rgba(255,138,56,0.075)" : "rgba(74,166,230,0.075)") : "rgba(80,174,222,0.075)");
const electricRange = influence?.kind === "leakageDamageRange";
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.72)" : (electricRange ? "rgba(126,218,255,0.90)" : (tempRange ? (hotRange ? "rgba(230,112,48,0.80)" : "rgba(74,166,230,0.82)") : "rgba(80,174,222,0.82)"));
ctx.fillStyle = blocked ? "rgba(232,84,72,0.055)" : (electricRange ? "rgba(126,218,255,0.085)" : (tempRange ? (hotRange ? "rgba(255,138,56,0.075)" : "rgba(74,166,230,0.075)") : "rgba(80,174,222,0.075)"));
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
@ -687,6 +688,33 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
function drawOperationToolPreview(ctx, world) {
const p = world?.pointer;
if (!p?.inside) return;
if (world?.tool === "bell") {
const size = world.toolSizeFor?.("bell") || "medium";
const ranges = globalThis.TarinaiMemorialBellSystem?.BELL_RANGES || { small: 120, medium: 210, large: 350 };
const range = Math.max(1, Number(ranges[size] || ranges.medium || 420) || 420);
ctx.save();
ctx.fillStyle = "rgba(229, 174, 70, 0.055)";
ctx.strokeStyle = "rgba(214, 151, 48, 0.76)";
ctx.lineWidth = Math.max(1.4, world.screenSizeToWorld ? world.screenSizeToWorld(2.0) : 2);
ctx.setLineDash([12, 9]);
ctx.beginPath();
ctx.arc(p.x, p.y, range, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.setLineDash([4, 10]);
ctx.globalAlpha = 0.62;
ctx.beginPath();
ctx.arc(p.x, p.y, range * 0.5, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 0.88;
ctx.fillStyle = "rgba(214, 151, 48, 0.88)";
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(4, world.screenSizeToWorld ? world.screenSizeToWorld(5) : 5), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return;
}
if (world?.tool === "copy" && world.copyBuffer) {
const type = world.copyBuffer.type || "";
const item = world.copyPreviewItemAt?.(p.x, p.y);
@ -877,8 +905,9 @@ function invalidateRenderCaches(reason = "manual") {
if (backgroundCache) backgroundCache.key = "";
if (terrainChunkCache) {
terrainChunkCache.key = "";
terrainChunkCache.chunks?.clear?.();
clearTerrainChunkCache();
}
if (typeof clearSharedStaticBitmapCaches === "function") clearSharedStaticBitmapCaches();
}
// Cached low-priority terrain layer: grass, footprints and splats are mostly
@ -890,8 +919,38 @@ const terrainChunkCache = {
size: 256,
key: "",
chunks: new Map(),
minEntries: 64,
spareEntries: 24,
};
function releaseTerrainChunk(chunk) {
if (!chunk?.canvas) return;
chunk.canvas.width = 0;
chunk.canvas.height = 0;
}
function clearTerrainChunkCache() {
for (const chunk of terrainChunkCache.chunks.values()) releaseTerrainChunk(chunk);
terrainChunkCache.chunks.clear();
}
function touchTerrainChunk(key, chunk) {
// Map insertion order is our LRU order. Refresh entries when they are used.
terrainChunkCache.chunks.delete(key);
terrainChunkCache.chunks.set(key, chunk);
}
function pruneTerrainChunkCache(visibleKeys, visibleCount) {
const maxEntries = Math.max(terrainChunkCache.minEntries, Math.max(0, visibleCount || 0) + terrainChunkCache.spareEntries);
if (terrainChunkCache.chunks.size <= maxEntries) return;
for (const [key, chunk] of terrainChunkCache.chunks) {
if (terrainChunkCache.chunks.size <= maxEntries) break;
if (visibleKeys?.has?.(key)) continue;
terrainChunkCache.chunks.delete(key);
releaseTerrainChunk(chunk);
}
}
function isCachedTerrainItem(it) {
if (!it || it.dead) return false;
if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true;
@ -933,7 +992,7 @@ function linkIntersectsVisibleRect(it, worldRef, rect, margin = 18) {
}
function isBehindSpriteLayerItem(it) {
return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "seesaw"));
return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "oil" || it.type === "bear_trap" || it.type === "seesaw"));
}
function renderLayerSortY(entity) {
@ -986,8 +1045,10 @@ function terrainChunkFor(cx, cy, worldRef) {
const h = Math.max(1, Math.min(size, Math.ceil((worldRef.h || 1) - y)));
if (!chunk) {
const canvas = document.createElement("canvas");
chunk = { canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" };
chunk = { cacheKey: key, canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" };
terrainChunkCache.chunks.set(key, chunk);
} else {
touchTerrainChunk(key, chunk);
}
if (chunk.w !== w || chunk.h !== h) {
chunk.w = w;
@ -997,6 +1058,40 @@ function terrainChunkFor(cx, cy, worldRef) {
return chunk;
}
function drawTerrainResidue(ctx, residue) {
if (!residue || residue.dead || residue.amount <= 0) return;
ctx.save();
ctx.translate(residue.x || 0, residue.y || 0);
if (residue.type === "trace") {
const fade = Math.max(0, Math.min(1, (Number(residue.amount || 0) || 0) / 220));
ctx.globalAlpha *= 0.35 + fade * 0.65;
ctx.fillStyle = "rgba(122, 100, 76, 0.12)";
ctx.strokeStyle = "rgba(122, 100, 76, 0.16)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, 0, (residue.r || 16) * 1.3, (residue.r || 16) * 0.75, Math.sin(residue.seed || 0) * 0.7, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
} else if (residue.type === "splat") {
const fade = Math.max(0, Math.min(1, (Number(residue.amount || 0) || 0) / 260));
ctx.globalAlpha *= fade;
ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`;
ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`;
ctx.lineWidth = 1.8;
const blobs = [[-12,-2,10,8],[0,0,16,10],[13,5,10,8],[-2,-12,9,7],[7,-8,7,5],[-18,8,6,5]];
for (const [bx, by, bw, bh] of blobs) {
ctx.beginPath();
ctx.ellipse(bx, by, bw, bh, randSeed((residue.seed || 0) + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
}
for (let i = 0; i < 7; i++) {
ctx.beginPath();
ctx.arc(randSeed((residue.seed || 0) + i, -28, 24), randSeed((residue.seed || 0) + i + 9, -18, 18), randSeed((residue.seed || 0) + i + 19, 2.5, 4.8), 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
function redrawTerrainChunk(chunk, worldRef, lighting) {
const c = chunk.ctx;
chunk.canvas.width = Math.max(1, chunk.w);
@ -1016,6 +1111,10 @@ function redrawTerrainChunk(chunk, worldRef, lighting) {
if (!isCachedTerrainItem(it) || !isEntityVisibleInRect(it, rect, 80)) continue;
it.draw(c, worldRef.time || 0, lighting);
}
for (const residue of worldRef.nearbyResidues?.(cx, cy, radius) || []) {
if (!residue || residue.dead || !isPointVisibleInRect(residue.x, residue.y, (residue.r || 24) + 42, rect)) continue;
drawTerrainResidue(c, residue);
}
} catch (err) {
console.error("terrain chunk redraw failed", err);
} finally {
@ -1032,7 +1131,7 @@ function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) {
const key = terrainCacheKey(worldRef, lighting);
if (terrainChunkCache.key !== key || worldRef.terrainDirtyGlobal || (worldRef.terrainDirty && !worldRef.terrainDirtyChunks?.size)) {
terrainChunkCache.key = key;
terrainChunkCache.chunks.clear();
clearTerrainChunkCache();
worldRef.terrainDirty = false;
worldRef.terrainDirtyGlobal = false;
worldRef.terrainDirtyChunks?.clear?.();
@ -1049,13 +1148,28 @@ function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) {
const maxX = Math.min(Math.floor(((worldRef.w || 1) - 1) / size), Math.floor((visibleRect.right || 0) / size));
const minY = Math.max(0, Math.floor((visibleRect.top || 0) / size));
const maxY = Math.min(Math.floor(((worldRef.h || 1) - 1) / size), Math.floor((visibleRect.bottom || 0) / size));
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const chunk = terrainChunkFor(cx, cy, worldRef);
if (chunk.dirty || chunk.key !== terrainChunkCache.key) redrawTerrainChunk(chunk, worldRef, lighting);
ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h);
}
const visibleChunks = worldRef._renderTerrainChunkScratch || (worldRef._renderTerrainChunkScratch = []);
const visibleChunkKeys = worldRef._renderTerrainChunkKeyScratch || (worldRef._renderTerrainChunkKeyScratch = new Set());
visibleChunks.length = 0;
visibleChunkKeys.clear();
let dirtyVisibleCount = 0;
for (let cy = minY; cy <= maxY; cy++) for (let cx = minX; cx <= maxX; cx++) {
const chunk = terrainChunkFor(cx, cy, worldRef);
visibleChunks.push(chunk);
visibleChunkKeys.add(chunk.cacheKey || terrainChunkKey(cx, cy));
if (chunk.dirty || chunk.key !== terrainChunkCache.key) dirtyVisibleCount += 1;
}
const deathBurst = /tarinai-death|splat-fade/.test(String(worldRef.terrainDirtyReason || "")) && dirtyVisibleCount > 8;
let redrawBudget = deathBurst ? 6 : Infinity;
if (deathBurst) visibleChunks.sort((a, b) => ((a.key === terrainChunkCache.key) ? 1 : 0) - ((b.key === terrainChunkCache.key) ? 1 : 0));
for (const chunk of visibleChunks) {
if (chunk.dirty || chunk.key !== terrainChunkCache.key) {
const usable = chunk.key === terrainChunkCache.key || (chunk.canvas.width > 1 && chunk.canvas.height > 1);
if (redrawBudget > 0 || !usable) { redrawTerrainChunk(chunk, worldRef, lighting); redrawBudget -= 1; }
}
ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h);
}
pruneTerrainChunkCache(visibleChunkKeys, visibleChunks.length);
} finally {
if (end) end();
}
@ -1119,7 +1233,7 @@ const STATIC_ITEM_BITMAP_TYPES = new Set([
// corpses are cheaper to draw directly and can exist in large numbers, so
// per-item canvases for them waste substantially more memory than they save.
"stone", "bed", "grass_bed", "nest_box", "pipe", "toilet", "rain_shelter", "ant_nest", "signboard",
"fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "one_way_fence", "plushie"
"fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "one_way_fence", "plushie", "grave"
]);
function canUseStaticItemBitmapCache(it, worldRef) {
@ -1151,32 +1265,10 @@ function staticItemBitmapState(it, lighting) {
flash: Number(it.toiletFlash ?? 0),
color: String(it.color || ""),
active: Boolean(it.active),
memorialFavorite: Boolean(it.memorialFavorite),
};
}
function staticItemBitmapStateMatches(cache, state) {
return cache.lightBucket === state.lightBucket
&& cache.warmthBucket === state.warmthBucket
&& cache.r === state.r
&& cache.angle === state.angle
&& cache.amount === state.amount
&& cache.servings === state.servings
&& cache.servingsMax === state.servingsMax
&& cache.variant === state.variant
&& cache.toolSize === state.toolSize
&& cache.text === state.text
&& cache.state === state.state
&& cache.freshness === state.freshness
&& cache.fertility === state.fertility
&& cache.flash === state.flash
&& cache.color === state.color
&& cache.active === state.active;
}
function assignStaticItemBitmapState(cache, state) {
Object.assign(cache, state);
}
function releaseStaticItemBitmapCache(it) {
const cache = it?._staticRenderBitmapCache;
if (!cache) return false;
@ -1190,6 +1282,96 @@ function releaseStaticItemBitmapCache(it) {
return true;
}
const SHARED_STATIC_ITEM_BITMAP_CACHE_MAX = 128;
const sharedStaticItemBitmapCache = new Map();
const sharedGraveBitmapCache = new Map();
function clearSharedStaticBitmapCaches() {
for (const cache of sharedStaticItemBitmapCache.values()) {
if (cache?.canvas) { cache.canvas.width = 0; cache.canvas.height = 0; }
}
for (const cache of sharedGraveBitmapCache.values()) {
if (cache?.canvas) { cache.canvas.width = 0; cache.canvas.height = 0; }
}
sharedStaticItemBitmapCache.clear();
sharedGraveBitmapCache.clear();
}
function sharedStaticBitmapKey(it, state, radius) {
// Procedural decorations use a small stable variant pool instead of one
// canvas per item. This preserves visual variety while bounding memory.
const seedVariant = Math.abs(Math.floor(Number(it.seed || 0))) % 4;
return [
String(it.type || ""), radius, seedVariant,
state.lightBucket, state.warmthBucket, state.r, state.angle,
state.amount, state.servings, state.servingsMax, state.variant,
state.toolSize, state.text, state.state, state.freshness,
state.fertility, state.flash, state.color, state.active ? 1 : 0,
state.memorialFavorite ? 1 : 0,
].join("|");
}
function getSharedStaticBitmap(key) {
const cache = sharedStaticItemBitmapCache.get(key);
if (!cache) return null;
sharedStaticItemBitmapCache.delete(key);
sharedStaticItemBitmapCache.set(key, cache);
return cache;
}
function putSharedStaticBitmap(key, cache) {
sharedStaticItemBitmapCache.set(key, cache);
while (sharedStaticItemBitmapCache.size > SHARED_STATIC_ITEM_BITMAP_CACHE_MAX) {
const oldestKey = sharedStaticItemBitmapCache.keys().next().value;
const oldest = sharedStaticItemBitmapCache.get(oldestKey);
sharedStaticItemBitmapCache.delete(oldestKey);
if (oldest?.canvas) { oldest.canvas.width = 0; oldest.canvas.height = 0; }
}
}
function drawSharedGraveBitmapCached(ctx, it, time, lighting, worldRef) {
const radius = Math.ceil(Math.max(42, renderRadiusForEntity(it) + 18));
const size = radius * 2;
const key = [
Math.round((lighting?.light || 0) * 6),
Math.round((lighting?.warmth || 0) * 3),
Number(it.r || 0),
it.memorialFavorite ? 1 : 0,
it.memorialTarinaiKing ? 1 : 0,
it.memorialZunchiSlave ? 1 : 0,
size,
].join(":");
let cache = sharedGraveBitmapCache.get(key);
if (cache) {
sharedGraveBitmapCache.delete(key);
sharedGraveBitmapCache.set(key, cache);
}
if (!cache) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const c = canvas.getContext("2d");
c.clearRect(0, 0, size, size);
c.save();
c.translate(radius - (it.x || 0), radius - (it.y || 0));
const pointer = worldRef?.pointer;
const oldInside = pointer?.inside;
if (pointer) pointer.inside = false;
try { it.draw(c, time, lighting); }
finally { if (pointer) pointer.inside = oldInside; c.restore(); }
cache = { canvas, radius };
sharedGraveBitmapCache.set(key, cache);
while (sharedGraveBitmapCache.size > 20) {
const oldestKey = sharedGraveBitmapCache.keys().next().value;
const oldest = sharedGraveBitmapCache.get(oldestKey);
sharedGraveBitmapCache.delete(oldestKey);
if (oldest?.canvas) { oldest.canvas.width = 0; oldest.canvas.height = 0; }
}
}
ctx.drawImage(cache.canvas, (it.x || 0) - cache.radius, (it.y || 0) - cache.radius);
return true;
}
function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) {
if (!canUseStaticItemBitmapCache(it, worldRef)) {
releaseStaticItemBitmapCache(it);
@ -1203,18 +1385,24 @@ function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) {
it.draw(ctx, time, lighting);
return false;
}
if (it.type === "grave") {
releaseStaticItemBitmapCache(it);
return drawSharedGraveBitmapCached(ctx, it, time, lighting, worldRef);
}
// Clear any legacy per-item bitmap left by an older build. Static artwork is
// now shared by visual state, so hundreds of identical objects reuse a small
// bounded set of backing canvases.
releaseStaticItemBitmapCache(it);
const state = staticItemBitmapState(it, lighting);
let cache = it._staticRenderBitmapCache;
const radius = Math.ceil(Math.max(42, renderRadiusForEntity(it) + 18));
const size = radius * 2;
if (!cache) cache = it._staticRenderBitmapCache = { canvas: document.createElement("canvas"), ctx: null, radius: 0 };
if (!cache.ctx) cache.ctx = cache.canvas.getContext("2d");
if (!staticItemBitmapStateMatches(cache, state) || cache.radius !== radius || cache.canvas.width !== size || cache.canvas.height !== size) {
cache.radius = radius;
assignStaticItemBitmapState(cache, state);
cache.canvas.width = size;
cache.canvas.height = size;
const c = cache.ctx;
const key = sharedStaticBitmapKey(it, state, radius);
let cache = getSharedStaticBitmap(key);
if (!cache) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const c = canvas.getContext("2d");
c.clearRect(0, 0, size, size);
c.save();
c.translate(radius - (it.x || 0), radius - (it.y || 0));
@ -1227,8 +1415,10 @@ function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) {
if (pointer) pointer.inside = oldInside;
c.restore();
}
cache = { canvas, radius };
putSharedStaticBitmap(key, cache);
}
ctx.drawImage(cache.canvas, (it.x || 0) - radius, (it.y || 0) - radius);
ctx.drawImage(cache.canvas, (it.x || 0) - cache.radius, (it.y || 0) - cache.radius);
// Pointer hover is not baked into the bitmap. Draw only the hovered outline
// live so cached objects still react immediately to the cursor.
const pointer = worldRef?.pointer;
@ -1309,8 +1499,21 @@ function mergeSortedRenderEntries(a, b, out) {
return out;
}
function backItemLayerRank(item) {
if (!item || item.dead) return 1;
// Ground decorations stay behind sleeping places. Oil is still below all
// creatures, but sits visually in front of ordinary beds.
if (item.type === "toilet") return 0;
if (item.type === "bed") return 1;
if (item.type === "oil" || item.type === "bear_trap") return 2;
if (item.type === "grass_bed") return 3;
return 1;
}
function compareBackItems(a, b) {
return (a._renderSortY ?? renderLayerSortY(a)) - (b._renderSortY ?? renderLayerSortY(b)) || (a.x || 0) - (b.x || 0);
return backItemLayerRank(a) - backItemLayerRank(b)
|| (a._renderSortY ?? renderLayerSortY(a)) - (b._renderSortY ?? renderLayerSortY(b))
|| (a.x || 0) - (b.x || 0);
}
function mergeSortedBackItems(a, b, out) {
@ -1382,7 +1585,7 @@ function collectVisibleRenderStack(worldRef, visibleRect, creatureSortRect = vis
const staticScratch = worldRef._renderVisibleStaticScratch || (worldRef._renderVisibleStaticScratch = []);
const cacheRect = { left: visibleRect.left - 52, top: visibleRect.top - 52, right: visibleRect.right + 52, bottom: visibleRect.bottom + 52 };
staticScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, cacheRect, staticScratch);
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells, cacheRect, staticScratch);
const oldVisibleRect = visibleRect;
visibleRect = cacheRect;
for (const it of staticScratch) addItemTo(it, staticCache.backItems, staticCache.layered);
@ -2073,10 +2276,25 @@ function render() {
const endEffects = renderPerfBegin("render.draw.effects");
const effectLevel = renderVisualLevel("effects");
let drawnEffects = 0;
let drawnAscensions = 0;
let drawnBubbles = 0;
const maxEffects = visualEffectLimit(effectLevel);
const effects = world.effects || [];
const totalEffects = effects.length;
let effectScanCap = 0;
const bubbleDrawLimit = Math.max(1, Number(CONFIG.bubbleLimit || 32) || 32);
for (let i = 0; i < totalEffects; i += 1) {
const ef = effects[i];
if (!ef || ef.dead) continue;
if (ef.type === "ascend_soul") {
if (isEntityVisibleInRect(ef, visibleRect, 180)) { ef.draw(ctx); drawnAscensions += 1; }
continue;
}
if (ef.type === "bubble" && drawnBubbles < bubbleDrawLimit && isEntityVisibleInRect(ef, visibleRect, 72)) {
ef.draw(ctx);
drawnBubbles += 1;
}
}
if (totalEffects > 0 && maxEffects > 0) {
const scanCap = totalEffects > 180 ? Math.min(totalEffects, Math.max(maxEffects * 8, 160)) : totalEffects;
effectScanCap = scanCap;
@ -2084,6 +2302,7 @@ function render() {
for (let scanned = 0; scanned < scanCap && drawnEffects < maxEffects; scanned += 1) {
const ef = effects[cursor];
cursor = (cursor + 1) % totalEffects;
if (!ef || ef.dead || ef.type === "ascend_soul" || ef.type === "bubble") continue;
if (isEntityVisibleInRect(ef, visibleRect)) {
ef.draw(ctx);
drawnEffects += 1;
@ -2093,12 +2312,12 @@ function render() {
}
if (window.TarinaiPerf?.diagnosticsEnabled?.() === true) {
world._effectRenderStats = {
total: totalEffects, drawn: drawnEffects, skipped: Math.max(0, totalEffects - drawnEffects),
total: totalEffects, drawn: drawnEffects + drawnAscensions + drawnBubbles, skipped: Math.max(0, totalEffects - drawnEffects - drawnAscensions - drawnBubbles),
budget: maxEffects, scanCap: effectScanCap, level: effectLevel,
};
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects);
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects + drawnAscensions + drawnBubbles);
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects - drawnAscensions - drawnBubbles));
} else world._effectRenderStats = null;
renderPerfEnd(endEffects);
drawPressureSwitchRangeOverlay(ctx, world);

View file

@ -14,8 +14,9 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
grass: 1 << 3,
grass_bed: 1 << 4,
water: 1 << 5,
grave: 1 << 6,
});
const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water"]);
const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water", "grave"]);
function robotCleanerDefaultMask() {
return ROBOT_DEFAULT_MASK;
@ -28,7 +29,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
function setRobotCleanerMask(item, mask = ROBOT_DEFAULT_MASK) {
if (!item || item.type !== "robot_cleaner") return false;
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(mask) | 0));
item.robotCleanerMask = Math.max(0, Math.min(0x7f, Number(mask) | 0));
return true;
}
@ -62,6 +63,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
case "grass": return "\u8349";
case "grass_bed": return "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9";
case "water": return "\u6c34\u6ef4";
case "grave": return "\u5893";
default: return "\u5bfe\u8c61";
}
}
@ -69,17 +71,65 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
function normalizeRobotCleanerState(item) {
if (!item || item.type !== "robot_cleaner") return null;
if (!Number.isFinite(Number(item.robotCleanerMask))) item.robotCleanerMask = ROBOT_DEFAULT_MASK;
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask) | 0));
item.robotCleanerMask = Math.max(0, Math.min(0x7f, Number(item.robotCleanerMask) | 0));
item.amount = Math.max(Number(item.amount || 0) || 0, 999);
if (!Number.isFinite(Number(item.robotCleanerScanTimer))) item.robotCleanerScanTimer = 0;
if (!Number.isFinite(Number(item.robotCleanerCleanFlash))) item.robotCleanerCleanFlash = 0;
if (!Number.isFinite(Number(item.robotCleanerAngle))) item.robotCleanerAngle = Number(item.angle || 0) || 0;
if (!Number.isFinite(Number(item.robotCleanerWanderAngle))) item.robotCleanerWanderAngle = Number(item.robotCleanerAngle || 0) || 0;
if (!item.robotCleanerUnreachableTargets || typeof item.robotCleanerUnreachableTargets !== "object") item.robotCleanerUnreachableTargets = Object.create(null);
if (!Number.isFinite(Number(item.robotCleanerPathFailTimer))) item.robotCleanerPathFailTimer = 0;
if (!Number.isFinite(Number(item.robotCleanerProgressTimer))) item.robotCleanerProgressTimer = 0;
if (!Number.isFinite(Number(item.robotCleanerProgressDistance))) item.robotCleanerProgressDistance = Infinity;
if (typeof item.robotCleanerProgressTargetId !== "string") item.robotCleanerProgressTargetId = "";
item.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed);
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
return item;
}
function robotTargetRuntimeId(target) {
return String(target?.id || target?.runtimeId || target?._runtimeId || "");
}
function robotTargetTemporarilyUnreachable(item, target, worldRef) {
const id = robotTargetRuntimeId(target);
if (!id) return false;
const record = item?.robotCleanerUnreachableTargets?.[id];
if (!record) return false;
const now = Number(worldRef?.time || 0) || 0;
if (typeof record === "number") return record > now;
const moved = distXY(Number(record.x || 0), Number(record.y || 0), Number(target.x || 0), Number(target.y || 0)) > 18;
const routeChanged = Number(record.obstacleVersion || 0) !== Number(worldRef?.routingObstacleVersion || 0);
if (moved || routeChanged || Number(record.until || 0) <= now) {
delete item.robotCleanerUnreachableTargets[id];
return false;
}
return true;
}
function markRobotTargetUnreachable(item, target, worldRef, seconds = 60) {
if (!item || !target) return false;
const id = robotTargetRuntimeId(target);
if (!id) return false;
if (!item.robotCleanerUnreachableTargets || typeof item.robotCleanerUnreachableTargets !== "object") item.robotCleanerUnreachableTargets = Object.create(null);
item.robotCleanerUnreachableTargets[id] = {
until: (Number(worldRef?.time || 0) || 0) + Math.max(15, Number(seconds || 60) || 60),
x: Number(target.x || 0) || 0,
y: Number(target.y || 0) || 0,
obstacleVersion: Number(worldRef?.routingObstacleVersion || 0) || 0,
};
if (String(item.robotCleanerTargetId || "") === id) {
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
item.robotCleanerScanTimer = 0;
}
item.robotCleanerPathFailTimer = 0;
item.robotCleanerProgressTimer = 0;
item.robotCleanerProgressDistance = Infinity;
item.robotCleanerProgressTargetId = "";
return true;
}
function robotTargetAlive(target) {
if (!target) return false;
return !target.dead && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y));
@ -96,12 +146,13 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
if (!key || !id || !worldRef) return null;
if (key === "tarinai") {
const t = worldRef.liveTarinaiById?.(id) || (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead);
return robotTargetAlive(t) ? t : null;
return robotTargetAlive(t) && !robotTargetTemporarilyUnreachable(item, t, worldRef) ? t : null;
}
const byId = typeof worldRef.itemById === "function" ? worldRef.itemById(id) : null;
const found = byId || (worldRef.items || []).find(it => it && it.id === id && !it.dead);
const found = byId || worldRef.residueByRuntimeId?.(id) || (worldRef.items || []).find(it => it && it.id === id && !it.dead);
const matchesKey = found && (found.type === key || (key === "ant_corpse" && (found.type === "trace" || found.type === "splat")));
return matchesKey && robotTargetAlive(found) ? found : null;
if (!matchesKey || !robotTargetAlive(found) || robotTargetTemporarilyUnreachable(item, found, worldRef)) return null;
return found;
}
function robotTargetCandidates(item, worldRef) {
@ -116,15 +167,22 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
for (const t of worldRef.tarinai || []) {
if (!t || t.dead) continue;
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (robotTargetTemporarilyUnreachable(item, t, worldRef)) continue;
out.push({ target: t, type: key });
}
continue;
}
const buckets = key === "ant_corpse"
? ["ant_corpse", "trace", "splat"].flatMap(type => worldRef.itemsOfType?.(type) || (worldRef.items || []).filter(it => it && it.type === type))
? [
...(worldRef.itemsOfType?.("ant_corpse") || []),
...(worldRef.residuesOfType?.("trace") || []),
...(worldRef.residuesOfType?.("splat") || []),
]
: (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key));
for (const it of buckets || []) {
if (!it || it.dead || it === item) continue;
if (key === "grave" && it.memorialFavorite) continue;
if (robotTargetTemporarilyUnreachable(item, it, worldRef)) continue;
out.push({ target: it, type: key });
}
}
@ -150,8 +208,14 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
if (score < bestD) { best = target; bestType = rec.type; bestD = score; }
}
if (best) {
item.robotCleanerTargetId = best.id || "";
item.robotCleanerTargetId = best.id || best.runtimeId || best._runtimeId || "";
item.robotCleanerTargetType = bestType || best.type || "";
if (item.robotCleanerProgressTargetId !== String(item.robotCleanerTargetId || "")) {
item.robotCleanerProgressTargetId = String(item.robotCleanerTargetId || "");
item.robotCleanerProgressDistance = distXY(item.x, item.y, best.x, best.y);
item.robotCleanerProgressTimer = 0;
item.robotCleanerPathFailTimer = 0;
}
}
return best;
}
@ -199,6 +263,10 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
function cleanRobotTarget(item, target, worldRef, typeOverride = "") {
if (!item || !target || !worldRef || target.dead) return false;
if (target.type === "grave" && target.memorialFavorite) {
clearRobotTrackedTargetIfMatched(item, target);
return false;
}
const type = String(typeOverride || item.robotCleanerTargetType || target.type || "");
const label = robotTargetLabel(type);
let removed = false;
@ -215,6 +283,8 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
worldRef.markDead?.(target, cleanReason);
removed = true;
}
} else if (type === "trace" || type === "splat") {
removed = Boolean(worldRef.removeResidue?.(target, "robot-cleaner"));
} else {
removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, {
reason: "robot-cleaner",
@ -237,7 +307,6 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
worldRef.queueEffect?.("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" });
worldRef.markItemBucketsDirty?.("robot-cleaner");
worldRef.markSpatialDirty?.("robot-cleaner");
if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner");
worldRef.updateItemCounts?.();
worldRef.drawListDirty = true;
return true;
@ -281,11 +350,24 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
preferFullPath: true,
maxChecks: 54,
});
if (waypoint) moveTarget = waypoint;
else desiredSpeed = speed * 0.35;
if (waypoint) {
moveTarget = waypoint;
item.robotCleanerPathFailTimer = 0;
} else {
item.robotCleanerPathFailTimer = Math.max(0, Number(item.robotCleanerPathFailTimer || 0) || 0) + dt;
desiredSpeed = speed * 0.18;
if (item.robotCleanerPathFailTimer >= 0.75) {
markRobotTargetUnreachable(item, target, worldRef, 90);
target = null;
desiredSpeed = speed * 0.62;
}
}
} else {
item.robotCleanerPathFailTimer = 0;
}
desiredAngle = Math.atan2((moveTarget.y || 0) - (item.y || 0), (moveTarget.x || 0) - (item.x || 0));
} else if ((item.robotCleanerWanderTimer || 0) <= 0) {
if (target) desiredAngle = Math.atan2((moveTarget.y || 0) - (item.y || 0), (moveTarget.x || 0) - (item.x || 0));
}
if (!target && (item.robotCleanerWanderTimer || 0) <= 0) {
desiredAngle = global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2));
item.robotCleanerWanderTimer = global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item);
}
@ -300,6 +382,28 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
item.prevY = item.y;
item.x += item.vx * dt;
item.y += item.vy * dt;
if (target && robotTargetAlive(target) && !isRobotTarinaiTarget(target)) {
const targetId = robotTargetRuntimeId(target);
const currentDistance = distXY(item.x, item.y, target.x, target.y);
if (item.robotCleanerProgressTargetId !== targetId) {
item.robotCleanerProgressTargetId = targetId;
item.robotCleanerProgressDistance = currentDistance;
item.robotCleanerProgressTimer = 0;
} else if (currentDistance <= Number(item.robotCleanerProgressDistance || Infinity) - 7) {
item.robotCleanerProgressDistance = currentDistance;
item.robotCleanerProgressTimer = 0;
} else {
item.robotCleanerProgressTimer = Math.max(0, Number(item.robotCleanerProgressTimer || 0) || 0) + dt;
if (item.robotCleanerProgressTimer >= 3.0 && currentDistance > robotCleanerCaptureRadius(item, target) * 1.35) {
markRobotTargetUnreachable(item, target, worldRef, 75);
target = null;
}
}
} else if (!target) {
item.robotCleanerProgressTargetId = "";
item.robotCleanerProgressDistance = Infinity;
item.robotCleanerProgressTimer = 0;
}
const minX = CONFIG.worldPadding + r * 0.5;
const minY = CONFIG.worldPadding + r * 0.5;
const maxX = Math.max(minX, (worldRef.w || 0) - CONFIG.worldPadding - r * 0.5);

View file

@ -8,7 +8,7 @@
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 = 40;
const SNAPSHOT_VERSION = 41;
const BINARY_SCHEMA_VERSION = 53;
function moodValue(index, fallback = "relaxed") {

View file

@ -185,7 +185,7 @@
const side = index === 1 ? 1 : -1;
const lift = Math.sin(item.seesawPhase) * 9 * side;
actor.x = base.x;
actor.y = base.y - 12 + (playing ? lift : 0);
actor.y = base.y - 16 + (playing ? lift : 0);
actor.vx = 0; actor.vy = 0;
const actionText = playing ? "\u30b7\u30fc\u30bd\u30fc\u3092\u3057\u3066\u3044\u308b\u3002" : "\u76f8\u624b\u3092\u5f85\u3063\u3066\u3044\u308b";
actor.thought = actionText;

View file

@ -5,7 +5,7 @@
const WIRE_TYPES = new Set(["wire", "insulated_wire"]);
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
const SIGNAL_SOURCE_TYPES = new Set(["pressure_switch"]);
const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator"]);
const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator", "leakage_device"]);
const DETECTOR_METRICS = Object.freeze({
tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 },
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 },
@ -154,9 +154,10 @@
return actors.length;
}
function updatePressureSwitches(worldRef) {
function updatePressureSwitches(worldRef, pressureSwitches = null) {
const minute = currentMinuteOfDay(worldRef);
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
const switches = pressureSwitches || worldRef.itemsOfType?.("pressure_switch") || [];
for (const item of switches) {
if (!item || item.dead) continue;
item.pressureTarget = normalizePressureTarget(item.pressureTarget);
const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1;
@ -205,7 +206,62 @@
}
}
function buildSignalNetwork(worldRef) {
function collectSignalSinks(worldRef, out) {
if (!worldRef || !out) return out;
if (typeof worldRef.itemsOfType === "function") {
for (const type of DIRECT_SIGNAL_SINK_TYPES) {
const bucket = worldRef.itemsOfType(type) || [];
if (bucket.length) out.sinks.push(...bucket);
}
return out;
}
for (const item of worldRef.items || []) {
if (item && !item.dead && DIRECT_SIGNAL_SINK_TYPES.has(item.type)) out.sinks.push(item);
}
return out;
}
function collectSignalBuckets(worldRef, includeSinks = true) {
const out = { pressureSwitches: [], boards: [], wires: [], sinks: [] };
if (!worldRef) return out;
if (typeof worldRef.itemsOfType === "function") {
out.pressureSwitches = worldRef.itemsOfType("pressure_switch") || [];
out.boards = worldRef.itemsOfType("circuit_board") || [];
out.wires = [
...(worldRef.itemsOfType("wire") || []),
...(worldRef.itemsOfType("insulated_wire") || []),
];
if (includeSinks) collectSignalSinks(worldRef, out);
return out;
}
// Compatibility fallback for lightweight test worlds that do not expose
// item buckets. Production worlds use the branch above and avoid a full
// world.items scan entirely.
for (const item of worldRef.items || []) {
if (!item || item.dead) continue;
if (item.type === "pressure_switch") out.pressureSwitches.push(item);
else if (item.type === "circuit_board") out.boards.push(item);
if (WIRE_TYPES.has(item.type)) out.wires.push(item);
if (includeSinks && DIRECT_SIGNAL_SINK_TYPES.has(item.type)) out.sinks.push(item);
}
return out;
}
function clearSignalSinks(worldRef, sinks) {
let changed = false;
for (const item of sinks || []) {
if (!item || item.dead) continue;
if (item._signalDriven || item._signalOn || item._wireSignalDriven || item._wireSignalOn) changed = true;
item._wireSignalDriven = false;
item._wireSignalOn = false;
setEffectivePower(item, false, false, worldRef);
}
if (changed) worldRef.drawListDirty = true;
return changed;
}
function buildSignalNetwork(worldRef, signalBuckets = null) {
const buckets = signalBuckets || collectSignalBuckets(worldRef);
const adjacency = new Map();
const connectedNodes = new Set();
const wires = [];
@ -219,20 +275,19 @@
connectedNodes.add(a); connectedNodes.add(b);
};
const boards = [];
for (const item of worldRef.items || []) {
for (const item of buckets.sinks || []) {
if (!item || item.dead) continue;
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type)) {
item._wireSignalDriven = false; item._wireSignalOn = false;
item._directSignalDriven = false; item._directSignalOn = false;
}
if (item.type === "circuit_board") {
circuit?.ensureConfig?.(item);
item._circuitInputState = Object.create(null);
if (!item._circuitOutputState || typeof item._circuitOutputState !== "object") circuit?.evaluate?.(item, item._circuitInputState);
boards.push(item);
}
item._wireSignalDriven = false; item._wireSignalOn = false;
item._directSignalDriven = false; item._directSignalOn = false;
}
for (const wire of worldRef.items || []) {
for (const item of buckets.boards || []) {
if (!item || item.dead) continue;
circuit?.ensureConfig?.(item);
item._circuitInputState = Object.create(null);
if (!item._circuitOutputState || typeof item._circuitOutputState !== "object") circuit?.evaluate?.(item, item._circuitInputState);
boards.push(item);
}
for (const wire of buckets.wires || []) {
if (!isWire(wire)) continue;
const pb = global.TarinaiPhysicsBodySystem;
const endpointA = pb?.endpoint?.(wire, 0);
@ -324,13 +379,10 @@
global.TarinaiAchievements?.recordCircuitBoardSignalTransfer?.(sourceBoard, targetBoard, entry.wire, { world: worldRef });
}
for (const item of worldRef.items || []) {
if (!item || item.dead) continue;
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !componentOf.has(item)) {
item._wireSignalDriven = false; item._wireSignalOn = false;
setEffectivePower(item, false, false, worldRef);
}
if (isWire(item) && !wires.some(entry => entry.wire === item)) item.signalActive = false;
for (const item of buckets.sinks || []) {
if (!item || item.dead || componentOf.has(item)) continue;
item._wireSignalDriven = false; item._wireSignalOn = false;
setEffectivePower(item, false, false, worldRef);
}
if (wireGeometryMoved) {
worldRef.drawListDirty = true;
@ -339,8 +391,6 @@
return wires;
}
const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
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();
@ -391,13 +441,13 @@
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;
if (global.TarinaiGeometry.pointSegmentDistance(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;
if (global.TarinaiGeometry.pointSegmentDistance(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 });
}
}
@ -409,10 +459,30 @@
}
function updateWorld(worldRef, dt = 0.016) {
if (!worldRef?.items) return false;
updatePressureSwitches(worldRef);
const wires = buildSignalNetwork(worldRef);
damageFromWires(worldRef, wires);
if (!worldRef) return false;
const buckets = collectSignalBuckets(worldRef, false);
const hasSwitches = buckets.pressureSwitches.length > 0;
const hasNetwork = buckets.wires.length > 0 || buckets.boards.length > 0;
// Normal worlds with no signal controllers stop after four O(1) bucket
// lookups. Sink buckets are only touched while a network exists or once
// when a removed network needs to clear stale power flags.
if (!hasSwitches && !hasNetwork && !worldRef._signalSystemHadNetwork) return false;
if (hasSwitches) updatePressureSwitches(worldRef, buckets.pressureSwitches);
if (!hasNetwork) {
if (worldRef._signalSystemHadNetwork) {
collectSignalSinks(worldRef, buckets);
clearSignalSinks(worldRef, buckets.sinks);
}
worldRef._signalSystemHadNetwork = false;
return hasSwitches;
}
collectSignalSinks(worldRef, buckets);
const wires = buildSignalNetwork(worldRef, buckets);
worldRef._signalSystemHadNetwork = true;
if (wires.length) damageFromWires(worldRef, wires);
return true;
}
@ -429,5 +499,6 @@
DETECTOR_METRICS,
PRESSURE_TARGETS,
detectorMetricValue,
normalizeMinute,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -109,8 +109,12 @@ function inheritedGenetics(a, b, seed = "") {
const fallback = key === "temperatureOffset" ? 0 : 1;
const av = Number.isFinite(Number(ga[key])) ? Number(ga[key]) : fallback;
const bv = Number.isFinite(Number(gb[key])) ? Number(gb[key]) : fallback;
const base = (av + bv) / 2;
const mutationScale = key === "temperatureOffset" ? 2.5 : 0.10;
// Keep offspring much closer to the parental range. A small deterministic
// dominance bias prevents every lineage from collapsing immediately to the
// exact midpoint, while mutation is intentionally modest.
const aWeight = 0.42 + stableUnit(seed, `gene-parent-weight-${key}`) * 0.16;
const base = av * aWeight + bv * (1 - aWeight);
const mutationScale = key === "temperatureOffset" ? 0.6 : 0.024;
const mutation = (stableUnit(seed, `gene-mut-${key}`) - 0.5) * mutationScale;
out[key] = clampGeneticValue(key, base + mutation);
}
@ -419,8 +423,10 @@ class Effect {
this.color = options.color || "#fff4a8";
this.seed = Math.random() * 1000;
this.text = options.text || "";
this.spriteId = options.spriteId || (type === "ascend_soul" ? "sleep" : "");
this._effectPooled = false;
this._effectUpdatedFrame = 0;
this._forceVisual = options.forceVisual === true || type === "ascend_soul";
return this;
}
@ -428,6 +434,11 @@ class Effect {
this.life -= dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
if (this.type === "ascend_soul") {
this.vx *= Math.pow(0.985, dt * 60);
this.vy *= Math.pow(0.997, dt * 60);
return;
}
this.vx *= Math.pow(0.92, dt * 60);
this.vy *= Math.pow(0.92, dt * 60);
}
@ -455,6 +466,42 @@ class Effect {
const s = this.size * (0.80 + (1 - alpha) * 0.36);
ctx.rotate(Math.sin(this.seed + (1 - alpha) * 3.2) * 0.18);
drawHeartShape(ctx, 0, 0, s, { fill: this.color || "rgba(240,91,135,0.92)", stroke: "rgba(255,252,246,0.88)", alpha: 1 });
} else if (this.type === "ascend_soul") {
const phase = 1 - alpha;
const spriteId = this.spriteId || "sleep";
const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, spriteId) : null;
const sway = Math.sin(this.seed + phase * Math.PI * 3.2) * this.size * 0.12;
const scale = 0.86 + phase * 0.18;
ctx.save();
ctx.globalCompositeOperation = "screen";
const pillarH = this.size * 6.4;
const pillarW = this.size * (1.35 + phase * 0.35);
const gradient = ctx.createLinearGradient(0, -pillarH, 0, this.size * 0.8);
gradient.addColorStop(0, "rgba(244,248,255,0)");
gradient.addColorStop(0.28, `rgba(238,246,255,${0.16 * alpha})`);
gradient.addColorStop(0.72, `rgba(255,247,207,${0.30 * alpha})`);
gradient.addColorStop(1, "rgba(255,244,194,0)");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.ellipse(0, -pillarH * 0.40, pillarW * 0.58, pillarH * 0.58, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = Math.min(0.75, alpha * 0.62);
ctx.fillStyle = "rgba(255,250,220,0.72)";
ctx.fillRect(-Math.max(1.5, this.size * 0.075), -pillarH * 0.92, Math.max(3, this.size * 0.15), pillarH * 0.95);
ctx.restore();
ctx.translate(sway, 0);
ctx.globalAlpha = Math.min(1, alpha * 1.18) * (0.82 + Math.sin(phase * Math.PI) * 0.18);
if (img && img.complete && (img.naturalWidth || img.width)) {
const ratio = (img.naturalHeight || img.height || 1) / Math.max(1, (img.naturalWidth || img.width || 1));
const w = this.size * 2.25 * scale;
const h = w * ratio;
ctx.drawImage(img, -w * 0.5, -h * 0.72, w, h);
} else {
ctx.fillStyle = "rgba(245,248,255,0.72)";
ctx.beginPath();
ctx.ellipse(0, -this.size * 0.35, this.size * 0.62, this.size * 0.46, 0, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "ring") {
ctx.strokeStyle = this.color;
ctx.lineWidth = 2;
@ -671,7 +718,6 @@ globalThis.TarinaiEffect = Effect;
class SpatialGrid {
constructor(cellSize = 96) {
this.cellSize = cellSize;
this.itemCells = new Map();
this.staticItemCells = new Map();
this.dynamicItemCells = new Map();
// Grass is passive terrain/food. Keep a second item index without grass so
@ -687,9 +733,6 @@ class SpatialGrid {
this._indexedTarinai = new Set();
this._tarinaiSyncStamp = 0;
this.antCells = new Map();
this.obstacleCells = new Map();
this.foodCells = new Map();
this.hazardCells = new Map();
this.staticObstacleCells = new Map();
this.dynamicObstacleCells = new Map();
this.staticFoodCells = new Map();
@ -705,16 +748,12 @@ class SpatialGrid {
}
clearItems() {
this.itemCells.clear();
this.staticItemCells.clear();
this.dynamicItemCells.clear();
this.staticNonGrassItemCells.clear();
this.dynamicNonGrassItemCells.clear();
this.grassCells.clear();
this.linkRenderCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.staticObstacleCells.clear();
this.dynamicObstacleCells.clear();
this.staticFoodCells.clear();
@ -916,7 +955,6 @@ class SpatialGrid {
this.add(target, it);
if (it.type === "grass") this.add(this.grassCells, it);
else this.add(dynamic ? this.dynamicNonGrassItemCells : this.staticNonGrassItemCells, it);
this.add(this.itemCells, it);
this.addLinkRenderItem(it);
this.addItemToTraitCells(it, dynamic);
}
@ -924,7 +962,6 @@ class SpatialGrid {
rebuildItems(items) {
this.clearItems();
for (const it of items || []) this.classifyItem(it);
this.rebuildCombinedItemCells();
}
rebuildStaticItems(items, opts = {}) {
@ -941,7 +978,6 @@ class SpatialGrid {
else this.add(this.staticNonGrassItemCells, it);
this.addItemToTraitCells(it, false);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
rebuildLinkRenderItems(items) {
@ -961,33 +997,6 @@ class SpatialGrid {
if (it.type !== "grass") this.add(this.dynamicNonGrassItemCells, it);
this.addItemToTraitCells(it, true);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
copyCells(src, dst) {
for (const [key, bucket] of src.entries()) {
let out = dst.get(key);
if (!out) {
out = [];
dst.set(key, out);
}
for (const entity of bucket) out.push(entity);
}
}
rebuildCombinedItemCells() {
this.itemCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.copyCells(this.staticItemCells, this.itemCells);
this.copyCells(this.dynamicItemCells, this.itemCells);
this.copyCells(this.staticObstacleCells, this.obstacleCells);
this.copyCells(this.dynamicObstacleCells, this.obstacleCells);
this.copyCells(this.staticFoodCells, this.foodCells);
this.copyCells(this.dynamicFoodCells, this.foodCells);
this.copyCells(this.staticHazardCells, this.hazardCells);
this.copyCells(this.dynamicHazardCells, this.hazardCells);
}
rebuildTarinai(tarinai) {

View file

@ -264,14 +264,13 @@
stats.full += 1;
if (cadence.visible) stats.visibleFullUsed += 1;
if (usesBackgroundBudget) stats.fullBudgetUsed += 1;
// Off-screen creatures used to receive the accumulated AI/timer dt but
// only one rendered-frame worth of movement (about 0.016s). At high
// zoom this effectively froze creatures after they left the viewport.
// Catch up a bounded amount of motion on their scheduled full update;
// the movement step uses the light collision path for this catch-up.
// Camera zoom must never change simulation travel distance. Off-screen
// creatures receive the same accumulated movement time as metabolism,
// AI timers, and health. The light collision path keeps the catch-up
// affordable while moveTarinaiWithCollision still substeps long travel.
const runContext = cadence.visible
? context
: { ...context, motionDt: Math.min(runDt, cadence.near ? 0.45 : 0.75), offscreenCatchup: true };
: { ...context, motionDt: Math.min(runDt, 2.4), offscreenCatchup: true };
global.TarinaiCreatureRuntime.updateOne(t, runDt, { context: runContext });
} else if (cadence.smoothMotion) {
if (cadence.visible && stats.visibleMotionUsed >= stats.visibleMotionBudget) {

View file

@ -4,8 +4,10 @@
// Owns frame updates for transient visual/effect entities.
(function (global) {
function effectPriority(effect) {
const explicit = Number(effect?._effectImportance);
if (Number.isFinite(explicit)) return Math.max(0, Math.min(3, explicit));
const type = String(effect?.type || "");
if (type === "explosion" || type === "shoot_impact" || type === "bubble" || type === "heart" || type === "electric_shock") return 3;
if (type === "explosion" || type === "shoot_impact" || type === "bubble" || type === "heart" || type === "electric_shock" || type === "ascend_soul") return 3;
if (type === "ring" || type === "fall") return 2;
return 1;
}
@ -29,6 +31,7 @@
for (let priority = 1; priority <= 3 && needDrop > 0; priority += 1) {
for (let i = 0; i < effects.length && needDrop > 0; i += 1) {
if (dropMask[i] || effectPriority(effects[i]) !== priority) continue;
if (effects[i]?._forceVisual === true || effects[i]?.type === "bubble" || effects[i]?.type === "ascend_soul") continue;
dropMask[i] = 1;
needDrop -= 1;
}
@ -60,18 +63,33 @@
return true;
}
function clearDisabledEffects(worldRef, effects) {
if (!effects?.length) return;
for (const effect of effects) if (effect) worldRef.releaseEffect?.(effect);
effects.length = 0;
function clearDisabledEffects(worldRef, effects, dt) {
if (!effects?.length) return 0;
let write = 0;
let updated = 0;
for (const effect of effects) {
if (!effect) continue;
const forced = effect._forceVisual === true || effect.type === "bubble" || effect.type === "ascend_soul";
if (!forced) {
worldRef.releaseEffect?.(effect);
continue;
}
if (!effect.dead) effect.update(Math.min(0.24, Math.max(0, dt)));
if (!effect.dead) {
effects[write++] = effect;
updated += 1;
} else worldRef.releaseEffect?.(effect);
}
effects.length = write;
worldRef._effectUpdateCursor = 0;
worldRef._renderEffectCursor = 0;
return updated;
}
function updateEffects(worldRef, dt) {
const effects = worldRef.effects || [];
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") {
clearDisabledEffects(worldRef, effects);
clearDisabledEffects(worldRef, effects, dt);
worldRef._effectUpdateStats = null;
return;
}

View file

@ -24,6 +24,9 @@
const now = Number(worldRef.time || 0) || 0;
let itemsCompacted = false;
worldRef.updateResidues?.(dt);
global.TarinaiMemorialBellSystem?.updateWorldNaturalDecay?.(worldRef);
// Link/plushie reconciliation is a safety net. Deletions already reconcile
// dependencies immediately, so a periodic pass is sufficient during normal play.
if (now >= (worldRef._nextStructureDependencyCheckAt || 0)) {

View file

@ -45,11 +45,9 @@
if (!worldRef) return false;
const thresholdSq = threshold * threshold;
let moved = false;
const tracked = global.TarinaiItemRuntime.trackedDynamicItems(worldRef);
const source = tracked && tracked.length ? tracked : null;
const tracked = global.TarinaiItemRuntime.trackedDynamicItems(worldRef) || [];
const visit = (entity) => { moved = trackSpatialEntityMoved(entity, thresholdSq) || moved; };
if (source) for (const entity of source) visit(entity);
else forScheduledItems(worldRef, visit);
for (const entity of tracked) visit(entity);
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}

View file

@ -317,8 +317,10 @@
(worldRef.items || []).forEach((item, index) => {
const row = Array.isArray(itemRows[index]) ? itemRows[index] : [];
item._achievementPlayerPlaced = Boolean(row[0]);
item._achievementPlayerPlacedCounted = item._achievementPlayerPlaced;
item._achievementPlacedAt = row[1] != null && Number.isFinite(Number(row[1])) ? Number(row[1]) : null;
});
worldRef.achievementPlayerPlacedActiveCount = (worldRef.items || []).reduce((count, item) => count + (item && !item.dead && item._achievementPlayerPlaced ? 1 : 0), 0);
return true;
}
@ -434,12 +436,25 @@
const owner = tarinaiIndex.get(item.ownerId) ?? -1;
const carried = item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1;
const consumed = item.singleUseConsumedById ? (tarinaiIndex.get(item.singleUseConsumedById) ?? -1) : -1;
return [q(item.hp, 10), q(item.maxHp, 10), owner, carried, item.singleUsePending ? 1 : 0, consumed, q(item.createdAt, 10)];
const base = [q(item.hp, 10), q(item.maxHp, 10), owner, carried, item.singleUsePending ? 1 : 0, consumed, q(item.createdAt, 10)];
if (type === "grave") return base.concat([
String(item.memorialFamilyKey || ""),
String(item.memorialName || ""),
String(item.memorialType || ""),
String(item.memorialDeathReason || ""),
q(item.memorialDeathTime, 10, -1),
String(item.memorialBuilderFamilyKey || ""),
JSON.stringify(Array.isArray(item.memorialEligibleFamilyKeys) ? item.memorialEligibleFamilyKeys : []),
item.memorialFavorite ? 1 : 0,
item.memorialTarinaiKing ? 1 : 0,
item.memorialZunchiSlave ? 1 : 0,
]);
return base;
}
if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0];
if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)];
if (type === "signboard") return [item.text || ""];
if (type === "robot_cleaner") return [Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0];
if (type === "robot_cleaner") return [Math.max(0, Math.min(0x7f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0];
if (type === "circuit_board") return [JSON.stringify(global.TarinaiCircuitBoardSystem?.serializeConfig?.(item) || item.circuitConfig || {})];
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex);
@ -482,9 +497,32 @@
}
function compactItem(item, index, tarinaiIndex = new Map(), itemIndex = new Map()) {
const typeId = itemTypeIndex(item?.type || "", -1);
if (typeId < 0 || item?.type === "trace") return null;
if (typeId < 0) return null;
return [typeId, q(item?.x, 1), q(item?.y, 1), q(item?.amount ?? item?.hp, 10), itemExtra(item, tarinaiIndex, itemIndex)];
}
function compactResidue(residue) {
if (!residue || residue.dead || Number(residue.amount || 0) <= 0) return null;
const typeCode = residue.type === "splat" ? 1 : (residue.type === "trace" ? 0 : -1);
if (typeCode < 0) return null;
return [
typeCode,
q(residue.x, 1),
q(residue.y, 1),
q(residue.amount, 10),
q(residue.r, 10, typeCode === 1 ? 260 : 160),
q(residue.seed, 100, 0),
];
}
function restoreResidueRow(worldRef, row) {
if (!worldRef || !Array.isArray(row)) return null;
const type = Number(row[0]) === 1 ? "splat" : "trace";
return worldRef.addResidue?.(type, u(row[1], 1), u(row[2], 1), {
amount: u(row[3], 10, type === "splat" ? 260 : 220),
r: u(row[4], 10, type === "splat" ? 26 : 16),
seed: u(row[5], 100, 0),
reason: "restore-residue",
}) || null;
}
function applyItemExtra(item, extra, tarinaiList = [], itemList = []) {
const type = item.type || "";
if (global.TarinaiPhysicsBodySystem.applyCompactExtra(item, extra, tarinaiList, itemList)) return;
@ -504,6 +542,19 @@
if (type === "plushie") {
item.onHead = Boolean(item.carriedById);
}
if (type === "grave") {
item.memorialFamilyKey = String(extra[7] || "");
item.memorialName = String(extra[8] || "");
item.memorialType = String(extra[9] || "");
item.memorialDeathReason = String(extra[10] || "");
item.memorialDeathTime = Number(extra[11]) >= 0 ? u(extra[11], 10, null) : null;
item.memorialBuilderFamilyKey = String(extra[12] || "");
try { item.memorialEligibleFamilyKeys = JSON.parse(String(extra[13] || "[]")); } catch (_) { item.memorialEligibleFamilyKeys = []; }
if (!Array.isArray(item.memorialEligibleFamilyKeys)) item.memorialEligibleFamilyKeys = [];
item.memorialFavorite = Boolean(extra[14]);
item.memorialTarinaiKing = Boolean(extra[15]);
item.memorialZunchiSlave = Boolean(extra[16]);
}
const def = global.StructureRegistry?.get?.(type);
if (def) {
item.label = def.label;
@ -521,7 +572,7 @@
item.text = String(extra[0] || "");
} else if (type === "robot_cleaner") {
const mask = Number(extra[0]);
item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3);
item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x7f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3);
item.robotCleanerHighSpeed = Boolean(extra[1]);
item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60);
item.robotCleanerTargetId = "";
@ -598,7 +649,7 @@
tarinaiLive.forEach((t, i) => { if (t.id) tarinaiIndex.set(t.id, i); if (t.familyKey) familyIndex.set(t.familyKey, i); });
const itemRecords = [];
for (const it of (worldRef.items || [])) {
if (!it || it.dead || it.type === "trace") continue;
if (!it || it.dead || it.type === "trace" || it.type === "splat") continue;
const typeId = itemTypeIndex(it.type || "", -1);
if (typeId < 0) continue;
itemRecords.push({ item: it, sortTypeId: typeId, sortX: q(it.x, 1), sortY: q(it.y, 1) });
@ -606,6 +657,12 @@
itemRecords.sort((a, b) => (a.sortTypeId || 0) - (b.sortTypeId || 0) || mortonKeyXY(a.sortX, a.sortY) - mortonKeyXY(b.sortX, b.sortY) || String(a.item?.id || "").localeCompare(String(b.item?.id || "")));
const itemIndex = new Map();
itemRecords.forEach((rec, i) => { if (rec.item?.id) itemIndex.set(rec.item.id, i); });
const residueRows = (worldRef.residues || [])
.filter(residue => residue && !residue.dead && Number(residue.amount || 0) > 0)
.slice()
.sort((a, b) => String(a.type || "").localeCompare(String(b.type || "")) || mortonKeyXY(a.x, a.y) - mortonKeyXY(b.x, b.y))
.map(compactResidue)
.filter(Boolean);
const itemRows = itemRecords.map((rec, i) => compactItem(rec.item, i, tarinaiIndex, itemIndex)).filter(Boolean);
return {
v: SNAPSHOT_VERSION,
@ -614,6 +671,7 @@
w: compactWorld(worldRef),
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
i: itemRows,
r: residueRows,
g: compactAchievementMetadata(worldRef, tarinaiLive, itemRecords, options),
};
}
@ -674,6 +732,7 @@
applyItemExtra(item, item.__savedExtra || [], worldRef.tarinai, worldRef.items);
delete item.__savedExtra;
}
for (const row of (Array.isArray(snapshot.r) ? snapshot.r : [])) restoreResidueRow(worldRef, row);
for (const t of worldRef.tarinai) {
if (Number.isInteger(t.__savedPinIdx) && t.__savedPinIdx >= 0) t.stuckPushpinId = worldRef.items[t.__savedPinIdx]?.id || null;
if (Number.isInteger(t.__savedNestIdx) && t.__savedNestIdx >= 0) t.insideNestBoxId = worldRef.items[t.__savedNestIdx]?.id || null;

View file

@ -23,6 +23,7 @@
sfx_firecracker_explode: 1.70,
sfx_shoot: 0.725,
sfx_bolt_action: 1.35,
sfx_bell: 1.00,
voice_major_damage_01: 1.0,
voice_major_damage_02: 1.0,
voice_major_damage_03: 1.0,
@ -59,6 +60,7 @@
sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3",
sfx_shoot: "assets/sounds/shoot_pistol.mp3",
sfx_bolt_action: "assets/sounds/shoot_bolt_action.mp3",
sfx_bell: "assets/sounds/bicycle_bell.mp3",
voice_major_damage_01: "assets/sounds/major_damage_01.wav",
voice_major_damage_02: "assets/sounds/major_damage_02.wav",
voice_major_damage_03: "assets/sounds/major_damage_03.wav",
@ -83,7 +85,7 @@
sfx_ui_click: "ops", sfx_ui_fold: "ops", sfx_place: "ops", sfx_delete: "ops", sfx_poke: "ops",
sfx_grab: "ops", sfx_drop: "ops", sfx_water_hose: "ops", sfx_drink: "ops",
sfx_ball_hit: "ops", sfx_stone_impact: "ops", sfx_firecracker_fuse: "ops", sfx_firecracker_explode: "ops",
sfx_shoot: "ops", sfx_bolt_action: "ops",
sfx_shoot: "ops", sfx_bolt_action: "ops", sfx_bell: "ops",
sfx_genkotsu_fall: "ops", sfx_genkotsu_impact: "ops", sfx_ground_rumble: "ops", sfx_grass: "ops",
sfx_zunchi: "ops", sfx_ant_nest: "ops"
}

View file

@ -283,6 +283,7 @@
const changed = markStructureGone(structure, reason);
if (!changed) return false;
worldRef.noteItemInactive?.(structure, reason);
if (structure.type === "grave") global.TarinaiMemorialBellSystem?.onGraveDestroyed?.(worldRef, structure, opts.breaker || null);
removeDependentLinks(worldRef, structure, reason);
if (ownerPanic && opts.panicOwner !== false) triggerOwnedStructureDestroyedPanic(worldRef, structure, opts.breaker || null, reason);
markWorldAfterStructureChange(worldRef, reason);
@ -305,6 +306,7 @@
if (!item || item.dead) return false;
const reason = opts.reason || "delete-tool";
if (item.isStructure) return deleteStructure(worldRef, item, { reason, userReason: opts.userReason, wake: opts.wake, breaker: opts.breaker || null, panicOwner: opts.panicOwner });
if (item.type === "poison_gas_can") global.TarinaiItemEnvironmentHazardSystem?.spawnPoisonGas?.(worldRef, item);
const changed = markLooseItemGone(item, reason);
if (!changed) return false;
worldRef.noteItemInactive?.(item, reason);

View file

@ -103,6 +103,9 @@
this.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8);
}
if (this.type === "grave") {
if (global.TarinaiMemorialBellSystem?.updateGraveNaturalDecay?.(this, worldRef)) return;
}
if (this.type === "grass_bed") {
if (!Number.isFinite(this.hp) || this.hp <= 0) {
this.hp = Math.max(1, Number(this.maxHp || 100) || 100);
@ -258,6 +261,86 @@
ctx.beginPath();
ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "grave") {
const favoriteMemorial = Boolean(this.memorialFavorite);
const kingMemorial = Boolean(this.memorialTarinaiKing);
const slaveMemorial = Boolean(this.memorialZunchiSlave);
const graveScale = slaveMemorial ? 0.72 : (kingMemorial ? 1.10 : 1);
ctx.scale(graveScale, graveScale);
const stone = night ? (kingMemorial ? "rgb(139,139,149)" : favoriteMemorial ? "rgb(126,126,133)" : "rgb(111,112,116)") : (kingMemorial ? "rgb(197,190,174)" : favoriteMemorial ? "rgb(181,176,164)" : "rgb(163,158,149)");
const edge = night ? "rgb(58,60,66)" : "rgb(91,87,80)";
ctx.fillStyle = "rgba(56,47,40,0.18)";
ctx.beginPath(); ctx.ellipse(0, this.r * 0.58, this.r * 1.02, this.r * 0.28, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = stone;
ctx.strokeStyle = edge;
ctx.lineWidth = kingMemorial ? 2.6 : 2;
ctx.beginPath();
ctx.moveTo(-this.r * 0.78, this.r * 0.22);
ctx.bezierCurveTo(-this.r * 0.88, -this.r * 0.38, -this.r * 0.46, -this.r * 0.80, this.r * 0.06, -this.r * 0.72);
ctx.bezierCurveTo(this.r * 0.62, -this.r * 0.68, this.r * 0.88, -this.r * 0.20, this.r * 0.72, this.r * 0.30);
ctx.bezierCurveTo(this.r * 0.44, this.r * 0.64, -this.r * 0.50, this.r * 0.62, -this.r * 0.78, this.r * 0.22);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = night ? "rgba(165,166,174,0.42)" : "rgba(225,220,208,0.48)";
ctx.beginPath(); ctx.ellipse(-this.r * 0.18, -this.r * 0.30, this.r * 0.30, this.r * 0.13, -0.35, 0, Math.PI * 2); ctx.fill();
if (favoriteMemorial || kingMemorial) {
ctx.fillStyle = night ? "rgb(101,102,108)" : "rgb(154,150,143)";
const stones = kingMemorial ? [
[-1.02,0.40,0.42,0.26,-0.18],[0.94,0.42,0.38,0.24,0.18],[-0.66,0.62,0.29,0.18,0.10],[0.58,0.63,0.28,0.18,-0.12],[0.02,0.72,0.24,0.14,0]
] : [
[-0.92,0.43,0.34,0.22,-0.18],[0.82,0.48,0.29,0.19,0.20],[-0.58,0.58,0.22,0.14,0.10]
];
for (const stoneSpec of stones) {
ctx.beginPath();
ctx.ellipse(this.r * stoneSpec[0], this.r * stoneSpec[1], this.r * stoneSpec[2], this.r * stoneSpec[3], stoneSpec[4], 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
}
}
const drawMemorialFlower = (flowerX, scale = 1, lean = 0, flowerY = 0) => {
const flowerBaseY = this.r * (0.50 + flowerY);
const flowerTopY = -this.r * (0.05 + 0.03 * scale - flowerY);
ctx.strokeStyle = night ? "rgb(72,114,73)" : "rgb(76,139,72)";
ctx.lineWidth = Math.max(1.1, this.r * 0.085 * scale);
ctx.beginPath(); ctx.moveTo(flowerX, flowerBaseY);
ctx.quadraticCurveTo(flowerX - this.r * (0.10 + lean), this.r * 0.20, flowerX + this.r * lean, flowerTopY); ctx.stroke();
ctx.fillStyle = night ? "rgb(128,149,104)" : "rgb(112,166,92)";
ctx.beginPath(); ctx.ellipse(flowerX - this.r * 0.10, this.r * 0.23, this.r * 0.18 * scale, this.r * 0.09 * scale, -0.55, 0, Math.PI * 2); ctx.fill();
const petal = night ? "rgb(205,190,203)" : "rgb(246,219,232)";
const petalR = Math.max(2.0, this.r * 0.14 * scale);
ctx.fillStyle = petal;
const cx = flowerX + this.r * lean;
for (let i = 0; i < 5; i++) {
const a = -Math.PI / 2 + i * Math.PI * 2 / 5;
ctx.beginPath(); ctx.arc(cx + Math.cos(a) * petalR * 0.78, flowerTopY + Math.sin(a) * petalR * 0.78, petalR * 0.66, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = night ? "rgb(196,153,70)" : "rgb(235,175,56)";
ctx.beginPath(); ctx.arc(cx, flowerTopY, petalR * 0.48, 0, Math.PI * 2); ctx.fill();
};
const drawWeed = (x, scale = 1, tilt = 0) => {
ctx.strokeStyle = night ? "rgb(74,94,65)" : "rgb(89,118,72)";
ctx.lineWidth = Math.max(1, this.r * 0.07 * scale);
for (let i = -1; i <= 1; i++) {
ctx.beginPath(); ctx.moveTo(x + i * this.r * 0.06, this.r * 0.56);
ctx.quadraticCurveTo(x + this.r * (tilt + i * 0.06), this.r * 0.18, x + this.r * (tilt + i * 0.12), -this.r * (0.08 + 0.06 * Math.abs(i))); ctx.stroke();
}
};
if (slaveMemorial) {
drawWeed(this.r * 0.56, 0.9, 0.04);
drawWeed(-this.r * 0.62, 0.72, -0.03);
} else if (kingMemorial) {
drawMemorialFlower(-this.r * 0.98, 0.90, -0.03, 0.03);
drawMemorialFlower(-this.r * 0.55, 1.05, 0.02, 0);
drawMemorialFlower(this.r * 0.62, 1.08, -0.02, 0);
drawMemorialFlower(this.r * 1.04, 0.90, 0.03, 0.04);
} else {
drawMemorialFlower(this.r * 0.78, 1, 0);
if (favoriteMemorial) {
drawMemorialFlower(-this.r * 0.72, 0.82, 0.035);
drawMemorialFlower(this.r * 1.02, 0.76, 0.035);
}
}
} else if (this.type === "plushie") {
const spriteId = "plushie_bear";
const img = getRenderableImage(spriteId, spriteId);
@ -322,6 +405,27 @@
return hit;
}
StructureRegistry.register({
type: "grave",
label: "墓",
buildNeed: "social",
buildTime: 9,
maxHp: 70,
materials: { splat: 1 },
roles: { memorial: true },
needEffects: { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 },
create(owner, x, y, world) {
const structure = new TarinaiStructure(this, null, x, y, world);
structure.ownerId = "";
structure.r = 18;
structure.memorialEligibleFamilyKeys = [];
return structure;
},
onDestroyed(structure, world, breaker) {
global.TarinaiMemorialBellSystem?.onGraveDestroyed?.(world, structure, breaker || null);
},
});
StructureRegistry.register({
type: "grass_bed",
label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9",

View file

@ -16,6 +16,7 @@
{ id: "items_and_ants", run(worldRef, ctx) { ctx.mobile = phase("updateItemsAndAnts")?.(worldRef, ctx.dt) || null; } },
{ id: "effects", run(worldRef, ctx) { phase("updateEffects")?.(worldRef, ctx.dt); } },
{ id: "creatures", run(worldRef, ctx) { phase("updateCreatures")?.(worldRef, ctx.dt); } },
{ id: "trap_holds", run(worldRef) { global.TarinaiItemEnvironmentHazardSystem?.enforceTrapHolds?.(worldRef); } },
{ id: "seesaws", run(worldRef, ctx) { global.TarinaiSeesawSystem?.updateWorld?.(worldRef, ctx.dt); } },
{ id: "maintenance", run(worldRef, ctx) { phase("runMaintenance")?.(worldRef, ctx.dt, ctx.mobile?.itemCountBefore ?? (worldRef.items || []).length); } },
{ id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } },
@ -25,12 +26,8 @@
{ id: "diagnostics", run(worldRef) { phase("finalizeDiagnostics")?.(worldRef); } },
]);
function update(worldRef, dt) {
const normalize = phase("normalizeDelta");
if (!normalize) throw new Error("Tarinai simulation normalize phase is not available");
const frameDt = normalize(worldRef, dt);
if (frameDt === null) return;
const ctx = { dt: frameDt, mobile: null };
function runSimulationStep(worldRef, stepDt) {
const ctx = { dt: stepDt, mobile: null };
const profiler = global.TarinaiPerf;
for (const system of SYSTEM_ORDER) {
const end = profiler.begin(`update.phase.${system.id}`) || null;
@ -42,5 +39,39 @@
}
}
const FIXED_SIMULATION_STEP = 0.025;
const MAX_FIXED_STEPS_PER_FRAME = 40;
function update(worldRef, dt) {
const normalize = phase("normalizeDelta");
if (!normalize) throw new Error("Tarinai simulation normalize phase is not available");
const scaledDt = normalize(worldRef, dt);
if (scaledDt === null) return;
// Always advance the world with one fixed simulation quantum. Previously
// slow speeds used many tiny render-frame-sized updates while fast speeds
// used fewer 0.05-second chunks. Any per-update random choice, cooldown
// edge, collision pass, or AI decision could therefore change simply by
// changing the speed control. A fixed accumulator keeps the number and
// size of simulation updates identical for the same amount of game time.
let accumulator = Math.max(0, Number(worldRef._fixedSimulationAccumulator || 0) || 0);
accumulator += Math.max(0, Number(scaledDt) || 0);
// Main-loop dt is capped and the public speed control tops out at x16, so
// this guard still covers the worst supported frame without dropping time.
let steps = 0;
while (accumulator + 1e-10 >= FIXED_SIMULATION_STEP && steps < MAX_FIXED_STEPS_PER_FRAME) {
runSimulationStep(worldRef, FIXED_SIMULATION_STEP);
accumulator -= FIXED_SIMULATION_STEP;
steps += 1;
}
// Keep a bounded remainder if an external caller supplies an unsupported
// delta. Normal gameplay never reaches this branch, but avoiding an
// unbounded backlog is safer than a permanent catch-up spiral.
if (steps >= MAX_FIXED_STEPS_PER_FRAME && accumulator >= FIXED_SIMULATION_STEP) {
accumulator = Math.min(accumulator, FIXED_SIMULATION_STEP * 0.999999);
}
worldRef._fixedSimulationAccumulator = Math.max(0, accumulator);
}
global.TarinaiSystemOrder = Object.freeze({ update });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -97,6 +97,7 @@ class Tarinai { constructor(world, opts = {}) {
this.affection = opts.affection ?? rand(0, 30);
this.dead = !!opts.dead;
this.deathReason = opts.deathReason || "";
this.deathTime = opts.deathTime != null && Number.isFinite(Number(opts.deathTime)) ? Number(opts.deathTime) : null;
this.lastDamageCause = opts.lastDamageCause || "";
this.lastDamageAmount = opts.lastDamageAmount ?? 0;
this.lastDamageAt = opts.lastDamageAt ?? -999;

View file

@ -64,8 +64,7 @@ function actionTextForReason(action = null, tarinai = null) {
}
function isEffectCauseText(text = "") {
return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text)
?? globalThis.isTarinaiEffectCauseText?.(text));
return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text));
}
function forcedCausePhrase(source = "", fallback = "") {

View file

@ -26,6 +26,7 @@ function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color =
function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) {
if (!t || !world || !target || target.dead) return false;
const foodType = effectiveFoodTypeForItem(target);
if (t.isZunchiSlave && foodType === "sweet") return false;
const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269";
const effectiveDt = Math.max(0.10, Number(dt) || 0.12);
if (isDuplicatorFoodSource(target) && (t.eatCooldown || 0) > 0.04) {

View file

@ -107,7 +107,31 @@ const d = contact.distance;
}
function updateResidues(tarinai, dt) {
const worldRef = tarinai?.world;
if (!worldRef?.nearbyResidues) return false;
let touched = false;
for (const residue of worldRef.nearbyResidues(tarinai.x, tarinai.y, (tarinai.radius || 22) + 58) || []) {
if (!residue || residue.dead) continue;
const d = distXY(tarinai.x, tarinai.y, residue.x, residue.y);
if (residue.type === "splat") {
const touch = clamp(1 - d / ((tarinai.radius || 22) + (residue.r || 26) + 16), 0, 1);
if (touch > 0) {
applyNeedShock(tarinai, { safety: touch * dt * 5, health: touch * dt * 3 });
if (touch > 0.06) tarinai.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses");
touched = true;
}
} else if (residue.type === "trace" && d <= (tarinai.radius || 22) + (residue.r || 16) + 14) {
tarinai.loneliness -= dt * 0.25;
applyNeedShock(tarinai, { social: -dt * 1, safety: dt * 0.8 });
touched = true;
}
}
return touched;
}
global.TarinaiContactItemSystem = Object.freeze({
updateCandidate,
updateResidues,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -109,6 +109,7 @@
if (!worldRef || !tarinai || tarinai.dead || !hasConsumableAmount(item)) return null;
if (typeof global.consumeBehaviorTarget !== "function") return null;
const type = itemType(item);
if (tarinai.isZunchiSlave && type === "sweet") return null;
const label = typeof global.toolLabel === "function" ? global.toolLabel(type) : type;
const healthNeedBefore = Number(tarinai.needs?.health);
const consumed = global.consumeBehaviorTarget(tarinai, worldRef, item, roleFor(item), 0.18);

View file

@ -290,22 +290,16 @@
let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
const now = this.world?.time || 0;
if (!alreadyInside && occupants.length >= capacity) {
const kingSleepDisplace = Boolean(this.isTarinaiChampion && (this.state === "seek_bed" || this.state === "sleep" || this.sleeping));
if (options?.forceDisplace || kingSleepDisplace) {
const victim = occupants.find(t => t && t !== this && !t.dead && !t.isTarinaiChampion)
|| occupants.find(t => t && t !== this && !t.dead);
if (victim) {
victim.leaveNestBox?.(box);
victim.awakeLockTimer = Math.max(victim.awakeLockTimer || 0, 0.8);
victim.fearTimer = Math.max(victim.fearTimer || 0, 0.22);
victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`;
victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65);
victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`);
if (this.isTarinaiChampion) {
this.world?.log?.(`${this.name}\u304c${containerLabel(box)}\u306b\u5165\u308a\u3001${victim.name}\u3092\u62bc\u3057\u51fa\u3057\u305f\u3002`, "event", { participants: [this, victim] });
}
occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim);
}
const victim = globalThis.TarinaiNestSleepSystem?.lowerPriorityNestOccupant?.(this.world, box, this, occupants) || null;
if (victim) {
victim.leaveNestBox?.(box);
victim.awakeLockTimer = Math.max(victim.awakeLockTimer || 0, 0.8);
victim.fearTimer = Math.max(victim.fearTimer || 0, 0.22);
victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`;
victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65);
victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`);
this.world?.log?.(`${this.name}\u304c${containerLabel(box)}\u306b\u5165\u308a\u3001${victim.name}\u3092\u62bc\u3057\u51fa\u3057\u305f\u3002`, "event", { participants: [this, victim] });
occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim);
}
if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, containerFullText(box)) || false;
}

View file

@ -12,6 +12,7 @@ const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai
if (!contact) return false;
const d = contact.distance;
const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type;
if (tarinai.isZunchiSlave && foodType === "sweet") return false;
const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi";
if (((foodType === "sweet" && canSweetBite) || (foodType === "food" && (canBite || foodActionActive)) || duplicatorConsumable) && tarinai.foodItemHasServingLeft(it)) {

View file

@ -145,6 +145,9 @@ function clearForcedBehaviorQueue(tarinai, predicate = null) {
}
function behaviorText(tarinai) {
if (tarinai?.state === "build_memorial") return "親しいたりないの墓を作っている。";
if (tarinai?.state === "mourn_grave") return "親しいたりないの墓が壊されて泣いている。";
if (tarinai?.state === "bell_call") return "呼び鈴の鳴った場所に集まっている。";
const b = currentTarinaiBehavior(tarinai);
if (!b) return "";
const state = String(tarinai.state || "");

View file

@ -18,6 +18,7 @@
}
global.TarinaiContactItemSystem.updateCandidate(this, ctx, item, dt);
}
global.TarinaiContactItemSystem.updateResidues?.(this, dt);
global.TarinaiItemInteractionContext.finalize(this);
return true;
}

View file

@ -73,6 +73,8 @@ function foodPriorityRank(tarinai, item) {
const need = getTarinaiBehaviorNeed(tarinai);
const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0;
const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0;
if (tarinai?.isZunchiSlave && type === "sweet") return -1;
if (tarinai?.isZunchiSlave && type === "zunchi") return 6200;
if (type === "sweet") return 4300 + Math.min(120, Math.max(0, healthNeed - 40) * 3);
if (type === "water") return 3650 + Math.max(0, healthNeed - 42) * 7;
if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6;
@ -106,6 +108,7 @@ function foodPriorityScore(tarinai, item, distance = 0) {
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
if (!tarinai || !world) return false;
if (tarinai.isZunchiSlave) return false;
const source = world.nearbyFood?.(tarinai.x, tarinai.y, maxDist, true) || world.nearbyItems?.(tarinai.x, tarinai.y, maxDist, true) || world.items || [];
for (const it of source) {
if (!it || it.dead) continue;

View file

@ -894,6 +894,7 @@ function resolveNeedsCore(dt) {
if (this.dead) return this.variantSprite("fear");
if (this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04) return this.sunbathSpriteId ? this.sunbathSpriteId() : "sunbath_1";
if (this.sleepDisease) return this.variantSprite("sleep");
if (Number(this._bearTrapHoldUntil || 0) > Number(this.world?.time || 0)) return this.variantSprite("hurt");
if (this.isZunchiSlave) return this.zunchiSlaveSpriteId();
const low = this.lowHealthSprite();
if (low) return low;
@ -945,7 +946,8 @@ function resolveNeedsCore(dt) {
const next = this.rawSpriteId();
const isCurrentIntimidator = this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04;
const isCurrentIntimidated = !isCurrentIntimidator && this.intimidatedTimer > 0.04;
const forceImmediate = String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate");
const trappedNow = Number(this._bearTrapHoldUntil || 0) > now;
const forceImmediate = trappedNow || String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate");
if (!this.visibleSpriteId || forceImmediate) {
this.visibleSpriteId = next;
this.spriteLockUntil = forceImmediate ? now : now + 1.0;
@ -1124,8 +1126,9 @@ function resolveNeedsCore(dt) {
bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)", options = {}) {
if (!options.force && this.world.time < this.nextBubbleAt) return false;
const spawned = this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color);
if (!spawned) return false;
this.nextBubbleAt = this.world.time + cooldown;
this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color);
return true;
},

View file

@ -73,6 +73,50 @@
return true;
}
function nestOccupantPriority(tarinai) {
if (!tarinai) return -1;
if (tarinai.isTarinaiChampion) return 2;
if (tarinai.isZunchiSlave) return 0;
return 1;
}
function lowerPriorityNestOccupant(worldRef, box, entrant, occupants = null) {
const entrantPriority = nestOccupantPriority(entrant);
const list = Array.isArray(occupants) ? occupants : nestBoxOccupants(worldRef, box, Infinity);
let best = null;
let bestPriority = Infinity;
for (const occupant of list || []) {
if (!occupant || occupant.dead || occupant === entrant) continue;
const priority = nestOccupantPriority(occupant);
if (priority >= entrantPriority) continue;
if (priority < bestPriority) {
best = occupant;
bestPriority = priority;
}
}
return best;
}
function lowerPriorityNestReservation(worldRef, box, entrant) {
if (!worldRef || !box || !entrant) return null;
const now = Number(worldRef.time || 0) || 0;
const entrantPriority = nestOccupantPriority(entrant);
let best = null;
let bestPriority = Infinity;
for (const candidate of worldRef.tarinai || []) {
if (!candidate || candidate.dead || candidate === entrant) continue;
if (!tarinaiIsReservingNest(candidate, box, now)) continue;
const priority = nestOccupantPriority(candidate);
if (priority >= entrantPriority) continue;
if (priority < bestPriority) {
best = candidate;
bestPriority = priority;
}
}
return best;
}
function nestBoxReservationCount(worldRef, box) {
if (!worldRef || !box || box.dead || !isNestContainer(box)) return 0;
const now = Number(worldRef.time || 0) || 0;
@ -113,8 +157,11 @@
if (sleepTargetBlocked(worldRef, tarinai, item)) return false;
const capacity = isNestContainer(item) ? nestBoxCapacity(worldRef, item) : 1;
const occupancy = bedOccupancy(worldRef, item);
if (tarinai?.isTarinaiChampion && item.type === "nest_box") return true;
return occupancy < capacity || tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id);
if (tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id)) return true;
if (occupancy < capacity) return true;
if (!isNestContainer(item)) return false;
const occupants = nestBoxOccupants(worldRef, item, Infinity);
return Boolean(lowerPriorityNestOccupant(worldRef, item, tarinai, occupants) || lowerPriorityNestReservation(worldRef, item, tarinai));
}
function findStructure(worldRef, tarinai, opts = {}) {
@ -195,5 +242,7 @@
nearestSleepPlaceOfType,
findPrioritySleepPlaceBeforeGrassBedBuild,
findNearestSleepPlace,
nestOccupantPriority,
lowerPriorityNestOccupant,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -249,10 +249,13 @@ function tarinaiSpriteReversesFacing(sprite) {
const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0;
const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0;
const coldStatus = this.temperatureStatus || this.world?.temperatureStatusFor?.(this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15), this) || null;
const trappedShakeActive = Number(this._bearTrapHoldUntil || 0) > Number(this.world?.time || 0);
const coldShakeActive = Boolean(coldStatus && coldStatus.direction === "cold" && (coldStatus.discomfort || 0) > 0.5 && this.state !== "sunbath");
const coldShakeStrength = coldShakeActive ? clamp((coldStatus.discomfort || 0) / 12, 0.25, 1.25) : 0;
const coldShakeX = coldShakeActive ? (Math.sin(t * 46 + this.age * 0.9) + Math.sin(t * 71 + this.age * 0.31) * 0.55) * this.radius * 0.035 * coldShakeStrength : 0;
const coldShakeY = coldShakeActive ? Math.sin(t * 58 + this.age * 0.47) * this.radius * 0.012 * coldShakeStrength : 0;
const trapShakeStrength = trappedShakeActive ? 1.05 : 0;
const shakeStrength = Math.max(coldShakeStrength, trapShakeStrength);
const coldShakeX = (coldShakeActive || trappedShakeActive) ? (Math.sin(t * 46 + this.age * 0.9) + Math.sin(t * 71 + this.age * 0.31) * 0.55) * this.radius * 0.035 * shakeStrength : 0;
const coldShakeY = (coldShakeActive || trappedShakeActive) ? Math.sin(t * 58 + this.age * 0.47) * this.radius * 0.012 * shakeStrength : 0;
const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25;
const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0;
const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0);
@ -467,17 +470,19 @@ function tarinaiSpriteReversesFacing(sprite) {
ctx.restore();
}
if (showDecor && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
const cursorLove = Math.max(0, this.cursorPetting || 0);
const cursorPettingActive = cursorLove > 0.035;
if (showDecor && this.state !== "sleep" && (cursorPettingActive || ((this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44))) {
ctx.save();
const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0;
ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34;
ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9"));
const count = this.state === "cursor_friend" ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2;
const showPettingHearts = cursorPettingActive || this.state === "cursor_friend";
ctx.globalAlpha = showPettingHearts ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34;
ctx.fillStyle = showPettingHearts ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9"));
const count = showPettingHearts ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2;
for (let i = 0; i < count; i++) {
const orbit = 0.24 + (i % 3) * 0.08 + cursorLove * 0.05;
const px = Math.cos(t * 2.2 + this.age + i * 1.42) * drawW * orbit;
const py = -drawH * (0.22 + (i % 2) * 0.06) + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * (0.08 + cursorLove * 0.02);
if (this.state === "cursor_friend") {
if (showPettingHearts) {
const s = Math.max(5, drawW * (0.070 + (i % 3) * 0.012 + cursorLove * 0.01));
drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 });
} else {

View file

@ -25,7 +25,7 @@
const ROUTE_STATES = new Set([
"seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent",
"seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature",
"wander", "panic",
"wander", "panic", "bell_call", "build_memorial", "visit_grave",
]);
function isNestContainerItem(item) {
@ -429,6 +429,8 @@
move(dt) {
if (!(this.burning || (this.burnTimer || 0) > 0.02) && this.freezeSleepDiseaseMotion(dt)) return;
const specialMotion = global.TarinaiMemorialBellSystem?.updateMotion?.(this, dt);
if (specialMotion?.handled) return;
let ax = 0, ay = 0;
const itemSpeedMul = this.itemSpeedMultiplier ? this.itemSpeedMultiplier() : 1;
const baseSpeed = 46.5 * 1.5 * this.trait.speed * itemSpeedMul * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1);
@ -831,6 +833,7 @@
const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason, opts) : (reason || "\u4e8b\u6545");
this.dead = true;
this.deathReason = finalReason;
this.deathTime = Number(this.world?.time || 0) || 0;
this.world.syncTarinaiCountEntry?.(this);
this.world.deadCount += 1;
globalThis.TarinaiAchievements?.evaluateEvent?.(this.world, "population", { tarinai: this, delta: -1, reason: finalReason });
@ -850,8 +853,11 @@
this.world.drawListDirty = true;
this.world.markItemBucketsDirty?.("tarinai-death-plushie-cleanup");
this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup");
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace");
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat");
this.world.addResidue?.("trace", this.x, this.y, { reason: "tarinai-death-trace" });
// Favorite Tarinai are memorialized immediately and therefore do not leave
// the splat resource that normal graves are built from.
if (!this.favorite) this.world.addResidue?.("splat", this.x, this.y, { reason: "tarinai-death-splat" });
this.world.markTerrainDirtyAt?.(this.x, this.y, Math.max(42, (this.radius || 22) * 2.1), "tarinai-death-remains");
if (deterministicChance(this.world, "death-grass-spawn", 0.55, this, finalReason)) {
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
if (spot) {

View file

@ -29,6 +29,10 @@
// and can over-amplify safety/fear transitions when updates are sparse.
const needStepDt = urgentAi ? 0.50 : 1.00;
t.resolveNeeds(needStepDt);
if (global.TarinaiMemorialBellSystem?.updateAi?.(t, scanDt)) {
t.updateFacing();
return { done: false, special: true };
}
const sleepingNow = t.state === "sleep";
if (!sleepingNow) {
t.reactToFreshZunchi(scanDt);

View file

@ -236,7 +236,7 @@
t._lastPhysicalVelocityY = 0;
return { done: false, held: true };
}
const motionDtCap = context.offscreenCatchup ? 0.75 : 0.05;
const motionDtCap = context.offscreenCatchup ? 2.4 : 0.05;
const motionDt = Math.max(0.001, Math.min(motionDtCap, Number(context.motionDt ?? dt) || 0.016));
if (context.motionOnly && /^(sleep|eat|sunbath|birth_ritual|breed|reproduction|intimidate|ant_intimidate)$/.test(String(t.state || ""))) {
return passivePhysicalMotion(t, motionDt, context);

View file

@ -69,6 +69,10 @@ TEXT_CATALOG.stateLabels = Object.freeze({
ant_attack: "\u30a2\u30ea\u653b\u6483\u4e2d",
ant_intimidate: "\u30a2\u30ea\u5a01\u5687\u4e2d",
frozen: "\u51b7\u51cd\u4e2d",
bell_call: "呼び鈴へ移動中",
build_memorial: "墓を作っている",
visit_grave: "墓参りをしている",
mourn_grave: "墓が壊されて泣いている",
});

View file

@ -4,7 +4,10 @@ const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const ui = {
pauseBtn: document.getElementById("pauseBtn"),
speedBtn: document.getElementById("speedBtn"),
speedSelect: document.getElementById("speedSelect"),
speedMenu: document.getElementById("speedMenu"),
speedMenuBtn: document.getElementById("speedMenuBtn"),
speedMenuPanel: document.getElementById("speedMenuPanel"),
topMenu: document.getElementById("topMenu"),
topMenuBtn: document.getElementById("topMenuBtn"),
topMenuPanel: document.getElementById("topMenuPanel"),
@ -28,6 +31,7 @@ const ui = {
selectedCard: document.getElementById("selectedCard"),
selectedCloseBtn: document.getElementById("selectedCloseBtn"),
colonyChartTabs: document.getElementById("colonyChartTabs"),
colonyChartRangeTabs: document.getElementById("colonyChartRangeTabs"),
selectedInfo: document.getElementById("selectedInfo"),
log: document.getElementById("log"),
logPushControls: document.getElementById("logPushControls"),
@ -99,10 +103,12 @@ const uiCache = {
chartBucketStart: -Infinity,
chartBucketEnd: -Infinity,
chartBucketAccum: null,
chartNextSampleAt: 0,
chartBirthEventSeen: null,
chartDeathEventSeen: null,
lastChartDraw: "",
chartMode: "population",
chartRange: "season",
chartHiddenSeries: new Set(["ants"]),
canvasDpr: 0,
panning: false,

View file

@ -408,6 +408,16 @@ function bindUI() {
uiCache.lastChartDraw = "";
renderStats();
});
ui.colonyChartRangeTabs?.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-chart-range]");
if (!btn) return;
uiCache.chartRange = btn.dataset.chartRange || "season";
for (const b of ui.colonyChartRangeTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn);
uiCache.lastChartDraw = "";
audio.uiClick?.();
renderStats({ force: true });
});
window.bindColonyChartHover?.();
const toggleChartSeries = (target) => {
const control = target?.closest?.("[data-series-key]");
if (!control) return false;
@ -469,6 +479,40 @@ function bindUI() {
}, { passive: false });
};
bindArchiveHorizontal(ui.archiveContent);
const clearFamilyHoverHighlight = (tree) => {
if (!tree) return;
tree.classList.remove("family-hover-active");
for (const el of tree.querySelectorAll(".tree-node.family-hover-dim, .tree-node.family-hover-focus, .tree-node.family-hover-parent, .tree-node.family-hover-child")) {
el.classList.remove("family-hover-dim", "family-hover-focus", "family-hover-parent", "family-hover-child");
}
};
ui.archiveContent?.addEventListener("mouseover", (e) => {
const node = e.target.closest(".lineage-family-tree [data-family-tarinai-id]");
if (!node || node.contains(e.relatedTarget)) return;
const tree = node.closest(".lineage-family-tree");
if (!tree) return;
clearFamilyHoverHighlight(tree);
const id = String(node.getAttribute("data-family-tarinai-id") || "");
const familyNode = world.family?.[id] || null;
const parentIds = new Set((familyNode?.parents || []).map(String));
const childIds = new Set((familyNode?.children || []).map(String));
tree.classList.add("family-hover-active");
for (const el of tree.querySelectorAll(".tree-node[data-family-tarinai-id]")) {
const otherId = String(el.getAttribute("data-family-tarinai-id") || "");
el.classList.add("family-hover-dim");
if (otherId === id) el.classList.add("family-hover-focus");
else if (parentIds.has(otherId)) el.classList.add("family-hover-parent");
else if (childIds.has(otherId)) el.classList.add("family-hover-child");
}
});
ui.archiveContent?.addEventListener("mouseout", (e) => {
const node = e.target.closest(".lineage-family-tree [data-family-tarinai-id]");
if (!node || node.contains(e.relatedTarget)) return;
const tree = node.closest(".lineage-family-tree");
const enteringAnotherNode = e.relatedTarget?.closest?.(".lineage-family-tree [data-family-tarinai-id]");
if (enteringAnotherNode && enteringAnotherNode.closest(".lineage-family-tree") === tree) return;
clearFamilyHoverHighlight(tree);
});
ui.archiveContent?.addEventListener("click", (e) => {
const node = e.target.closest("[data-family-tarinai-id]");
if (!node) return;
@ -570,12 +614,37 @@ function bindUI() {
});
ui.topMenuPanel?.addEventListener("click", (e) => e.stopPropagation());
ui.speedBtn?.addEventListener("click", () => {
function closeSpeedMenu() {
ui.speedMenuPanel?.classList.add("hidden");
ui.speedMenuBtn?.setAttribute("aria-expanded", "false");
}
function toggleSpeedMenu() {
const opening = Boolean(ui.speedMenuPanel?.classList.contains("hidden"));
closeTopMenu();
ui.speedMenuPanel?.classList.toggle("hidden", !opening);
ui.speedMenuBtn?.setAttribute("aria-expanded", opening ? "true" : "false");
}
ui.speedMenuBtn?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
audio.uiClick?.();
window.TarinaiCommands.dispatch(world, { type: "simulation.speed.cycle", speeds: [1, 2, 4] });
toggleSpeedMenu();
});
ui.speedMenuPanel?.addEventListener("click", (e) => {
e.stopPropagation();
const option = e.target.closest?.("[data-speed-value]");
if (!option) return;
const speed = Number(option.dataset.speedValue);
if (ui.speedSelect) ui.speedSelect.value = String(speed);
window.TarinaiCommands.dispatch(world, { type: "simulation.speed.set", speed, allowed: [0.5, 1, 2, 4, 8, 16] });
closeSpeedMenu();
syncTopButtons();
});
ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); openEcologyDialog(); });
ui.speedSelect?.addEventListener("change", () => {
window.TarinaiCommands.dispatch(world, { type: "simulation.speed.set", speed: Number(ui.speedSelect.value), allowed: [0.5, 1, 2, 4, 8, 16] });
syncTopButtons();
});
ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); closeSpeedMenu(); closeTopMenu(); openEcologyDialog(); });
ui.soundBtn?.addEventListener("click", (e) => {
e.stopPropagation();
closeVisualSettingsPanel();
@ -595,6 +664,7 @@ function bindUI() {
closeVisualSettingsPanel();
if (!ui.topMenu?.contains(target)) closeTopMenu();
}
if (!ui.speedMenu?.contains(target)) closeSpeedMenu();
// Normal controls keep the selected tool. A blank UI click exits placement
// mode; canvas clicks are still handled as tool actions.
if (isActiveToolMode() && !canvas?.contains?.(target) && !isInteractiveUiTarget(target)) {

View file

@ -15,7 +15,7 @@ function bindColonyMoodTooltip() {
}
function colonyChartObjectKeys() {
return ["grass", "zunchi", "trace", "grass_bed", "water"];
return ["grass", "zunchi", "trace", "grave", "grass_bed", "water"];
}
function colonyStatsUiVisible() {
@ -26,7 +26,15 @@ function colonyStatsUiVisible() {
return true;
}
function collectDynamicColonyStats() {
function collectDynamicColonyStats(options = {}) {
const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
const cache = world._colonyStatsDynamicCache;
const cacheAge = now - (Number(world._colonyStatsDynamicCacheAt) || 0);
// Colony cards can ask for a refresh from many UI paths. Reuse one aggregate
// for a short real-time window instead of rescanning every Tarinai each time.
// Fixed history samples opt into force=true so their recorded values remain
// tied to the requested simulation timestamp.
if (options.force !== true && cache && cacheAge >= 0 && cacheAge < 800) return cache;
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const needSums = Object.fromEntries(needKeys.map(key => [key, 0]));
let liveCount = 0;
@ -54,6 +62,7 @@ function collectDynamicColonyStats() {
...Object.fromEntries(needKeys.map(key => [`need_${key}`, liveCount ? needSums[key] / divisor : 0])),
};
world._colonyStatsDynamicCache = dynamic;
world._colonyStatsDynamicCacheAt = now;
return dynamic;
}
@ -83,6 +92,13 @@ function renderStats(options = {}) {
stress: dynamic.stress || 0,
temperature: Number(temp) || 0,
ants: dynamic.ants || 0,
graves: (() => {
const bucket = typeof world.itemsOfType === "function" ? world.itemsOfType("grave") : null;
if (bucket && typeof bucket.length === "number") return Math.max(0, bucket.length);
let count = 0;
for (const item of world.items || []) if (item && !item.dead && item.type === "grave") count += 1;
return count;
})(),
...dynamic,
objects: world.itemCounts || {},
};
@ -113,126 +129,143 @@ function renderStats(options = {}) {
bindColonyMoodTooltip();
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
window.TarinaiGroundUI.update(world);
updateColonyHistory(values);
drawColonyChart(values);
if (ui?.selectedCard && !ui.selectedCard.classList.contains("hidden")) renderSelected();
if (world.familyTreeDirty) scheduleArchiveWindowRender();
}
function updateColonyHistory(values) {
const t = Math.max(0, world.time || 0);
function updateColonyHistory(values, options = {}) {
const t = Math.max(0, Number(world.time || 0) || 0);
const history = uiCache.chartHistory;
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
const maxPoints = 5 * 12;
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) {
const maxPoints = 20 * 12;
const birthsTotal = Math.max(0, Number(values.birthEvents || 0) || 0);
const deathsTotal = Math.max(0, Number(values.deathEvents || 0) || 0);
const lastT = history.length ? Number(history[history.length - 1]?.t || 0) : -Infinity;
const rewound = t + 1e-6 < lastT
|| (Number.isFinite(uiCache.chartNextSampleAt) && t + interval < uiCache.chartNextSampleAt)
|| (Number.isFinite(uiCache.chartBirthEventSeen) && birthsTotal < uiCache.chartBirthEventSeen)
|| (Number.isFinite(uiCache.chartDeathEventSeen) && deathsTotal < uiCache.chartDeathEventSeen);
if (rewound) {
history.length = 0;
uiCache.chartBucketStart = -Infinity;
uiCache.chartBucketEnd = -Infinity;
uiCache.chartBucketAccum = null;
uiCache.chartNextSampleAt = 0;
uiCache.chartBirthEventSeen = null;
uiCache.chartDeathEventSeen = null;
uiCache.lastChartDraw = "";
}
if (!Number.isFinite(uiCache.chartNextSampleAt)) uiCache.chartNextSampleAt = 0;
if (options.force !== true && t + 1e-6 < uiCache.chartNextSampleAt) return false;
if (!Number.isFinite(uiCache.chartBirthEventSeen)) uiCache.chartBirthEventSeen = birthsTotal;
if (!Number.isFinite(uiCache.chartDeathEventSeen)) uiCache.chartDeathEventSeen = deathsTotal;
const births = Math.max(0, birthsTotal - uiCache.chartBirthEventSeen);
const deaths = Math.max(0, deathsTotal - uiCache.chartDeathEventSeen);
uiCache.chartBirthEventSeen = birthsTotal;
uiCache.chartDeathEventSeen = deathsTotal;
const objectKeys = colonyChartObjectKeys();
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const makeBucket = (start) => ({
start,
end: start + interval,
count: 0,
sums: {
pop: 0,
zunchiSlaves: 0,
tarinaiKings: 0,
sick: 0,
juvenile: 0,
elder: 0,
ants: 0,
stress: 0,
temperature: 0,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
},
objects: Object.fromEntries(objectKeys.map(k => [k, 0])),
events: { births: 0, deaths: 0 },
});
if (!uiCache.chartBucketAccum) {
const start = Math.floor(t / interval) * interval;
uiCache.chartBucketStart = start;
uiCache.chartBucketEnd = start + interval;
uiCache.chartBucketAccum = makeBucket(start);
}
const pushBucket = (bucket) => {
if (!bucket || bucket.count <= 0) return;
const row = {
t: bucket.end,
pop: bucket.sums.pop / bucket.count,
zunchiSlaves: bucket.sums.zunchiSlaves / bucket.count,
tarinaiKings: bucket.sums.tarinaiKings / bucket.count,
sick: bucket.sums.sick / bucket.count,
juvenile: bucket.sums.juvenile / bucket.count,
elder: bucket.sums.elder / bucket.count,
ants: bucket.sums.ants / bucket.count,
stress: bucket.sums.stress / bucket.count,
temperature: bucket.sums.temperature / bucket.count,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])),
births: Math.max(0, Number(bucket.events?.births || 0) || 0),
deaths: Math.max(0, Number(bucket.events?.deaths || 0) || 0),
};
history.push(row);
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
const sampleT = Math.max(0, Math.floor(t / interval) * interval);
const row = {
t: sampleT,
pop: values.pop || 0,
zunchiSlaves: values.zunchiSlaves || 0,
tarinaiKings: values.tarinaiKings || 0,
sick: values.sick || 0,
juvenile: values.juvenile || 0,
elder: values.elder || 0,
ants: values.ants || 0,
stress: values.stress || 0,
temperature: Number(values.temperature || 0) || 0,
...Object.fromEntries(needKeys.map(k => [`need_${k}`, values[`need_${k}`] || 0])),
...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, k === "grave" ? (values.graves || 0) : ((values.objects || {})[k] || 0)])),
births,
deaths,
};
while (t >= uiCache.chartBucketEnd) {
const b = uiCache.chartBucketAccum;
pushBucket(b);
uiCache.chartBucketStart = uiCache.chartBucketEnd;
uiCache.chartBucketEnd = uiCache.chartBucketStart + interval;
uiCache.chartBucketAccum = makeBucket(uiCache.chartBucketStart);
if (history.length && Math.abs((Number(history[history.length - 1]?.t) || 0) - sampleT) < 1e-6) history[history.length - 1] = row;
else history.push(row);
if (history.length > maxPoints) history.splice(0, history.length - maxPoints);
uiCache.chartNextSampleAt = sampleT + interval;
return true;
}
function collectColonyHistorySampleValues() {
const counts = world.tarinaiCounts?.() || world.rebuildTarinaiCountCache?.("chart-sample") || { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 };
const dynamic = collectDynamicColonyStats({ force: true });
const graveBucket = typeof world.itemsOfType === "function" ? world.itemsOfType("grave") : null;
let graves = 0;
if (graveBucket && typeof graveBucket.length === "number") graves = Math.max(0, graveBucket.length);
else for (const item of world.items || []) if (item && !item.dead && item.type === "grave") graves += 1;
return {
pop: Math.max(0, Number(counts.alive || 0) || 0),
zunchiSlaves: Math.max(0, Number(counts.zunchiSlaves || 0) || 0),
tarinaiKings: Math.max(0, Number(counts.tarinaiKings || 0) || 0),
sick: dynamic.sick || 0,
juvenile: dynamic.juvenile || 0,
elder: dynamic.elder || 0,
ants: dynamic.ants || 0,
stress: dynamic.stress || 0,
temperature: Number(world.currentTemperature ?? world.updateTemperature?.(0) ?? (CONFIG.standardTemperature ?? 15)) || 0,
birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0),
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
graves,
...dynamic,
objects: world.itemCounts || {},
};
}
function tickColonyHistorySampling() {
const t = Math.max(0, Number(world?.time || 0) || 0);
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
if (!Number.isFinite(uiCache.chartNextSampleAt)) uiCache.chartNextSampleAt = 0;
if (t + 1e-6 < uiCache.chartNextSampleAt) return false;
return updateColonyHistory(collectColonyHistorySampleValues(), { force: true });
}
function chartRangeConfig() {
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
const range = ["day", "season", "year"].includes(uiCache.chartRange) ? uiCache.chartRange : "season";
if (range === "day") return { id: range, duration: dayLength, footer: "\u0032\u6642\u9593\u3054\u3068 / 1\u65e5" };
if (range === "year") return { id: range, duration: dayLength * 20, footer: "1\u65e51\u70b9 / 20\u65e5\u9593" };
return { id: "season", duration: dayLength * 5, footer: "\u0032\u6642\u9593\u3054\u3068 / 5\u65e5\u9593" };
}
function yearRepresentativeRows(rows, minT, maxT) {
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
const grouped = new Map();
for (const row of rows || []) {
const t = Math.max(0, Number(row?.t || 0) || 0);
if (t < minT - 1e-6 || t > maxT + 1e-6) continue;
const day = Math.floor(t / dayLength);
const noon = day * dayLength + dayLength * 0.5;
const prev = grouped.get(day);
if (!prev || Math.abs(t - noon) < Math.abs((Number(prev.t || 0) || 0) - noon)) grouped.set(day, row);
}
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.zunchiSlaves += values.zunchiSlaves || 0;
bucket.sums.tarinaiKings += values.tarinaiKings || 0;
bucket.sums.sick += values.sick || 0;
bucket.sums.juvenile += values.juvenile || 0;
bucket.sums.elder += values.elder || 0;
bucket.sums.ants += values.ants || 0;
bucket.sums.stress += values.stress || 0;
bucket.sums.temperature += Number(values.temperature || 0) || 0;
for (const k of needKeys) bucket.sums[`need_${k}`] += values[`need_${k}`] || 0;
for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0;
return Array.from(grouped.entries()).sort((a, b) => a[0] - b[0]).map(([, row]) => row);
}
function chartRows(values) {
// The colony chart is sampled in two-hour buckets. Do not append a live
// preview row here; it makes the graph redraw every frame even though the
// recorded statistics have not advanced yet.
if (uiCache.chartHistory.length) return uiCache.chartHistory.slice();
const allRows = uiCache.chartHistory.length ? uiCache.chartHistory.slice() : [];
const now = Math.max(0, Number(world.time || 0) || 0);
const interval = Math.max(1, (CONFIG.dayLength || 120) / 12);
// Keep the visible time window anchored to the newest recorded sample rather
// than the continuously advancing world clock. The graph now shifts left at
// exactly the same moment a new data point is appended.
const maxT = allRows.length
? Math.max(0, Number(allRows[allRows.length - 1]?.t || 0) || 0)
: Math.max(0, Math.floor(now / interval) * interval);
const config = chartRangeConfig();
const minT = Math.max(0, maxT - config.duration);
let rows = allRows.filter(row => {
const t = Math.max(0, Number(row?.t || 0) || 0);
return t >= minT - 1e-6 && t <= maxT + 1e-6;
});
if (config.id === "year") rows = yearRepresentativeRows(rows, minT, maxT);
if (rows.length) return rows;
const objectKeys = colonyChartObjectKeys();
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
return [{
t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0),
pop: 0,
zunchiSlaves: 0,
tarinaiKings: 0,
sick: 0,
juvenile: 0,
elder: 0,
ants: 0,
stress: 0,
temperature: 0,
births: 0,
deaths: 0,
t: now,
pop: 0, zunchiSlaves: 0, tarinaiKings: 0, sick: 0, juvenile: 0, elder: 0, ants: 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])),
}];
@ -247,6 +280,119 @@ function chartSeasonDayLabelForTime(t = 0) {
return `${season}${seasonDay}\u65e5`;
}
function chartDateTimeLabelForTime(t = 0) {
const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120);
const safe = Math.max(0, Number(t || 0) || 0);
const dayStart = Math.floor(safe / dayLength) * dayLength;
const dayRatio = Math.max(0, Math.min(0.999999, (safe - dayStart) / dayLength));
const totalMinutes = Math.floor(dayRatio * 24 * 60);
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
const mm = String(totalMinutes % 60).padStart(2, "0");
return `${chartSeasonDayLabelForTime(safe)} ${hh}:${mm}`;
}
function bindColonyChartHover() {
const chart = ui?.colonyChart;
if (!chart || chart.dataset.historyHoverBound === "1") return;
chart.dataset.historyHoverBound = "1";
let tooltip = document.getElementById("colonyChartFloatingTooltip");
if (!tooltip) {
tooltip = document.createElement("div");
tooltip.id = "colonyChartFloatingTooltip";
tooltip.className = "colony-chart-tooltip hidden";
document.body.appendChild(tooltip);
}
const hoverMarker = () => {
let marker = chart.querySelector(".colony-chart-hover-point");
if (!marker) {
marker = document.createElement("div");
marker.className = "colony-chart-hover-point hidden";
chart.appendChild(marker);
}
return marker;
};
const hide = () => {
tooltip.classList.add("hidden");
hoverMarker().classList.add("hidden");
};
chart.addEventListener("mouseleave", hide);
chart.addEventListener("mousemove", (event) => {
const state = uiCache.chartHoverState;
if (!state?.rows?.length) return hide();
const rect = chart.getBoundingClientRect();
if (!rect.width || !rect.height) return hide();
const px = Math.max(0, Math.min(state.cssW, (event.clientX - rect.left) * state.cssW / rect.width));
const py = Math.max(0, Math.min(state.cssH, (event.clientY - rect.top) * state.cssH / rect.height));
const plotX = Math.max(state.pad.l, Math.min(state.pad.l + state.w, px));
const ratio = (plotX - state.pad.l) / Math.max(1, state.w);
const targetT = state.minT + ratio * Math.max(1, state.maxT - state.minT);
let nearest = state.rows[0];
let best = Infinity;
for (const row of state.rows) {
const d = Math.abs((Number(row?.t || 0) || 0) - targetT);
if (d < best) { best = d; nearest = row; }
}
if (!nearest) return hide();
const snappedX = state.pad.l + clamp(((Number(nearest.t || 0) || 0) - state.minT) / Math.max(1, state.maxT - state.minT), 0, 1) * state.w;
const scale = state.scale || { min: 0, max: 100, percent: false };
const yForSeries = (seriesDef) => {
const raw = Number(nearest?.[seriesDef.key] || 0);
if (scale.percent) return state.pad.t + state.h - state.h * clamp(raw / 100, 0, 1);
const denom = Math.max(1, scale.max - scale.min);
return state.pad.t + state.h - state.h * clamp((raw - scale.min) / denom, 0, 1);
};
let snapSeries = state.series[0] || null;
let snappedY = snapSeries ? yForSeries(snapSeries) : state.pad.t + state.h * 0.5;
let bestY = Math.abs(snappedY - py);
for (const seriesDef of state.series.slice(1)) {
const candidateY = yForSeries(seriesDef);
const distance = Math.abs(candidateY - py);
if (distance < bestY) {
bestY = distance;
snapSeries = seriesDef;
snappedY = candidateY;
}
}
const marker = hoverMarker();
marker.style.left = `${snappedX * rect.width / state.cssW}px`;
marker.style.top = `${snappedY * rect.height / state.cssH}px`;
if (snapSeries?.color) marker.style.borderColor = snapSeries.color;
marker.classList.remove("hidden");
const lines = state.series.map(series => `<div class="colony-chart-tooltip-row"><span>${series.label}</span><b>${formatChartValue(series.key, nearest)}</b></div>`).join("");
tooltip.innerHTML = `<strong>${chartDateTimeLabelForTime(nearest.t)}</strong>${lines}`;
tooltip.classList.remove("hidden");
tooltip.style.visibility = "hidden";
tooltip.style.left = "0px";
tooltip.style.top = "0px";
const tipW = Math.max(1, tooltip.offsetWidth || 160);
const tipH = Math.max(1, tooltip.offsetHeight || 80);
const viewportW = Math.max(1, window.innerWidth || document.documentElement.clientWidth || rect.right + tipW);
const viewportH = Math.max(1, window.innerHeight || document.documentElement.clientHeight || rect.bottom + tipH);
const gap = 10;
const anchorX = rect.left + snappedX * rect.width / state.cssW;
const anchorY = rect.top + snappedY * rect.height / state.cssH;
const placements = [
{ side: "right", left: rect.right + gap, top: anchorY - tipH / 2, fits: viewportW - rect.right >= tipW + gap },
{ side: "left", left: rect.left - tipW - gap, top: anchorY - tipH / 2, fits: rect.left >= tipW + gap },
{ side: "top", left: anchorX - tipW / 2, top: rect.top - tipH - gap, fits: rect.top >= tipH + gap },
{ side: "bottom", left: anchorX - tipW / 2, top: rect.bottom + gap, fits: viewportH - rect.bottom >= tipH + gap },
];
const placement = placements.find(item => item.fits) || placements[0];
const left = Math.max(gap, Math.min(viewportW - tipW - gap, placement.left));
const top = Math.max(gap, Math.min(viewportH - tipH - gap, placement.top));
tooltip.dataset.side = placement.side;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.top = `${Math.round(top)}px`;
tooltip.style.visibility = "visible";
});
}
window.bindColonyChartHover = bindColonyChartHover;
function niceChartMax(raw = 0) {
const value = Math.max(1, Number(raw) || 0);
const pow = Math.pow(10, Math.floor(Math.log10(value)));
@ -278,12 +424,14 @@ function chartAxisLabel(value, scale) {
function drawColonyChart(values) {
const chart = ui.colonyChart;
if (!chart) return;
bindColonyChartHover();
let mode = uiCache.chartMode || "population";
if (!["population", "life", "objects"].includes(mode)) {
mode = "population";
uiCache.chartMode = "population";
for (const b of ui.colonyChartTabs?.querySelectorAll("button[data-chart]") || []) b.classList.toggle("active", b.dataset.chart === "population");
}
const rangeConfig = chartRangeConfig();
const rows = chartRows(values);
const last = rows[rows.length - 1] || values;
const allSeries = chartSeries(mode);
@ -291,8 +439,10 @@ function drawColonyChart(values) {
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}`;
const maxT = Math.max(0, Number(last?.t ?? world.time ?? 0) || 0);
const visibleDay = Math.floor(maxT / dayLength);
const lastRowT = Math.round(maxT * 10);
const snapshot = `${mode}:${rangeConfig.id}:${rows.length}:${lastRowT}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`;
if (uiCache.lastChartDraw === snapshot) return;
uiCache.lastChartDraw = snapshot;
const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320));
@ -301,20 +451,15 @@ function drawColonyChart(values) {
const w = cssW - pad.l - pad.r;
const h = cssH - pad.t - pad.b;
const scale = chartScaleForRows(mode, rows, series);
const times = rows.map(r => Math.max(0, Number(r.t || 0) || 0));
const minT = Math.min(...times, Math.max(0, Number(world.time || 0) || 0));
const maxT = Math.max(...times, Math.max(0, Number(world.time || 0) || 0), minT + 1);
const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, maxT - minT), 0, 1) * w;
const xFor = (i) => xForTime(rows[i]?.t ?? maxT);
const yFor = (row, s) => {
const raw = Number(row[s.key] || 0);
if (scale.percent) {
const v = clamp(raw / 100, 0, 1);
return pad.t + h - h * v;
}
const chartMaxT = Math.max(1, maxT);
const minT = Math.max(0, chartMaxT - rangeConfig.duration);
const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, chartMaxT - minT), 0, 1) * w;
const xFor = (i) => xForTime(rows[i]?.t ?? chartMaxT);
const yFor = (row, seriesDef) => {
const raw = Number(row[seriesDef.key] || 0);
if (scale.percent) return pad.t + h - h * clamp(raw / 100, 0, 1);
const denom = Math.max(1, scale.max - scale.min);
const v = clamp((raw - scale.min) / denom, 0, 1);
return pad.t + h - h * v;
return pad.t + h - h * clamp((raw - scale.min) / denom, 0, 1);
};
const grid = [0, 25, 50, 75, 100].map(yv => {
const y = pad.t + h - h * yv / 100;
@ -322,23 +467,25 @@ function drawColonyChart(values) {
return `<line x1="${pad.l}" y1="${y.toFixed(1)}" x2="${(pad.l + w).toFixed(1)}" y2="${y.toFixed(1)}" stroke="rgba(84,68,48,0.13)" stroke-width="1"/><text x="${pad.l - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="rgba(117,106,94,0.78)">${chartAxisLabel(rawLabel, scale)}</text>`;
}).join("");
const firstDay = Math.ceil(minT / dayLength);
const lastDay = Math.floor(maxT / dayLength);
const lastDay = Math.floor(chartMaxT / dayLength);
const dayMarkers = [];
for (let d = firstDay; d <= lastDay; d += 1) {
const t = d * dayLength;
if (t < minT - 0.001 || t > maxT + 0.001) continue;
if (t < minT - 0.001 || t > chartMaxT + 0.001) continue;
const x = xForTime(t);
const label = chartSeasonDayLabelForTime(t);
dayMarkers.push(`<line x1="${x.toFixed(1)}" y1="${pad.t}" x2="${x.toFixed(1)}" y2="${(pad.t + h).toFixed(1)}" stroke="rgba(104,104,104,0.38)" stroke-width="1" stroke-dasharray="4 5"/><text x="${(x + 3).toFixed(1)}" y="${(pad.t + 11).toFixed(1)}" font-size="10" fill="rgba(92,92,92,0.76)">${label}</text>`);
const showLabel = rangeConfig.id !== "year" || ((d - firstDay) % 2 === 0);
dayMarkers.push(`<line x1="${x.toFixed(1)}" y1="${pad.t}" x2="${x.toFixed(1)}" y2="${(pad.t + h).toFixed(1)}" stroke="rgba(104,104,104,0.38)" stroke-width="1" stroke-dasharray="4 5"/>${showLabel ? `<text x="${(x + 3).toFixed(1)}" y="${(pad.t + 11).toFixed(1)}" font-size="10" fill="rgba(92,92,92,0.76)">${label}</text>` : ""}`);
}
const lines = series.map(s => {
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" ");
const last = rows[rows.length - 1];
const lines = series.map(seriesDef => {
const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, seriesDef).toFixed(1)}`).join(" ");
const latest = rows[rows.length - 1];
const cx = xFor(rows.length - 1).toFixed(1);
const cy = yFor(last, s).toFixed(1);
return `<polyline points="${pts}" fill="none" stroke="${s.color}" stroke-width="${s.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${s.color}"/>`;
const cy = yFor(latest, seriesDef).toFixed(1);
return `<polyline points="${pts}" fill="none" stroke="${seriesDef.color}" stroke-width="${seriesDef.key === "pop" ? 2.4 : 1.8}" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${cx}" cy="${cy}" r="2.7" fill="${seriesDef.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\u6642\u9593\u3054\u3068 / 1\u9031\u9593</text></svg>`;
chart.innerHTML = `<svg viewBox="0 0 ${cssW} ${cssH}" preserveAspectRatio="none" aria-hidden="true"><rect x="0" y="0" width="${cssW}" height="${cssH}" rx="14" fill="rgba(255,255,255,0.18)"/>${grid}${dayMarkers.join("")}${lines}<text x="${pad.l}" y="${cssH - 8}" font-size="10" fill="rgba(117,106,94,0.76)">${rangeConfig.footer}</text></svg>`;
uiCache.chartHoverState = { rows, series, minT, maxT: chartMaxT, pad, w, h, cssW, cssH, mode, range: rangeConfig.id, scale };
if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = allSeries.map(s => {
const hidden = hiddenSeries.has(s.key);
return `<button type="button" class="chart-series-toggle ${hidden ? "series-hidden" : ""}" data-series-key="${s.key}" aria-pressed="${hidden ? "false" : "true"}" title="${hidden ? "\u8868\u793a\u3059\u308b" : "\u975e\u8868\u793a\u306b\u3059\u308b"}"><i aria-hidden="true" style="background:${s.color}"></i><span>${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}</span></button>`;
@ -359,6 +506,7 @@ function chartSeries(mode = "population") {
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f5a2f" },
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
{ key: "obj_grave", label: "\u5893", color: "#77716a" },
{ key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" },
{ key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" },
{ key: "temperature", label: "\u6c17\u6e29", color: "#c46f3c" },

View file

@ -1,7 +1,5 @@
"use strict";
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
function lineageNodeSort(a, b) {
return (a.generation || 1) - (b.generation || 1) ||
(a.birthTime || 0) - (b.birthTime || 0) ||
@ -12,6 +10,10 @@ function lineageNodeSort(a, b) {
function lineageGeneticPercentFromNode(n, key = "") {
const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null;
if (entity?.geneticStatRatio) return Math.round((entity.geneticStatRatio(key) - 1) * 100);
if (Array.isArray(n?.geneticSummary)) {
const index = { life: 0, size: 1, attack: 2, speed: 3 }[key];
if (Number.isInteger(index) && Number.isFinite(Number(n.geneticSummary[index]))) return Math.round(Number(n.geneticSummary[index]));
}
const g = typeof normalizeGenetics === "function" ? normalizeGenetics(n?.genetics, n?.id || "") : (n?.genetics || {});
const map = { life: "lifeSpanMul", attack: "attackMul", speed: "speedMul", size: "sizeMul" };
const ratio = Number(g?.[map[key] || key]) || 1;
@ -24,11 +26,15 @@ function lineageSignedPercentText(value = 0) {
}
function familyDeathTimeText(value) {
return globalThis.TarinaiMemorialBellSystem?.formatWorldTime?.(value) || "";
}
function lineageTreeNodeHtml(n, x, y) {
const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null;
const live = entity && !entity.dead ? entity : null;
const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path;
const name = escapeHtml(n?.name || "\u4e0d\u660e");
const name = window.TarinaiUIHelpers.htmlEscape(n?.name || "\u4e0d\u660e");
const gen = n?.generation ? `G${n.generation}` : "G?";
const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : "";
const briefReason = familyDeathReasonLabel(rawReason, entity);
@ -38,15 +44,16 @@ function lineageTreeNodeHtml(n, x, y) {
const tags = rawTags.slice();
const displayTags = tags.slice(0, 4);
const tagText = tags.length ? ` / ${tags.join(" ")}` : "";
const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}${tagText}`);
const deathLine = "";
const tagLine = displayTags.length ? `<span class="family-personality-tags">${displayTags.map(tag => `<em>${escapeHtml(tag)}</em>`).join("")}</span>` : "";
const title = window.TarinaiUIHelpers.htmlEscape(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}${tagText}`);
const deathTimeText = !n?.alive ? familyDeathTimeText(n?.deathTime) : "";
const deathLine = deathTimeText ? `<span class="family-death-time">${window.TarinaiUIHelpers.htmlEscape(deathTimeText)}</span>` : "";
const tagLine = displayTags.length ? `<span class="family-personality-tags">${displayTags.map(tag => `<em>${window.TarinaiUIHelpers.htmlEscape(tag)}</em>`).join("")}</span>` : "";
const champion = !!(live?.isTarinaiChampion || n?.isTarinaiChampion);
const statLine = n ? `<span class="family-genetic-stats" title="\u5BFF\u547D / \u4F53\u683C / \u653B\u6483 / \u901F\u5EA6">
<span><b>\u5BFF</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "life")))}</span>
<span><b>\u4F53</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "size")))}</span>
<span><b>\u653B</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "attack")))}</span>
<span><b>\u901F</b>${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "speed")))}</span>
<span><b>\u5BFF</b>${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "life")))}</span>
<span><b>\u4F53</b>${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "size")))}</span>
<span><b>\u653B</b>${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "attack")))}</span>
<span><b>\u901F</b>${window.TarinaiUIHelpers.htmlEscape(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "speed")))}</span>
</span>` : "";
const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
const childCls = (n?.parents || []).length ? " child" : "";
@ -54,12 +61,12 @@ function lineageTreeNodeHtml(n, x, y) {
? (live.effectiveScale ? live.effectiveScale() : (Number.isFinite(live.scale) ? live.scale : (Number.isFinite(live.adultScale) ? live.adultScale : 0.28)))
: (Number.isFinite(n?.scale) ? n.scale : (Number.isFinite(n?.adultScale) ? n.adultScale : 0.28));
const iconScale = clamp((Number(iconBaseScale) || 0.28) / 0.28, 0.64, 1.42).toFixed(2);
const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : "";
const data = n?.id ? ` data-family-tarinai-id="${window.TarinaiUIHelpers.htmlEscape(n.id)}"` : "";
return `<div class="family-node tree-node ${cls}${childCls}${champion ? " champion" : ""}"${data} title="${title}" style="left:${Math.round(x)}px;top:${Math.round(y)}px;--family-icon-scale:${iconScale}">
<div class="family-portrait" aria-hidden="true"><img src="${img}" loading="lazy" decoding="async" alt=""></div>
<div class="family-copy">
<strong>${champion ? "\u265B " : ""}${name}</strong>
<span>${escapeHtml(gen)} / ${escapeHtml(status)}${champion ? " / \u738B\u8005" : ""}</span>
<span>${window.TarinaiUIHelpers.htmlEscape(gen)} / ${window.TarinaiUIHelpers.htmlEscape(status)}${champion ? " / \u738B\u8005" : ""}</span>
${statLine}
${deathLine}
${tagLine}
@ -378,7 +385,7 @@ function lineageBuildRowGroups(row, idSet) {
function lineageLayoutRows(rows, idSet) {
const NODE_W = 150;
const NODE_H = 104;
const NODE_H = 112;
const SIBLING_GAP = 24;
const GROUP_GAP = 42;
const PARTNER_GROUP_GAP = 18;

View file

@ -1,7 +1,5 @@
"use strict";
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
function lineageSplitByActualEdges(component) {
const family = lineageEdgeFamily(world.family || {});
const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values());
@ -116,7 +114,7 @@ function lineageFamilyHtml(component, index, family, signature = "") {
const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join("");
const empty = !layout.childLinks.length ? `<div class="lineage-empty-family">\u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>` : "";
const html = `<section class="lineage-family lineage-family-tree" data-lineage-key="${escapeHtml(cacheKey)}">
const html = `<section class="lineage-family lineage-family-tree" data-lineage-key="${window.TarinaiUIHelpers.htmlEscape(cacheKey)}">
<h3>\u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
<div class="lineage-tree-panel" style="width:${Math.ceil(layout.width)}px;height:${Math.ceil(layout.height)}px">
<svg class="lineage-links" viewBox="0 0 ${Math.ceil(layout.width)} ${Math.ceil(layout.height)}" aria-hidden="true">
@ -134,10 +132,11 @@ function lineageFamilyHtml(component, index, family, signature = "") {
function lineageArchiveNeedsRender() {
const familyVersion = world.familyVersion || 0;
const familyDirty = Boolean(world.familyTreeDirty);
// A cancelled/cleared DOM must never make the family view permanently blank
// merely because the cached data version still matches the world.
const hasSnapshot = Boolean(ui.archiveContent?.firstElementChild);
return !hasSnapshot || !uiCache.archiveVersion || uiCache.archiveFamilyVersion !== familyVersion || familyDirty;
// Only a completed tree or completed empty-state counts as a usable snapshot.
// A preparation/status shell left by an interrupted async render must be rebuilt.
const hasCompletedSnapshot = Boolean(ui.archiveContent?.querySelector?.(".lineage-family-tree, .selected-info.empty"));
const renderingNow = Boolean(uiCache.archiveRenderRunning && ui.archiveContent?.querySelector?.(".lineage-render-status"));
return (!hasCompletedSnapshot && !renderingNow) || !uiCache.archiveVersion || uiCache.archiveFamilyVersion !== familyVersion || familyDirty;
}
function lineageDeferArchiveIfOffscreen(hiddenInterval = 30000) {
@ -216,7 +215,8 @@ function renderArchive(options = {}) {
}
const family = world.family || {};
const version = String(world.familyVersion || 0);
if (uiCache.archiveVersion === version) {
const hasArchiveSnapshot = Boolean(ui.archiveContent?.querySelector?.(".lineage-family-tree, .selected-info.empty"));
if (uiCache.archiveVersion === version && hasArchiveSnapshot && !familyDirty) {
world.familyTreeDirty = false;
uiCache.archivePendingDirtyAfterRun = false;
return;

View file

@ -1,7 +1,5 @@
"use strict";
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const perfScale = window.TarinaiPerf.dprScale();
@ -15,7 +13,7 @@ function resizeCanvas() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#cfe3d3";
ctx.fillRect(0, 0, Math.max(1, rect.width), Math.max(1, rect.height));
world.setViewportSize(Math.max(1, rect.width), Math.max(1, rect.height));
world.setViewportSize(Math.max(1, rect.width), Math.max(1, rect.height), { resizeWorld: true, scaleContents: true });
uiCache.lastChartDraw = "";
if (typeof render === "function") render();
}
@ -30,7 +28,7 @@ function applyFieldLayout(fieldType = world.fieldType || "garden") {
root.style.setProperty("--field-mobile-min-height", layout.mobileMinHeight);
world.fieldType = field.id;
const rect = canvas.getBoundingClientRect();
world.setViewportSize(rect.width, rect.height);
world.setViewportSize(rect.width, rect.height, { resizeWorld: true, scaleContents: true });
resizeCanvas();
}
@ -89,8 +87,8 @@ function renderEcologyCards() {
const cards = window.TEXT_CATALOG?.ecologyCards || [];
ui.ecologyGrid.innerHTML = cards.map(card => `
<section class="ecology-card">
<img data-src="${escapeHtml(card.image || "assets/sprites/tarinai_01_smile.webp")}" loading="lazy" decoding="async" alt="">
<div><strong>${escapeHtml(card.title || "")}</strong><p>${escapeHtml(card.text || "")}</p></div>
<img data-src="${window.TarinaiUIHelpers.htmlEscape(card.image || "assets/sprites/tarinai_01_smile.webp")}" loading="lazy" decoding="async" alt="">
<div><strong>${window.TarinaiUIHelpers.htmlEscape(card.title || "")}</strong><p>${window.TarinaiUIHelpers.htmlEscape(card.text || "")}</p></div>
</section>`).join("");
ui.ecologyGrid.dataset.ready = "1";
}
@ -180,7 +178,13 @@ function showToast(text) {
function syncTopButtons() {
ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62";
ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`;
if (ui.speedSelect) ui.speedSelect.value = String(world.speed);
if (ui.speedMenuBtn) ui.speedMenuBtn.textContent = `速度 ×${world.speed}`;
for (const option of ui.speedMenuPanel?.querySelectorAll?.("[data-speed-value]") || []) {
const active = Number(option.dataset.speedValue) === Number(world.speed);
option.classList.toggle("active", active);
option.setAttribute("aria-checked", active ? "true" : "false");
}
syncAudioControls();
syncArchiveUpdateButton();
}
@ -204,8 +208,9 @@ function setArchiveUpdateEnabled(on) {
if (uiCache.archiveUpdateEnabled) {
uiCache.archiveVersion = "";
uiCache.archiveFamilyVersion = null;
uiCache.archiveViewActivated = true;
world.familyTreeDirty = true;
scheduleArchiveWindowRender?.();
renderArchive?.({ userRequested: true });
showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002");
} else {
if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; }

View file

@ -1,7 +1,5 @@
"use strict";
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
const LOG_EVENT_CATEGORIES = Object.freeze(["life", "danger", "social", "living", "environment", "observe"]);
const LOG_EVENT_CATEGORY_LABELS = Object.freeze({
life: "\u751f\u547d",
@ -50,15 +48,6 @@ function loadLogPushSettings() {
}
return;
}
// Keep the previous push master setting, but start the shared event filters
// with all categories visible so upgrading never hides historical logs.
const legacyRaw = localStorage.getItem("tarinai_log_push_settings_v2");
if (legacyRaw) {
const legacy = JSON.parse(legacyRaw);
uiCache.logPushEnabled = Boolean(legacy.enabled);
uiCache.logEventCategories = allLogEventCategories();
saveLogPushSettings();
}
} catch (_) {}
}
@ -151,7 +140,7 @@ function renderLog(logs) {
ui.log.innerHTML = "";
const frag = document.createDocumentFragment();
const touchFirst = Boolean(window.TarinaiInputMode.classification?.touchFirst || document.body.classList.contains("touch-mobile-ui") || window.innerWidth <= 980);
const limit = touchFirst ? 34 : 80;
const limit = touchFirst ? 34 : 40;
const visibleLogs = Array.isArray(logs)
? logs.map(raw => typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw).filter(shouldShowInEventLog)
: [];
@ -229,9 +218,9 @@ function relationListHtml(t, kind = "friend") {
if (!rows.length) return "\u306a\u3057";
const sign = kind === "friend" ? "+" : "";
return `<div class="relation-chip-list">${rows.map(r => `
<button type="button" class="relation-chip ${kind === "enemy" ? "enemy" : "friend"}" data-focus-tarinai-id="${escapeHtml(r.id)}" title="${escapeHtml(r.name)}\u3078\u79fb\u52d5">
<button type="button" class="relation-chip ${kind === "enemy" ? "enemy" : "friend"}" data-focus-tarinai-id="${window.TarinaiUIHelpers.htmlEscape(r.id)}" title="${window.TarinaiUIHelpers.htmlEscape(r.name)}\u3078\u79fb\u52d5">
${relationIconHtml(r.id)}
<span class="relation-chip-name">${escapeHtml(r.name)}</span>
<span class="relation-chip-name">${window.TarinaiUIHelpers.htmlEscape(r.name)}</span>
<em>${sign}${fmt(r.score)}</em>
</button>`).join("")}</div>`;
}

View file

@ -1,7 +1,5 @@
"use strict";
function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); }
function quoteNameInRecordText(text, name) {
const raw = String(text || "");
const n = String(name || "").trim();
@ -48,7 +46,7 @@ function personalityValueText(value) {
function personalityRowsHtml(personality = {}) {
const p = normalizePersonality(personality);
return PERSONALITY_KEYS.map(key => `<span>${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}</span>`).join("");
return PERSONALITY_KEYS.map(key => `<span>${window.TarinaiUIHelpers.htmlEscape(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}</span>`).join("");
}
function personalityShortLabel(key) {
@ -129,9 +127,9 @@ function personalityDailyChangeHtml(t) {
if (up > 0.0001) parts.push(`${personalityShortLabel(key)} +${up.toFixed(3)}`);
if (down > 0.0001) parts.push(`${personalityShortLabel(key)} -${down.toFixed(3)}`);
}
if (parts.length) rows.push(`<li><span>${escapeHtml(cause)}</span><strong>${escapeHtml(parts.join(" / "))}</strong><em>${escapeHtml(dayLabel)}</em></li>`);
if (parts.length) rows.push(`<li><span>${window.TarinaiUIHelpers.htmlEscape(cause)}</span><strong>${window.TarinaiUIHelpers.htmlEscape(parts.join(" / "))}</strong><em>${window.TarinaiUIHelpers.htmlEscape(dayLabel)}</em></li>`);
}
return rows.join("") || `<li><span>\u8981\u56e0</span><strong>\u5909\u5316\u306a\u3057</strong><em>${escapeHtml(dayLabel)}</em></li>`;
return rows.join("") || `<li><span>\u8981\u56e0</span><strong>\u5909\u5316\u306a\u3057</strong><em>${window.TarinaiUIHelpers.htmlEscape(dayLabel)}</em></li>`;
}
function personalityDailySnapshot(t) {
@ -153,15 +151,15 @@ function personalityDailySnapshot(t) {
function selectedDataCategoryHtml(id, label, bodyHtml) {
if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set();
const collapsed = uiCache.selectedCollapsedCategories.has(id);
return `<section class="selected-category${collapsed ? " collapsed" : ""}" data-selected-category="${escapeHtml(id)}">
<button class="selected-category-toggle" type="button" aria-expanded="${collapsed ? "false" : "true"}">${escapeHtml(label)}</button>
return `<section class="selected-category${collapsed ? " collapsed" : ""}" data-selected-category="${window.TarinaiUIHelpers.htmlEscape(id)}">
<button class="selected-category-toggle" type="button" aria-expanded="${collapsed ? "false" : "true"}">${window.TarinaiUIHelpers.htmlEscape(label)}</button>
<div class="selected-category-body">${bodyHtml}</div>
</section>`;
}
function selectedInfoRowHtml(label, valueHtml, metric = "") {
const metricAttr = metric ? ` data-metric="${escapeHtml(metric)}"` : "";
return `<div class="info-row"${metricAttr}><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
const metricAttr = metric ? ` data-metric="${window.TarinaiUIHelpers.htmlEscape(metric)}"` : "";
return `<div class="info-row"${metricAttr}><span>${window.TarinaiUIHelpers.htmlEscape(label)}</span><strong>${valueHtml}</strong></div>`;
}
@ -180,7 +178,7 @@ function selectedInventoryItems(t) {
function selectedInventoryHtml(t) {
const entries = selectedInventoryItems(t);
if (!entries.length) return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>\u306a\u3057</strong></div>`;
return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>${entries.map(v => `<span class="personality-tag">${escapeHtml(v)}</span>`).join("")}</strong></div>`;
return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>${entries.map(v => `<span class="personality-tag">${window.TarinaiUIHelpers.htmlEscape(v)}</span>`).join("")}</strong></div>`;
}
function renderSelected() {
@ -246,7 +244,7 @@ function renderSelected() {
if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}</span>`);
if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}</span>`);
const personalityTagHtml = personalityTags.length
? personalityTags.map(tag => `<span class="personality-tag">${escapeHtml(tag)}</span>`).join("")
? personalityTags.map(tag => `<span class="personality-tag">${window.TarinaiUIHelpers.htmlEscape(tag)}</span>`).join("")
: `<span class="personality-tag neutral">\u4e2d\u7acb</span>`;
const recentChangeHtml = personalityDailyChangeHtml(t);
const personalityHtml = `
@ -258,10 +256,10 @@ function renderSelected() {
const recentMemoryHtml = (t.records || []).slice(0, 7).map(r => {
const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0);
const recordText = quoteNameInRecordText(r.text || "", t.name);
return `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(recordText)}</strong></li>`;
return `<li><span>${window.TarinaiUIHelpers.htmlEscape(timeText)}</span><strong>${window.TarinaiUIHelpers.htmlEscape(recordText)}</strong></li>`;
}).join("") || `<li><span>-</span><strong>\u307e\u3060\u8a18\u61b6\u304c\u3042\u308a\u307e\u305b\u3093</strong></li>`;
const basicHtml = `
<div class="info-row"><span>\u4e16\u4ee3</span><strong>${escapeHtml(generationDisplay || "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u4e16\u4ee3</span><strong>${window.TarinaiUIHelpers.htmlEscape(generationDisplay || "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u5e74\u9f62</span><strong>${fmt(t.age)} / ${fmt(t.lifeSpan)}</strong></div>
<div class="info-row"><span>\u6700\u5927\u5bff\u547d</span><strong>${geneticPercentText(t, "life")}</strong></div>
<div class="info-row"><span>\u653b\u6483\u529b</span><strong>${geneticPercentText(t, "attack")}</strong></div>
@ -273,10 +271,10 @@ function renderSelected() {
const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key;
const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10);
const chosen = behaviorState?.need === key ? ` data-chosen-need="true"` : "";
return `<div class="need-cell"${chosen}><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
return `<div class="need-cell"${chosen}><span>${window.TarinaiUIHelpers.htmlEscape(label)}</span><strong>${window.TarinaiUIHelpers.htmlEscape(value)}</strong></div>`;
}).join("");
const healthHtml = [
`<div class="info-row action-reason-row"><span>\u884c\u52d5</span><strong>${escapeHtml(reasonLabel(t))}</strong></div>`,
`<div class="info-row action-reason-row"><span>\u884c\u52d5</span><strong>${window.TarinaiUIHelpers.htmlEscape(reasonLabel(t))}</strong></div>`,
`<div class="need-grid">${needRowsHtml}</div>`,
selectedInfoRowHtml("\u4f53\u529b", `${fmt(t.energy)} / ${fmt(typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100))}`, "energy"),
selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"),
@ -284,19 +282,19 @@ function renderSelected() {
selectedInfoRowHtml("\u6e80\u8179\u5ea6", fmt(Math.max(0, Math.min(100, 100 - (Number(t.hunger) || 0)))), "fullness"),
].join("");
const itemEffectHtml = itemEffectSummary.length
? itemEffectSummary.map(e => `<div class="info-row"><span>${escapeHtml(e.label)}</span><strong>${escapeHtml(e.detail)}</strong></div>`).join("")
? itemEffectSummary.map(e => `<div class="info-row"><span>${window.TarinaiUIHelpers.htmlEscape(e.label)}</span><strong>${window.TarinaiUIHelpers.htmlEscape(e.detail)}</strong></div>`).join("")
: `<div class="info-row"><span>\u30a2\u30a4\u30c6\u30e0\u52b9\u679c</span><strong>\u306a\u3057</strong></div>`;
const relationHtml = `
<div class="info-row relation-row relation-list-row"><span>\u53cb\u9054</span><strong>${relationListHtml(t, "friend")}</strong></div>
<div class="info-row relation-row relation-list-row"><span>\u6575\u5bfe</span><strong>${relationListHtml(t, "enemy")}</strong></div>
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u89aa</span><strong>${window.TarinaiUIHelpers.htmlEscape(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
<div class="info-row"><span>\u3051\u3093\u304b\u52dd\u7387</span><strong>${escapeHtml(fightWinRateLabel(t))}</strong></div>
<div class="info-row"><span>\u3051\u3093\u304b\u52dd\u7387</span><strong>${window.TarinaiUIHelpers.htmlEscape(fightWinRateLabel(t))}</strong></div>
<div class="info-row"><span>\u79f0\u53f7</span><strong>${t.isTarinaiChampion ? "\u305f\u308a\u306a\u3044\u738b\u8005" : "\u306a\u3057"}</strong></div>
`;
ui.selectedInfo.innerHTML = `
<div class="selected-name-editor">
<input class="selected-name-input" data-selected-name data-selected-id="${escapeHtml(t.id)}" value="${escapeHtml(t.name)}" maxlength="18" aria-label="\u305f\u308a\u306a\u3044\u306e\u540d\u524d" title="\u540d\u524d\u3092\u7de8\u96c6">
<input class="selected-name-input" data-selected-name data-selected-id="${window.TarinaiUIHelpers.htmlEscape(t.id)}" value="${window.TarinaiUIHelpers.htmlEscape(t.name)}" maxlength="18" aria-label="\u305f\u308a\u306a\u3044\u306e\u540d\u524d" title="\u540d\u524d\u3092\u7de8\u96c6">
</div>
${effectBadges.length ? `<div class="selected-effects">${effectBadges.join("")}</div>` : ""}
<div class="selected-data-groups">

View file

@ -2,7 +2,7 @@
const SCALABLE_TOOLS = new Set(scalableToolIds());
const PHYSICS_ICON_TOOL_IDS = new Set(window.TarinaiItemToolMetadata?.PHYSICS_TOOL_IDS || []);
const TEXT_ICON_TOOL_IDS = new Set(["poke", "pinch", "water_hose", "copy", "undo", "redo", "area_delete", "clear_tool"]);
const TEXT_ICON_TOOL_IDS = new Set(["poke", "pinch", "water_hose", "copy", "undo", "redo", "area_delete", "clear_tool", "bell"]);
function drawPhysicsToolIcon(canvas, toolId) {
@ -140,7 +140,6 @@ const PICK_MODE_NAMES = Object.freeze({
function normalizedToolPickMode() {
const value = String(world?.toolPickMode || "free").toLowerCase();
if (value === "grid") return "grid40";
return PICK_MODE_LABELS[value] ? value : "free";
}
@ -342,8 +341,8 @@ function renderToolPalette() {
if (!hasPhysicsIcon) attachToolSpritePreview(btn, def, toolId);
if (toolId === world?.tool) btn.classList.add("selected");
body.appendChild(btn);
if (category.id === "operate" && toolId === "clear_tool") body.appendChild(createToolPickModeControl());
}
if (category.id === "operate") body.appendChild(createToolPickModeControl());
section.append(toggle, body);
frag.appendChild(section);
}

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "39.16.109";
const APP_BUILD = "tarinai-ui-v39-16-109";
const APP_VERSION = "39.16.137";
const APP_BUILD = "tarinai-ui-v39-16-137";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -5,7 +5,9 @@ class World { constructor() {
this.h = 720;
this.viewportW = 1000;
this.viewportH = 720;
this._worldSizeInitialized = false;
this.time = 0;
this._fixedSimulationAccumulator = 0;
this.day = 1;
this.elapsedDays = 0;
this.paused = false;
@ -76,9 +78,16 @@ class World { constructor() {
resetRuntimeCollections() {
this.tarinai = [];
this.items = [];
this.residues = [];
this.residueById = new Map();
this.residueTypeBuckets = new Map();
this.residueCells = new Map();
this.residueSerial = 0;
this.residueDecayAccumulator = 0;
this.ants = [];
this.effects = [];
this.logs = [];
this.memorialRequests = [];
this.events = window.TarinaiEvents || null;
this.eventCounters = {};
this.countsTimer = 0;
@ -96,6 +105,7 @@ class World { constructor() {
this.achievementNaturalSlaveGenerations = [];
this.achievementNaturalKingGenerations = [];
this.achievementPlayerPlacementCount = 0;
this.achievementPlayerPlacedActiveCount = 0;
this.achievementManualTarinaiAddedCount = 0;
this.achievementDirectFeedCount = 0;
this.achievementRobotCleanCount = 0;

View file

@ -4,7 +4,7 @@
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_combat_effects.js");
const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall", "bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind"]);
const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall", "bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind", "ascend_soul"]);
const EFFECT_POOL_LIMIT_PER_TYPE = 96;
const effectPool = new Map();
@ -28,13 +28,15 @@
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
queueEffect(type, x, y, options = {}) {
// The visual-effects toggle is a true simulation gate: when disabled,
// do not allocate, enqueue, update, or draw transient Effect objects.
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null;
const opts = options || {};
const forceVisual = opts.forceVisual === true || type === "ascend_soul";
// Semantic one-shot effects such as grave ascension remain visible even when
// optional ambient effects are disabled.
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off" && !forceVisual) return null;
const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1))));
const effect = acquireEffect(type, x, y, opts);
effect._effectImportance = importance;
effect._forceVisual = forceVisual;
this.effects.push(effect);
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
this._effectSpawnStats = this._effectSpawnStats || {};
@ -45,8 +47,9 @@
},
spawnEffect(type, x, y, options = {}) {
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null;
const opts = options || {};
const forceVisual = opts.forceVisual === true || type === "ascend_soul";
if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off" && !forceVisual) return null;
const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1))));
if (importance <= 1 && opts.allowOffscreen !== true) {
const margin = Math.max(80, Number(opts.offscreenMargin || 140));
@ -112,6 +115,53 @@
},
applyExplosionExternalForces(x, y, blastRadius, source = null, scale = 1, salt = "explosion-force") {
globalThis.TarinaiItemEnvironmentHazardSystem?.igniteOilNear?.(this, x, y, blastRadius * 1.05);
for (const can of this.itemsOfType?.("poison_gas_can") || []) {
if (!can || can.dead || distXY(x, y, can.x, can.y) > blastRadius) continue;
global.TarinaiStructureLifecycle?.deleteItem?.(this, can, { reason: "explosion", breaker: source });
}
const radius = Math.max(1, Number(blastRadius || 0) || 1);
// Mechanical bodies can have long custom shapes, so do not rely on a spatial
// query that only sees their center/radius. Scan the bounded item list and use
// the mechanical extent for blast reach; this keeps rotators and reciprocators
// consistent even immediately after placement or shape edits.
const affected = this.items || [];
let moved = 0;
for (const item of affected) {
if (!item || item.dead || item === source) continue;
const mechanical = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type);
if (!mechanical && item.type !== "grave") continue;
let dx = (Number(item.x || 0) || 0) - x;
let dy = (Number(item.y || 0) || 0) - y;
let d = Math.hypot(dx, dy);
const bodyExtent = mechanical ? Math.max(18, Number(global.TarinaiMechanicalSystem?.extent?.(item) || item.r || 0) || 0) : Math.max(16, Number(item.r || 0) || 0);
if (d > radius + bodyExtent) continue;
if (d < 0.001) {
const a = deterministicAngle(this, `${salt}-overlap`, source || item, item);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
const q = clamp(1 - d / Math.max(1, radius), 0, 1);
const force = (430 + q * 960) * Math.max(0.1, Number(scale || 1) || 1);
const nx = dx / d, ny = dy / d;
if (item.type === "grave") {
if (global.TarinaiMemorialBellSystem?.applyExternalForce?.(this, item, force, source, "explosion")) moved += 1;
continue;
}
const contactScale = Math.max(18, Math.min(160, Number(global.TarinaiMechanicalSystem?.extent?.(item) || item.r || 40) || 40)) * 0.55;
// Give rotators a small off-centre pressure component as well as translation.
// A purely radial force through the pivot produces zero torque and previously
// made blasts look as though they did nothing to a rotator.
const tangentOffset = item.type === "rotator" ? Math.min(56, contactScale * 0.42) : 0;
const contactX = item.x - nx * contactScale - ny * tangentOffset;
const contactY = item.y - ny * contactScale + nx * tangentOffset;
const hit = global.TarinaiMechanicalSystem?.applyExternalImpulse?.(item, contactX, contactY, nx * force, ny * force, this, item.type === "rotator" ? 1.18 : 1);
if (hit) moved += 1;
}
if (moved) { this.markSpatialDirty?.("explosion-external-force"); this.drawListDirty = true; }
return moved;
},
blastLoosePinsFrom(x, y, blastRadius, source = null, scale = 1, salt = "blast") {
let blastedPins = 0;
const pinSystem = globalThis.TarinaiItemDynamicPinSystem;
@ -221,6 +271,7 @@
if (!t.dead && deterministicChance(this, "firecracker-panic-bubble", 0.45, it, t)) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
}
if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it);
this.applyExplosionExternalForces?.(x, y, blastRadius, it, it.blastScale || 1, "firecracker");
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
source: it,
@ -269,6 +320,7 @@
this.spawnEffect("ring", x, y, { size: (30 + r * 24) * blastScale, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" });
}
this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale);
this.applyExplosionExternalForces?.(x, y, blastRadius, t, blastScale, "disease-explosion");
for (let i = 0; i < 34; i++) {
const a = Math.PI * 2 * i / 34 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i);
const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale;
@ -360,15 +412,18 @@
const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1);
const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7);
let cleaned = 0;
let itemTouched = false;
for (const it of this.nearbyItems(x, y, radius + 80)) {
if (!it || it.dead) continue;
const d = distXY(it.x, it.y, x, y);
if (d > radius + (it.r || 12)) continue;
const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1);
if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") {
it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p);
if (it.type === "zunchi") {
itemTouched = true;
it.amount -= dt * 230 * (0.35 + p);
cleaned += p;
} else if (it.type === "grass") {
itemTouched = true;
it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 135 * (0.30 + p));
it.growth = Math.max(0, (Number(it.growth) || 0) - dt * 0.50 * (0.20 + p));
if (it.amount <= 0.5) {
@ -379,6 +434,7 @@
}
cleaned += p * 0.72;
} else if (it.type === "fire") {
itemTouched = true;
it.amount = Math.max(0, (Number(it.amount) || 0) - dt * 540 * (0.35 + p));
it.flameLife = Math.max(0, (Number(it.flameLife) || 0) - dt * 5.2 * (0.35 + p));
it.fireShrinkPulse = Math.max(Number(it.fireShrinkPulse || 0), 0.18);
@ -388,12 +444,31 @@
}
cleaned += p * 1.15;
} else if (it.type === "grass_bed") {
itemTouched = true;
it.amount = 0;
it.hp = 0;
global.TarinaiStructureLifecycle.deleteItem(this, it, { reason: "water-hose-grass-bed", userReason: "\u6d17\u6d44\u3067\u6d41\u3055\u308c\u305f", wake: true });
global.TarinaiStructureLifecycle.deleteItem(this, it, { reason: "water-hose-grass-bed", userReason: "洗浄で流された", wake: true });
cleaned += p * 0.95;
} else if (it.type === "grave") {
itemTouched = true;
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, it, {
reason: "water-hose-grave",
userReason: "洗浄で流された",
wake: true,
});
if (removed) cleaned += p * 1.05;
}
}
for (const residue of this.nearbyResidues?.(x, y, radius + 80) || []) {
if (!residue || residue.dead) continue;
const d = distXY(residue.x, residue.y, x, y);
if (d > radius + (residue.r || 12)) continue;
const p = clamp(1 - d / (radius + (residue.r || 12)), 0, 1);
residue.amount = Math.max(0, (Number(residue.amount) || 0) - dt * 180 * (0.35 + p));
cleaned += p;
if (residue.amount <= 0.5) this.removeResidue?.(residue, "water-hose-residue");
else this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), "water-hose-residue");
}
for (const ef of this.effects || []) {
if (!ef || ef.dead) continue;
if (ef.type !== "bleed" && ef.type !== "splat") continue;
@ -432,11 +507,13 @@
}
if (cleaned > 0.25) {
this.drawListDirty = true;
this.markItemBucketsDirty?.("water-hose-clean");
this.markTerrainDirtyAt?.(x, y, radius + 84, "water-hose-clean");
this.markSpatialDirty?.("water-hose-clean");
this.compactItems?.();
this.updateItemCounts();
if (itemTouched) {
this.markItemBucketsDirty?.("water-hose-clean");
this.markSpatialDirty?.("water-hose-clean");
this.compactItems?.();
this.updateItemCounts();
}
}
},
@ -512,18 +589,29 @@
},
spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") {
if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return;
const bubbleLimit = Math.max(1, Number(CONFIG.bubbleLimit || 32) || 32);
let bubbleCount = Math.max(0, Number(this.effectCounts.bubble || 0) || 0);
if (bubbleCount >= bubbleLimit) {
bubbleCount = 0;
for (const effect of this.effects || []) if (effect && !effect.dead && effect.type === "bubble") bubbleCount += 1;
this.effectCounts.bubble = bubbleCount;
if (bubbleCount >= bubbleLimit) return;
}
if (text === "z") text = "Zzz...";
audio.bubble(text);
this.spawnEffect("bubble", x, y, {
const effect = this.spawnEffect("bubble", x, y, {
vx: deterministicRange(this, "bubble-vx", -2, 2, x, y, text),
vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text),
life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text),
size: 8,
text,
color,
forceVisual: true,
importance: 3,
});
if (!effect) return null;
this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1;
return effect;
},
spawnHeadbuttEffect(x, y) {

View file

@ -2,37 +2,47 @@
function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
const axes = ["aggression", "openness", "sociability"];
const inheritedUnit = (...parts) => typeof globalThis.stableUnit === "function" ? globalThis.stableUnit(...parts) : 0.5;
const axes = ["aggression", "openness", "sociability", "neuroticism"];
const profileFor = (birthSeed, profileOpts = {}) => globalThis.TarinaiSeedFactory?.birthProfile?.(birthSeed || "", profileOpts)?.birthPersonality || {};
const childBase = normalizePersonality(profileFor(seed, opts));
let inheritedAny = false;
const randomBase = normalizePersonality(profileFor(seed, opts));
const validParents = (parents || []).filter(Boolean).slice(0, 2);
if (!validParents.length) return null;
const child = { ...randomBase };
for (const key of axes) {
const values = validParents.map(parent => {
const parentBase = normalizePersonality(profileFor(parent.birthSeed || parent.familyKey || ""));
return Number(normalizePersonality(parent.birthPersonality || parentBase)[key]) || 0;
});
let inherited = values[0] || 0;
if (values.length > 1) {
const aWeight = 0.42 + inheritedUnit(seed, `personality-parent-weight-${key}`) * 0.16;
inherited = values[0] * aWeight + values[1] * (1 - aWeight);
}
const mutation = (inheritedUnit(seed, `personality-mut-${key}`) - 0.5) * 0.04;
child[key] = clampPersonalityValue(inherited * 0.94 + (Number(randomBase[key]) || 0) * 0.06 + mutation);
}
// Champion lineage boosts remain heritable on top of the stronger ordinary
// personality inheritance, so royal descendants still preserve that trait.
for (const key of ["aggression", "openness", "sociability"]) {
let totalLegacy = 0;
let parentCount = 0;
for (const parent of parents || []) {
if (!parent) continue;
for (const parent of validParents) {
if (!parent.isTarinaiChampion) 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;
const baseValue = Number(parentBase[key]) || 0;
const championTarget = clampPersonalityValue(Math.max(baseValue + 0.48, 0.86));
const championDelta = Math.max(0, championTarget - baseValue);
if (championDelta <= 0) continue;
totalLegacy += championDelta;
parentCount += 1;
}
if (parentCount > 0 && totalLegacy > 0) {
childBase[key] = clampPersonalityValue((Number(childBase[key]) || 0) + (totalLegacy / parentCount) * 0.70);
inheritedAny = true;
child[key] = clampPersonalityValue((Number(child[key]) || 0) + (totalLegacy / parentCount) * 0.82);
}
}
return inheritedAny ? childBase : null;
return child;
}
(function (global) {
@ -154,7 +164,8 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
for (const item of nearby) {
if (!item || item.dead || item.type === "trace" || item.type === "splat") continue;
const isSleepFurniture = sleepFurniture?.has?.(item.type) || ["bed", "nest_box", "pipe", "grass_bed"].includes(item.type);
if (!isSleepFurniture) continue;
const isMemorial = item.type === "grave";
if (!isSleepFurniture && !isMemorial) continue;
const isNest = global.TarinaiToolRuntime?.isNestContainerItem?.(item);
const isOwnedBed = item.type === "grass_bed";
const fallbackDistance = distXY(p.x, p.y, Number(item.x || 0), Number(item.y || 0));
@ -182,6 +193,9 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
if (best.type === "grass_bed") {
return { target: best, lines: ownedBedTooltipLinesLocal(worldRef, best), kind: "grass_bed" };
}
if (best.type === "grave") {
return { target: best, lines: global.TarinaiMemorialBellSystem?.graveTooltipLines?.(best) || ["墓"], kind: "grave" };
}
const definition = global.TarinaiToolRuntime?.itemDefinition?.(best.type) || null;
const lines = [definition?.label || String(best.type || "\u30aa\u30d6\u30b8\u30a7\u30af\u30c8")];
if (definition?.tooltip) lines.push(definition.tooltip);
@ -196,8 +210,9 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
const preset = normalizeResetPresetId(presetId);
this.fieldType = field.id;
this.fieldZoom = 1;
this.setViewportSize(this.viewportW, this.viewportH);
this.setViewportSize(this.viewportW, this.viewportH, { resizeWorld: true });
this.time = 0;
this._fixedSimulationAccumulator = 0;
this.day = 1;
this.elapsedDays = 0;
this.paused = false;
@ -289,6 +304,16 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
return t && !t.dead ? t : null;
},
compactFamilyGeneticSummary(genetics = null) {
const g = typeof normalizeGenetics === "function" ? normalizeGenetics(genetics, "") : (genetics || {});
return [
Math.round(((Number(g.lifeSpanMul) || 1) - 1) * 100),
Math.round(((Number(g.sizeMul) || 1) - 1) * 100),
Math.round(((Number(g.attackMul) || 1) - 1) * 100),
Math.round(((Number(g.speedMul) || 1) - 1) * 100),
];
},
recordFamily(t) {
if (!this.family) this.family = {};
const familyKey = this.tarinaiFamilyKey(t);
@ -332,6 +357,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
isTarinaiChampion: nextChampion,
alive: !t.dead,
deathReason: t.deathReason || "",
deathTime: t.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : null,
};
changed = true;
} else {
@ -356,6 +382,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
if ((Number(entry.totalFightLosses) || 0) !== nextFightLosses) changed = true;
if (!!entry.isTarinaiChampion !== nextChampion) changed = true;
if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true;
if ((entry.deathTime ?? null) !== (t.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : (entry.deathTime ?? null))) changed = true;
if (entry.alive !== nextAlive) changed = true;
if (!sameList(entry.parents, nextParents)) changed = true;
if (!sameList(entry.children, nextChildren)) changed = true;
@ -375,10 +402,22 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
entry.isTarinaiChampion = nextChampion;
entry.hasPaired = !!t.hasPaired;
entry.deathReason = t.deathReason || entry.deathReason || "";
entry.deathTime = t.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : (entry.deathTime ?? null);
entry.parents = nextParents;
entry.parentNames = [...(t.parentNames || entry.parentNames || [])];
entry.children = nextChildren;
}
if (t.dead && this.family[familyKey]) {
const archived = this.family[familyKey];
archived.geneticSummary = this.compactFamilyGeneticSummary(archived.genetics);
delete archived.genetics;
delete archived.personality;
delete archived.lifeSpan;
delete archived.growth;
delete archived.adultScale;
delete archived.totalFightWins;
delete archived.totalFightLosses;
}
if (changed) {
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("family-record");
@ -393,11 +432,12 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
if (!this.family) this.family = {};
if (this.familyCleanVersion === (this.familyVersion || 0)) return false;
const family = this.family;
const liveById = new Map((this.tarinai || []).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id));
const liveById = new Map((this.tarinai || []).filter(t => t && !t.dead).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id));
let changed = false;
const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id)))));
const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
for (const t of this.tarinai || []) {
if (!t || t.dead) continue;
const familyKey = this.tarinaiFamilyKey(t);
if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue;
const before = this.familyVersion || 0;
@ -428,15 +468,21 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
children,
birthTime: live?.birthTime ?? n.birthTime ?? 0,
scale: Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : (Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : 0.28))),
adultScale: Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : (Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : 0.28))),
growth: Number.isFinite(live?.growth) ? live.growth : (Number.isFinite(n.growth) ? n.growth : 1),
genetics: live && typeof normalizeGenetics === "function" ? normalizeGenetics(live.genetics, live.familyKey || live.id || "") : (typeof normalizeGenetics === "function" ? normalizeGenetics(n.genetics, id) : { ...(n.genetics || {}) }),
lifeSpan: Number(live?.lifeSpan || n.lifeSpan || 0) || 0,
totalFightWins: Number(live?.totalFightWins ?? n.totalFightWins ?? 0) || 0,
totalFightLosses: Number(live?.totalFightLosses ?? n.totalFightLosses ?? 0) || 0,
isTarinaiChampion: !!(live?.isTarinaiChampion || n.isTarinaiChampion),
...(live ? {
adultScale: Number.isFinite(live.adultScale) ? live.adultScale : (Number.isFinite(live.scale) ? live.scale : 0.28),
growth: Number.isFinite(live.growth) ? live.growth : 1,
genetics: typeof normalizeGenetics === "function" ? normalizeGenetics(live.genetics, live.familyKey || live.id || "") : { ...(live.genetics || {}) },
lifeSpan: Number(live.lifeSpan || 0) || 0,
totalFightWins: Number(live.totalFightWins || 0) || 0,
totalFightLosses: Number(live.totalFightLosses || 0) || 0,
isTarinaiChampion: !!live.isTarinaiChampion,
} : {
geneticSummary: Array.isArray(n.geneticSummary) ? n.geneticSummary.slice(0, 4) : this.compactFamilyGeneticSummary(n.genetics),
isTarinaiChampion: !!n.isTarinaiChampion,
}),
alive: live ? !live.dead : n.alive !== false,
deathReason: live?.deathReason || n.deathReason || "",
deathTime: live?.deathTime != null && Number.isFinite(Number(live.deathTime)) ? Number(live.deathTime) : (n.deathTime != null && Number.isFinite(Number(n.deathTime)) ? Number(n.deathTime) : null),
};
for (const key of Object.keys(next)) {
const a = family[id]?.[key];
@ -549,15 +595,25 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
},
markDead(t, reason) {
this.clearLiveReferencesTo(t);
if (!this.family) this.family = {};
const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason;
if (t && (t.deathTime == null || !Number.isFinite(Number(t.deathTime)))) t.deathTime = Number(this.time || 0) || 0;
global.TarinaiMemorialBellSystem?.captureDeath?.(this, t, finalReason);
this.clearLiveReferencesTo(t);
const familyKey = this.tarinaiFamilyKey(t);
if (familyKey && this.family[familyKey]) {
if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) {
if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason || this.family[familyKey].deathTime == null) {
this.family[familyKey].alive = false;
this.family[familyKey].deathReason = finalReason;
this.family[familyKey].deathTime = t?.deathTime != null && Number.isFinite(Number(t.deathTime)) ? Number(t.deathTime) : (Number(this.time || 0) || 0);
this.family[familyKey].geneticSummary = this.compactFamilyGeneticSummary(this.family[familyKey].genetics);
delete this.family[familyKey].genetics;
delete this.family[familyKey].personality;
delete this.family[familyKey].lifeSpan;
delete this.family[familyKey].growth;
delete this.family[familyKey].adultScale;
delete this.family[familyKey].totalFightWins;
delete this.family[familyKey].totalFightLosses;
this.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("death");
}

View file

@ -170,6 +170,7 @@
<label><input type="checkbox" data-robot-target="grass"> \u8349</label>
<label><input type="checkbox" data-robot-target="grass_bed"> \u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9</label>
<label><input type="checkbox" data-robot-target="water"> \u6c34\u6ef4</label>
<label><input type="checkbox" data-robot-target="grave"> \u5893</label>
</div>
<div class="robot-cleaner-targets robot-cleaner-target-scan">
<label><input id="robotCleanerHighSpeed" type="checkbox"> \u9ad8\u901f\u30e2\u30fc\u30c9</label>
@ -741,7 +742,7 @@
}
if (item.type === "robot_cleaner") {
const mask = Number(data.robotCleanerMask);
item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3);
item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x7f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3);
item.robotCleanerHighSpeed = Boolean(data.robotCleanerHighSpeed);
item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60);
item.robotCleanerTargetId = "";
@ -891,7 +892,7 @@
const timeEnd = dialog.querySelector("#pressureSwitchTimeEnd");
const timeStartLabel = dialog.querySelector("#pressureSwitchTimeStartLabel");
const timeEndLabel = dialog.querySelector("#pressureSwitchTimeEndLabel");
const normalizeMinute = (value, fallback) => Math.max(0, Math.min(1435, Math.round((Number.isFinite(Number(value)) ? Number(value) : fallback) / 5) * 5));
const normalizeMinute = global.TarinaiSignalSystem.normalizeMinute;
const formatMinute = value => { const minute = normalizeMinute(value, 0); return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; };
const targetDef = () => DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
const normalizeValue = (value, fallback, def = targetDef()) => {
@ -1513,7 +1514,7 @@
handleClick(x, y) {
const tool = this.tool;
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new"].includes(tool)) {
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new", "bell"].includes(tool)) {
const editable = findEditableItemAt(this, x, y);
if (editable && openEditableItemEditor(editable, this)) {
global.TarinaiCommands.setSelection(this, null) || (this.selected = null);
@ -1588,6 +1589,13 @@
return;
}
if (tool === "bell") {
const size = this.toolSizeFor?.("bell") || "medium";
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "bell", x, y });
global.TarinaiMemorialBellSystem?.ringAt?.(this, x, y, size);
return;
}
if (tool === "new") {
const spot = this.edgeSpawnPoint(x, y, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 });

View file

@ -201,12 +201,10 @@
this.ensureSpatial?.("nearby-items");
const out = this.spatialItemScratch || [];
out.length = 0;
if (this.spatial?.nearbyInto && this.spatial.staticItemCells && this.spatial.dynamicItemCells) {
this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise);
this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise);
return out;
}
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise);
if (!this.spatial?.nearbyInto) return out;
this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise);
this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise);
return out;
},
nearbyGrass(x, y, radius, precise = false) {
@ -253,26 +251,20 @@
},
nearbyFood(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-food");
if (this.spatial?.nearbySplitInto && this.spatial.staticFoodCells && this.spatial.dynamicFoodCells) {
return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise);
}
return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise);
if (!this.spatial?.nearbySplitInto) return this.spatialFoodScratch;
return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise);
},
nearbyObstacles(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-obstacles");
if (this.spatial?.nearbySplitInto && this.spatial.staticObstacleCells && this.spatial.dynamicObstacleCells) {
return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
}
return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
if (!this.spatial?.nearbySplitInto) return this.spatialObstacleScratch;
return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
},
nearbyHazards(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-hazards");
if (this.spatial?.nearbySplitInto && this.spatial.staticHazardCells && this.spatial.dynamicHazardCells) {
return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise);
}
return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise);
if (!this.spatial?.nearbySplitInto) return this.spatialHazardScratch;
return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise);
},
rebuildSpatial(force = false, reason = "manual") {
// Direct calls keep their old eager semantics. update() uses markSpatialDirty + ensureSpatial
@ -449,9 +441,135 @@
return item;
},
residueCellKey(x, y) {
const size = 160;
return `${Math.floor((Number(x) || 0) / size)}:${Math.floor((Number(y) || 0) / size)}`;
},
addResidue(type, x, y, opts = {}) {
const residueType = String(type || "");
if (residueType !== "trace" && residueType !== "splat") return null;
if (!(this.residueById instanceof Map)) this.residueById = new Map();
if (!(this.residueTypeBuckets instanceof Map)) this.residueTypeBuckets = new Map();
if (!(this.residueCells instanceof Map)) this.residueCells = new Map();
if (!Array.isArray(this.residues)) this.residues = [];
const id = String(opts.id || `res${++this.residueSerial}`);
const residue = {
id, type: residueType,
x: Number(x) || 0, y: Number(y) || 0,
r: Math.max(1, Number(opts.r ?? (residueType === "splat" ? 26 : 16)) || 1),
amount: Math.max(0, Number(opts.amount ?? (residueType === "splat" ? 260 : 220)) || 0),
seed: Number.isFinite(Number(opts.seed)) ? Number(opts.seed) : Math.random() * 1000,
dead: false,
_memorialReservedBy: String(opts._memorialReservedBy || ""),
};
this.residues.push(residue);
this.residueById.set(id, residue);
let typeBucket = this.residueTypeBuckets.get(residueType);
if (!typeBucket) { typeBucket = new Set(); this.residueTypeBuckets.set(residueType, typeBucket); }
typeBucket.add(residue);
const cellKey = this.residueCellKey(residue.x, residue.y);
residue._cellKey = cellKey;
let cell = this.residueCells.get(cellKey);
if (!cell) { cell = new Set(); this.residueCells.set(cellKey, cell); }
cell.add(residue);
this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r, 36), opts.reason || "residue-add");
return residue;
},
rebuildResidueIndex() {
if (!(this.residueById instanceof Map)) this.residueById = new Map();
if (!(this.residueTypeBuckets instanceof Map)) this.residueTypeBuckets = new Map();
if (!(this.residueCells instanceof Map)) this.residueCells = new Map();
this.residueById.clear(); this.residueTypeBuckets.clear(); this.residueCells.clear();
for (const residue of this.residues || []) {
if (!residue || residue.dead || residue.amount <= 0) continue;
this.residueById.set(String(residue.id), residue);
let typeBucket = this.residueTypeBuckets.get(residue.type);
if (!typeBucket) { typeBucket = new Set(); this.residueTypeBuckets.set(residue.type, typeBucket); }
typeBucket.add(residue);
const key = this.residueCellKey(residue.x, residue.y); residue._cellKey = key;
let cell = this.residueCells.get(key);
if (!cell) { cell = new Set(); this.residueCells.set(key, cell); }
cell.add(residue);
}
return this.residueById.size;
},
residueByRuntimeId(id) {
if (id == null || id === "") return null;
const residue = this.residueById?.get?.(String(id)) || null;
return residue && !residue.dead && residue.amount > 0 ? residue : null;
},
residuesOfType(type) {
return this.residueTypeBuckets?.get?.(String(type || "")) || [];
},
nearbyResidues(x, y, radius, type = "") {
const out = this._nearbyResidueScratch || (this._nearbyResidueScratch = []);
out.length = 0;
if (!(this.residueCells instanceof Map) || !this.residueCells.size) return out;
const size = 160;
const r = Math.max(0, Number(radius) || 0);
const minX = Math.floor(((Number(x) || 0) - r) / size), maxX = Math.floor(((Number(x) || 0) + r) / size);
const minY = Math.floor(((Number(y) || 0) - r) / size), maxY = Math.floor(((Number(y) || 0) + r) / size);
const wanted = String(type || "");
const r2 = r * r;
for (let cy = minY; cy <= maxY; cy++) for (let cx = minX; cx <= maxX; cx++) {
const cell = this.residueCells.get(`${cx}:${cy}`);
if (!cell) continue;
for (const residue of cell) {
if (!residue || residue.dead || residue.amount <= 0 || (wanted && residue.type !== wanted)) continue;
const dx = residue.x - x, dy = residue.y - y;
if (dx * dx + dy * dy <= r2) out.push(residue);
}
}
return out;
},
removeResidue(residue, reason = "residue-remove") {
if (!residue || residue.dead) return false;
residue.dead = true;
residue.amount = 0;
this.residueById?.delete?.(residue.id);
const typeBucket = this.residueTypeBuckets?.get?.(residue.type);
typeBucket?.delete?.(residue);
if (typeBucket && typeBucket.size === 0) this.residueTypeBuckets.delete(residue.type);
const cell = this.residueCells?.get?.(residue._cellKey || this.residueCellKey(residue.x, residue.y));
cell?.delete?.(residue);
if (cell && cell.size === 0) this.residueCells.delete(residue._cellKey || this.residueCellKey(residue.x, residue.y));
this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), reason);
return true;
},
updateResidues(dt) {
if (!Array.isArray(this.residues) || !this.residues.length) return 0;
this.residueDecayAccumulator = Math.max(0, Number(this.residueDecayAccumulator || 0)) + Math.max(0, Number(dt) || 0);
if (this.residueDecayAccumulator < 0.75) return 0;
const step = this.residueDecayAccumulator;
this.residueDecayAccumulator = 0;
let removed = 0;
for (const residue of this.residues) {
if (!residue || residue.dead) continue;
const before = Number(residue.amount || 0) || 0;
residue.amount = Math.max(0, before - step * (residue.type === "splat" ? 1.05 : 1.35));
if (residue.type === "splat" && Math.floor(before / 18) !== Math.floor(residue.amount / 18)) {
this.markTerrainDirtyAt?.(residue.x, residue.y, Math.max(residue.r || 24, 36), "splat-fade");
}
if (residue.amount <= 0.001 && this.removeResidue(residue, "residue-decay")) removed += 1;
}
if (removed > 0 || this.residues.length > 512) this.residues = this.residues.filter(r => r && !r.dead && r.amount > 0);
return removed;
},
noteItemInactive(item, reason = "item-inactive", notifyAchievements = true) {
if (!item || item._worldCountedActive !== true) return false;
item._worldCountedActive = false;
if (item._achievementPlayerPlaced && item._achievementPlayerPlacedCounted !== false) {
this.achievementPlayerPlacedActiveCount = Math.max(0, (Number(this.achievementPlayerPlacedActiveCount || 0) || 0) - 1);
item._achievementPlayerPlacedCounted = false;
}
if (item.type) this.itemCounts[item.type] = Math.max(0, (Number(this.itemCounts[item.type] || 0) || 0) - 1);
if (Number.isFinite(this._activeObjectCountCache)) this._activeObjectCountCache = Math.max(0, this._activeObjectCountCache - 1);
if (notifyAchievements && !this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { item, delta: -1, reason });
@ -489,10 +607,17 @@
this.itemOwnerBuckets.clear();
this.carriedPlushies.length = 0;
let activeObjectCount = 0;
let playerPlacedActiveCount = 0;
for (const it of this.items) {
if (!it || it.dead) continue;
activeObjectCount += 1;
it._worldCountedActive = true;
if (it._achievementPlayerPlaced) {
playerPlacedActiveCount += 1;
it._achievementPlayerPlacedCounted = true;
} else {
it._achievementPlayerPlacedCounted = false;
}
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);
@ -512,6 +637,7 @@
bucket.push(it);
}
this._activeObjectCountCache = activeObjectCount;
this.achievementPlayerPlacedActiveCount = playerPlacedActiveCount;
this.itemBucketsDirty = false;
this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1;
},

View file

@ -339,13 +339,22 @@
const oldH = this.h || height;
this.viewportW = Math.max(1, width || this.viewportW || 1000);
this.viewportH = Math.max(1, height || this.viewportH || 720);
const requestedScale = fieldWorldScale(this);
const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH);
this.w = this.viewportW * scale;
this.h = this.viewportH * scale;
// The playable world follows the CSS canvas size. Callers can opt out for
// presentation-only measurements, but normal viewport resizes keep the two
// dimensions linked and may scale existing contents to preserve layout.
const resizeWorld = opts.resizeWorld !== false || !this._worldSizeInitialized;
if (resizeWorld) {
const requestedScale = fieldWorldScale(this);
const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH);
this.w = this.viewportW * scale;
this.h = this.viewportH * scale;
this._worldSizeInitialized = true;
}
this.fieldZoom = this.normalizedFieldZoom(this.fieldZoom);
this.clampCamera();
if (opts.scaleContents && oldW > 0 && oldH > 0) {
if (resizeWorld && opts.scaleContents && oldW > 0 && oldH > 0) {
const sx = this.w / oldW;
const sy = this.h / oldH;
const scalePoint = (p) => {
@ -355,6 +364,8 @@
};
for (const t of this.tarinai || []) scalePoint(t);
for (const it of this.items || []) scalePoint(it);
for (const residue of this.residues || []) scalePoint(residue);
this.rebuildResidueIndex?.();
for (const ef of this.effects || []) scalePoint(ef);
scalePoint(this.pointer);
this.cameraX *= sx;

View file

@ -166,7 +166,7 @@ const expectedIds = [
assert(titles.below_absolute_zero_item === "物理法則を下側に超越", "absolute-zero title mismatch");
assert(titles.ignite_during_birth_ritual === "燃え上がる恋", "ritual ignition title mismatch");
assert(titles.safe_colony_25_5_minutes === "安全第一", "safe colony title mismatch");
assert(titles.great_mother_1000_births === "大いなる母", "great mother title mismatch");
assert(titles.great_mother_1000_births === "なる大地", "great mother title mismatch");
assert(titles.fight_pair_danger_kill === "喧嘩両成敗", "fight-pair title mismatch");
assert(titles.direct_feed_33 === "餌やりじいさん", "feeding title mismatch");
assert(titles.eternal_history_generation_10 === "悠久の歴史", "generation title mismatch");
@ -222,12 +222,16 @@ const expectedIds = [
const lowFpsCard = cards.find(node => node.dataset.achievementId === "low_fps_single_digit");
const sleepCard = cards.find(node => node.dataset.achievementId === "continuous_play_24_hours");
const livelyCard = cards.find(node => node.dataset.achievementId === "lively_making");
const hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
const disclosedSelfZunchiCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
const disclosedRainCard = cards.find(node => node.dataset.achievementId === "rain_shelter_all");
const disclosedMegalopolisCard = cards.find(node => node.dataset.achievementId === "megalopolis");
assert(firstBirthCard.children[1].children[0].children[1].textContent === descriptions.first_birth, "disclosed locked condition is not shown in the UI");
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard, livelyCard]) {
assert(card.children[1].children[0].children[1].textContent !== "", "requested pre-unlock condition is hidden");
}
assert(hiddenCard.children[1].children[0].children[1].textContent === "", "undisclosed locked condition is not hidden as question marks");
for (const card of [disclosedSelfZunchiCard, disclosedRainCard, disclosedMegalopolisCard]) {
assert(card.children[1].children[0].children[1].textContent !== "", "requested pre-unlock condition is hidden");
}
const groups = h.elements.get("achievementList").children;
assert(groups.length === 6, "achievement UI must contain exactly six category groups");
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "population", "experiment", "construction", "operation", "other"]), "achievement category order mismatch");
@ -306,14 +310,13 @@ const expectedIds = [
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "unrelated generations incorrectly counted as a lineage");
h = createHarness();
const feedWorldA = { time: 0, achievementDirectFeedCount: 0 };
const feedWorldA = { time: 0 };
for (let i = 0; i < 32; i += 1) h.api.recordDirectFeed({ world: feedWorldA });
assert(!unlocked(h, "direct_feed_33"), "direct feed unlocked before 33");
const feedWorldB = { time: 0, achievementDirectFeedCount: 0 };
h.api.resetWorldProgress(feedWorldA);
const feedWorldB = { time: 0 };
h.api.recordDirectFeed({ world: feedWorldB });
assert(!unlocked(h, "direct_feed_33"), "direct-feed count survived a field reset");
h.api.recordDirectFeed({ world: feedWorldA });
assert(unlocked(h, "direct_feed_33"), "direct feed did not unlock at 33 in one field");
assert(unlocked(h, "direct_feed_33"), "direct-feed count did not survive a field reset");
h = createHarness();
const diseased = Array.from({ length: 25 }, () => ({ fightDisease: true, dead: false }));
@ -567,24 +570,6 @@ const expectedIds = [
h.api.evaluateWorld(thermalWorld, { id: "relaxed" });
assert(unlocked(h, "below_absolute_zero_item"), "absolute-zero achievement did not unlock below absolute zero");
storage = new Map();
storage.set("tarinai_achievements_v2", JSON.stringify({
unlocked: { signal_automation_first: 1000, link_craftsman: 2000 },
pending: ["signal_automation_first", "link_craftsman"],
progress: { linkTypes: ["rope", "rod", "spring"] },
}));
h = createHarness(storage);
assert(unlocked(h, "mechanized_industry"), "legacy AND achievements were not migrated");
storage = new Map();
storage.set("tarinai_achievements_v2", JSON.stringify({
unlocked: { link_craftsman: 2000 },
pending: [],
progress: {},
}));
h = createHarness(storage);
h.api.recordSignalActivation({ world: {}, target: { type: "gate_fence" } });
assert(unlocked(h, "mechanized_industry"), "legacy link-craftsman progress was not reconstructed before a later signal");
storage = new Map();
h = createHarness(storage, "?debug=1");

View file

@ -17,7 +17,7 @@ css = read('css/components.css')
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
require(len(ids_js) == 76, 'JS achievement count is not 76')
require(len(ids_js) == 77, 'JS achievement count is not 77')
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
@ -39,8 +39,8 @@ checks = {
'self-zunchi unlock survives fatal ID release': 'const hitTarinaiIdBeforeDamage = String(t.id || t.releasedLiveId || "")' in read('js/item_dynamic_zunchi_system.js') and 'const eligibleSelfZunchiDeath = Boolean(' in read('js/item_dynamic_zunchi_system.js') and 'if (t.dead && eligibleSelfZunchiDeath)' in read('js/item_dynamic_zunchi_system.js'),
'held launched zunchi is disqualified': 'if (item.playerHeld || item._heldByPlayer)' in read('js/item_dynamic_zunchi_system.js') and 'target.achievementBurstNeverStopped = false' in read('js/ui_input_shared.js'),
'generation runs persist in saves': 'achievementNaturalSlaveGenerations' in read('js/snapshot_system.js') and 'achievementNaturalKingGenerations' in read('js/snapshot_system.js'),
'direct-feed field progress persists in saves': 'achievementDirectFeedCount' in read('js/snapshot_system.js'),
'direct-feed field progress resets with world': 'this.achievementDirectFeedCount = 0;' in read('js/world.js'),
'direct-feed cumulative progress is persistent': 'progress.directFeedCount' in ach and 'progress.directFeedCount + 1' in ach,
'direct-feed cumulative progress is not reset with world': 'progress.directFeedCount = 0' not in ach,
'simultaneous toast queue exists': 'let toastQueue = [];' in ach and 'runNextUnlockToast' in ach,
'achievement toast is top-left': 'top: 18px;' in css and 'left: 18px;' in css and 'right: auto;' in css,
'laxative frame-edge marker exists': '_achievementLaxativeActiveThisFrame' in read('js/tarinai_update_step_frame.js') and '_achievementLaxativeActiveThisFrame' in ach,
@ -73,7 +73,7 @@ checks = {
'toilet-sand field count': 'evaluateSandbox' in ach and 'item.type === "toilet"' in ach,
'ground-change hooks': 'recordGroundChange' in ach and 'recordGroundChange?.' in read('js/world_view.js'),
'idle observer timer': 'observerTarget' in ach and 'OBSERVER_GAME_DAYS = 3' in ach,
'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" in api,
'retired achievement migration removed': 'signal_automation_first' not in api and 'link_craftsman' not in api,
'automatic aggregate recovery sync': 'localUnlockSnapshot' in ach and 'action: "sync"' in ach and "['session', 'unlock', 'sync', 'reset', 'summary']" in api and "$action === 'sync'" in api,
'save-slot collection hook': 'recordSaveSlotsFilled' in ach and 'recordSaveSlotsFilled?.' in read('js/save_storage.js'),
'empty-click hook': 'recordEmptyClick' in ach and 'recordEmptyClick?.' in read('js/world_placement_log.js') and 'recordPauseClick' not in ach and 'pauseButton?.addEventListener' not in ach,

View file

@ -0,0 +1,47 @@
"use strict";
global.window = global;
global.TarinaiSnapshot = { version: 40 };
global.TarinaiSaveSchema = {
BINARY_SCHEMA_VERSION: 53,
FIELD_IDS: ["garden"],
itemTypeValue(_index, fallback = "") { return fallback; },
};
require("../js/save_codec.js");
// first_birth + statistician + memento_mori. Current v8 also stores persistent lively-making progress.
// Achievements 65-75 remain in the extended mask value at spell[3].
const progress = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 9999, 99];
const spell = [8, 1, 0, 1152, 100, [0, 1, 2], progress];
const snapshot = {
v: 40,
a: "tj1",
m: [1, 0, "garden", 0],
w: [0, 0, 0, 0, 0, -9990, 1, 0, "w0000000000000000", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
t: [],
i: [],
g: {
w: [[], [], 0, 0, 0, null, -1, null, 0, -1, -1],
t: [],
i: [],
s: spell,
},
};
const bytes = global.TarinaiSaveCodec.encodeBinarySnapshot(snapshot);
const decoded = global.TarinaiSaveCodec.decodeBinarySnapshot(bytes);
const restored = decoded?.g?.s;
if (!Array.isArray(restored) || restored[0] !== 8) throw new Error("achievement spell v8 was not restored");
if (restored[3] !== 1152) throw new Error("extended achievement mask bytes were lost");
if (restored[6]?.[14] !== 29) throw new Error("statistician progress was lost");
if (restored[6]?.[15] !== 9999) throw new Error("memento mori progress was lost");
if (restored[6]?.[16] !== 99) throw new Error("lively making progress was lost");
const oldSchemaBytes = Uint8Array.from(bytes);
oldSchemaBytes[0] = (oldSchemaBytes[0] & 0x80) | 52;
let rejectedOldSchema = false;
try { global.TarinaiSaveCodec.decodeBinarySnapshot(oldSchemaBytes); } catch (_) { rejectedOldSchema = true; }
if (!rejectedOldSchema) throw new Error("obsolete binary schema was accepted");
console.log("[OK] current-only schema 53, achievement v8 mask, and persistent progress binary round-trip passed");

View file

@ -7,8 +7,8 @@ const creature = read("js/simulation_creature_system.js");
const movement = read("js/tarinai_update_step_movement.js");
const social = read("js/tarinai_social_move_life.js");
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
ok(/offscreenCatchup: true/.test(creature) && /motionDt: Math\.min\(runDt, cadence\.near \? 0\.45 : 0\.75\)/.test(creature), "off-screen scheduled updates catch up bounded elapsed movement time");
ok(/context\.offscreenCatchup \? 0\.75 : 0\.05/.test(movement), "off-screen catch-up is allowed beyond one rendered-frame dt");
ok(/offscreenCatchup: true/.test(creature) && /motionDt: Math\.min\(runDt, 2\.4\)/.test(creature), "off-screen scheduled updates catch up the full accumulated movement time");
ok(/context\.offscreenCatchup \? 2\.4 : 0\.05/.test(movement), "off-screen catch-up is allowed beyond one rendered-frame dt");
ok(/context\.motionOnly \|\| context\.offscreenCatchup/.test(movement), "off-screen catch-up uses the light collision path");
ok(/const candidateBudget = Math\.max\(20, scanLimit \+ 8\)/.test(social), "dense social contact work has a fixed candidate budget");
ok(/if \(inspected >= candidateBudget\) break;/.test(social), "contact iteration stops at the candidate budget");

View file

@ -1675,7 +1675,7 @@ def colony_limits_and_defaults_smoke() -> None:
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:
if "inheritedChampionPersonality" not in family_social or "parent.isTarinaiChampion" not in family_social or "* 0.82" 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:

View file

@ -14,7 +14,7 @@ const render = read("js/render.js");
const tarinaiRender = read("js/tarinai_render.js");
const history = read("js/history_system.js");
ok(/reproductionCooldown:\s*30\b/.test(data), "reproduction cooldown reduces the current reproduction pace by about 30 percent");
ok(/reproductionCooldown:\s*39\b/.test(data), "reproduction cooldown reduces the current reproduction pace by about 30 percent");
ok(/normalMetrics\s*=\s*getImageMetrics\("smile"\)/.test(tarinaiRender), "birth ritual sprite is normalized to ordinary tarinai bounds");
ok(!/if \(sprite === "birth_ritual"\) \{\s*drawW \*= 0\.64;\s*drawH \*= 0\.64;/s.test(tarinaiRender), "legacy oversized ritual scalar is removed");
ok(/const STATIC_ITEM_BITMAP_TYPES = new Set\(\[[\s\S]*"plushie"[\s\S]*\]\);/.test(render), "expensive structural bitmap cache remains enabled");

View file

@ -1,11 +1,11 @@
"use strict";
const APP_VERSION = "39.16.109";
const APP_VERSION = "39.16.137";
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", "circuit_board_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", "seesaw_system", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_direct_feeding_system", "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", "game_dialogs", "achievements", "ui_helpers", "ui_log", "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", "circuit_board_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_environment_hazard_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", "memorial_bell_system", "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", "seesaw_system", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_direct_feeding_system", "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", "game_dialogs", "achievements", "ui_helpers", "ui_log", "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 = [
"./",