diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index 55074fa..a5d7199 100644 --- a/BUILD_NOTE.txt +++ b/BUILD_NOTE.txt @@ -6,7 +6,7 @@ tarinai_compact_save_stage4 - 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.94 +- 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. @@ -15,92 +15,92 @@ tarinai_compact_save_stage4 - Signal effects use the exported TarinaiEffect constructor. - Pressure-switch placement/editor overlays display the configured detection radius. -- v39.16.18: removed redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping; unified one-way-fence and bounce corrections while retaining debug diagnostics and defensive recovery paths. +- 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.17: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper. +- v39.16.105: fixed the undefined bodyFor reference in mechanical one-way-fence contact handling by using the canonical body velocity helper. -- v39.16.16: link rendering no longer depends on midpoint spatial visibility; bounce fences launch Tarinai without shock damage; passive springs are more under-damped and elastic. +- v39.16.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.16: population chart includes per-bucket births/deaths; rotators accept signed motor speed; champion personality bonuses inherit through descendants; champion-pair mate weighting is moderately reduced. -- v39.16.16: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction. +- v39.16.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.16: one-way fences allow Tarinai forward crossing in swept collision; chart legend dots toggle series; wire tools attach to Tarinai/ants; energized living endpoints panic and take damage; selected data reports electrocution. +- 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.19: 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: 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.20: 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: 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.21: 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: 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.22: 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: 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.23: 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.25: 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: 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.29: 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: 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.29: 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: 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.30: 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: 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.31: 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: unified in-world tooltip spacing, added the Hair Trigger preset, and replaced physics-category line-like tool icons with distinct semantic silhouettes. -- v39.16.35: 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: 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.37: 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: 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.38: 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: 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.39: restored the compact sticky operation palette, added exact-match panel search, reorganized colony settings/stats, and refreshed range-delete and mechanism icons. -- v39.16.40: 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. +- 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.41: Added local achievements and optional self-hosted shared unlock percentage API. +39.16.105: Added local achievements and optional self-hosted shared unlock percentage API. -39.16.42: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking. +39.16.105: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking. -39.16.44: 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: 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.44: 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: 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.45: 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: 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.46: 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: 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.47: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button. +39.16.105: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button. -39.16.48: 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: 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.49: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss. +39.16.105: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss. -39.16.51: 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: 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.52: 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: 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.53: 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: 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.54: 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 零体満足; 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.55: 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.56: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards. +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.57: 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: 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.59: 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.57 spells remain readable. -39.16.60: 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: 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.62: 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 41–43. +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 41–43. -39.16.63: 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. +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.64 +## 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.65 +## 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. @@ -108,26 +108,26 @@ tarinai_compact_save_stage4 - `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.66 +## 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.67 +## 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.68 +## 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.69 +## 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. @@ -136,7 +136,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema: 39/48. Older saves are intentionally unsupported. -## v39.16.70 +## 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. @@ -147,7 +147,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema: 40/49. Older saves are intentionally unsupported. -## v39.16.71 +## 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. @@ -156,7 +156,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. -## v39.16.72 +## 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. @@ -164,7 +164,7 @@ tarinai_compact_save_stage4 - Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. -## v39.16.73 +## 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. @@ -172,14 +172,14 @@ tarinai_compact_save_stage4 - Save snapshot/schema remains 40/49. -## v39.16.76 +## 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.78 +## 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. @@ -190,15 +190,15 @@ tarinai_compact_save_stage4 - 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.78: 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. +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.80 +## 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.85 +## 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. @@ -206,7 +206,7 @@ tarinai_compact_save_stage4 - Added binary-history regression coverage for storage shape, duplicate suppression, Undo restoration, and Redo restoration. -## v39.16.85 +## 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. @@ -214,32 +214,32 @@ tarinai_compact_save_stage4 - Render-layer sort keys are computed once per stack insertion. -## v39.16.85 +## 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.86 +## 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.88 +## 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.89 +## 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.90 +## 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. @@ -247,7 +247,7 @@ tarinai_compact_save_stage4 - Updated the Item lifecycle pipeline regression to validate local bucket/spatial invalidation when passive serving-food decay actually changes its visual footprint. -## v39.16.94 +## 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. @@ -256,7 +256,27 @@ tarinai_compact_save_stage4 - 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.94 +## 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.96: UI input/limits/zoom refinements, infection chance reduced to one-third, 75 achievements, grass spatial/hot-path optimization, summer flat temperature bonus removed. +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. diff --git a/FILES.json b/FILES.json index 22c5608..80f5ee5 100644 --- a/FILES.json +++ b/FILES.json @@ -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.98", + "version": "39.16.109", "entrypoints": { "app": "index.html", "runtime": "js/main.js", diff --git a/achievement_api.php b/achievement_api.php index d439797..3b7a2c8 100644 --- a/achievement_api.php +++ b/achievement_api.php @@ -81,6 +81,7 @@ const ACHIEVEMENT_IDS = [ 'well_informed', 'lively_making', 'memento_mori', + 'mad_scientist', ]; const DATA_DIR_NAME = '.achievement_data'; const DATA_FILE_NAME = 'state.json'; diff --git a/app_manifest.json b/app_manifest.json index 48459a8..3dd37b6 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.16.98", + "version": "39.16.109", "css": [ "css/base.css", "css/layout.css", diff --git a/assets/sounds/achievement_unlock.mp3 b/assets/sounds/achievement_unlock.mp3 new file mode 100644 index 0000000..9d814a7 Binary files /dev/null and b/assets/sounds/achievement_unlock.mp3 differ diff --git a/css/components.css b/css/components.css index a13e84e..a9ad7fb 100644 --- a/css/components.css +++ b/css/components.css @@ -335,13 +335,20 @@ border: 1px solid rgba(192, 138, 96, 0.24); background: rgba(255, 247, 239, 0.74); } +.event-category-heading { + margin-bottom: 7px; + font-size: 11px; + font-weight: 700; + color: rgba(82, 66, 52, 0.88); +} .push-master { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 700; - margin-bottom: 8px; + margin-top: 9px; + margin-bottom: 0; } .push-kind-grid { display: grid; @@ -419,6 +426,13 @@ .log-push-toast.log-push-weather::before { background: #7bbbdc; } .log-push-toast.log-push-grass::before { background: #8cc77a; } .log-push-toast.log-push-event::before { background: #8b82d6; } + +.log-push-toast.log-push-life::before { background: #82bd72; } +.log-push-toast.log-push-danger::before { background: #df845d; } +.log-push-toast.log-push-social::before { background: #b28bd0; } +.log-push-toast.log-push-living::before { background: #d7ad58; } +.log-push-toast.log-push-environment::before { background: #70aac8; } +.log-push-toast.log-push-observe::before { background: #9b9184; } .log-push-toast.leaving { animation: logPushOut .18s ease both; } diff --git a/css/layout.css b/css/layout.css index 18545dc..c05d30a 100644 --- a/css/layout.css +++ b/css/layout.css @@ -295,9 +295,15 @@ meter { .log-grass { border-left-color: rgba(72, 148, 67, 0.68); } .log-weather { border-left-color: rgba(92, 142, 210, 0.62); } .log-relation { border-left-color: rgba(204, 126, 176, 0.62); } -.log-observe { border-left-color: rgba(175, 146, 86, 0.62); } .log-event { border-left-color: rgba(132, 120, 210, 0.62); } +.log-life { border-left-color: rgba(105, 166, 96, 0.68); } +.log-danger { border-left-color: rgba(214, 104, 70, 0.72); } +.log-social { border-left-color: rgba(184, 116, 184, 0.66); } +.log-living { border-left-color: rgba(183, 151, 74, 0.68); } +.log-environment { border-left-color: rgba(82, 142, 170, 0.66); } +.log-observe { border-left-color: rgba(142, 132, 116, 0.62); } + /* Clearer tool category grouping. */ .tool-category[data-tool-category="operate"] { --tool-cat-a: rgba(224,239,255,0.82); --tool-cat-b: rgba(203,226,255,0.74); --tool-cat-line: rgba(82,128,190,0.26); } .tool-category[data-tool-category="food"] { --tool-cat-a: rgba(236,252,219,0.82); --tool-cat-b: rgba(214,244,194,0.74); --tool-cat-line: rgba(96,156,72,0.26); } diff --git a/index.html b/index.html index c00fce4..8e2e0bc 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ たりない観察 - - - - - - - - + + + + + + + +
@@ -159,19 +159,17 @@
-
- +
+
表示カテゴリ
- - - - - - - - - + + + + + +
+
@@ -293,173 +291,173 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/achievements.js b/js/achievements.js index c98a1e8..6dcbda4 100644 --- a/js/achievements.js +++ b/js/achievements.js @@ -33,6 +33,7 @@ const STATISTICIAN_TARGET = 30; const LIVELY_MAKING_TARGET = 100; const MEMENTO_MORI_TARGET = 10000; + const MAD_SCIENTIST_TARGET = 10; const FULL_SEASON_CYCLE_DAYS = 20; const PRE_UNLOCK_DESCRIPTION_IDS = new Set([ "natural_zunchi_slave", @@ -64,6 +65,7 @@ "elite_few", "lively_making", "memento_mori", + "mad_scientist", ]); const ACHIEVEMENT_CATEGORIES = Object.freeze([ Object.freeze({ @@ -81,7 +83,7 @@ }), Object.freeze({ id: "population", - title: "\u7e41\u6b96\u30fb\u4eba\u53e3", + title: "\u4eba\u53e3", ids: Object.freeze([ "first_birth", "natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations", "birth_50_in_60_seconds", "great_mother_1000_births", "eternal_history_generation_10", @@ -97,7 +99,7 @@ "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", - "chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai", + "chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai", "mad_scientist", ]), }), Object.freeze({ @@ -202,7 +204,8 @@ Object.freeze({ id: "statistician", title: "\u7d71\u8a08\u5b66\u8005", description: "\u7d71\u8a08\u306e\u500b\u4f53\u6570\u30fb\u6b32\u6c42\u30fb\u74b0\u5883\u30fb\u30b0\u30e9\u30d5\u8868\u793a\u5207\u66ff\u30dc\u30bf\u30f3\u3092\u7d2f\u8a0830\u56de\u62bc\u3059\u3002" }), Object.freeze({ id: "well_informed", title: "\u60c5\u5831\u901a", description: "\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3059\u308b\u3002" }), Object.freeze({ id: "lively_making", title: "\u8cd1\u308f\u3044\u3065\u304f\u308a", description: "\u300c\u305f\u308a\u306a\u3044\u3092\u8ffd\u52a0\u300d\u3067100\u4f53\u8ffd\u52a0\u3059\u308b\u3002" }), - Object.freeze({ id: "memento_mori", title: "\u30e1\u30e1\u30f3\u30c8\u30fb\u30e2\u30ea", description: "\u305f\u308a\u306a\u3044\u304c\u5408\u8a0810000\u4f53\u6b7b\u4ea1\u3059\u308b\u3002" }), + Object.freeze({ id: "memento_mori", title: "\u591a\u6570\u306e\u6b7b\u306f\u7d71\u8a08\u4e0a\u306e\u6570\u5b57\u3067\u3057\u304b\u306a\u3044", description: "\u305f\u308a\u306a\u3044\u304c\u5408\u8a0810000\u4f53\u6b7b\u4ea1\u3059\u308b\u3002" }), + Object.freeze({ id: "mad_scientist", title: "\u30de\u30c3\u30c9\u30b5\u30a4\u30a8\u30f3\u30c6\u30a3\u30b9\u30c8", description: "\u7570\u306a\u308b10\u5339\u306e\u305f\u308a\u306a\u3044\u306b\u602a\u3057\u3044\u85ac\u3092\u6295\u4e0e\u3059\u308b\u3002" }), ]); const definitionById = new Map(DEFINITIONS.map(def => [def.id, def])); const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [ @@ -246,7 +249,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 } }; + 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: [] } }; } function loadState() { @@ -299,6 +302,7 @@ statsButtonPressCount: Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(progressSource.statsButtonPressCount || 0) || 0))), totalDeathCount: Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(progressSource.totalDeathCount || 0) || 0))), manualTarinaiAddedCount: Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(progressSource.manualTarinaiAddedCount || 0) || 0))), + mysteryDrugTarinaiIds: [...new Set((Array.isArray(progressSource.mysteryDrugTarinaiIds) ? progressSource.mysteryDrugTarinaiIds : []).map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET), }; const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]); if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) { @@ -449,6 +453,10 @@ const count = state.unlocked[id] ? MEMENTO_MORI_TARGET : Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress?.totalDeathCount || 0) || 0))); return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / MEMENTO_MORI_TARGET * 100).toFixed(1)}%\uff08${count} / ${MEMENTO_MORI_TARGET}\u4f53\uff09`; } + if (id === "mad_scientist") { + const count = state.unlocked[id] ? MAD_SCIENTIST_TARGET : Math.min(MAD_SCIENTIST_TARGET, state.progress?.mysteryDrugTarinaiIds?.length || 0); + return `\u73fe\u5728 ${count} / ${MAD_SCIENTIST_TARGET}\u5339`; + } if (id === "continuous_play_1_hour") { const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs()); return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 60:00\uff09`; @@ -487,6 +495,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(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(":"); return `${unlocks}|${rates}|${local}`; @@ -656,6 +665,7 @@ function runNextUnlockToast() { if (toastActive || !toastQueue.length) return; const definition = toastQueue.shift(); + global.TarinaiAudio?.achievementUnlock?.(); if (!dom.toast || !dom.toastTitle) { global.showToast?.(`\u5b9f\u7e3e\u89e3\u9664: ${definition.title} \u2014 ${definition.description || ""}`); global.setTimeout(runNextUnlockToast, 80); @@ -919,6 +929,8 @@ state.progress.statsButtonPressCount = Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(state.progress.statsButtonPressCount || 0) || 0))); state.progress.totalDeathCount = Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress.totalDeathCount || 0) || 0))); state.progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(state.progress.manualTarinaiAddedCount || 0) || 0))); + if (!Array.isArray(state.progress.mysteryDrugTarinaiIds)) state.progress.mysteryDrugTarinaiIds = []; + state.progress.mysteryDrugTarinaiIds = [...new Set(state.progress.mysteryDrugTarinaiIds.map(id => String(id || "")).filter(Boolean))].slice(0, MAD_SCIENTIST_TARGET); return state.progress; } @@ -990,6 +1002,7 @@ state.unlocked.statistician ? 0 : Math.min(STATISTICIAN_TARGET, progress.statsButtonPressCount), 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), ], ]; } @@ -1083,6 +1096,10 @@ progress.statsButtonPressCount = Math.max(progress.statsButtonPressCount, Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(packed[14] || 0) || 0)))); progress.totalDeathCount = Math.max(progress.totalDeathCount, Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(packed[15] || 0) || 0)))); progress.manualTarinaiAddedCount = Math.max(progress.manualTarinaiAddedCount, Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(packed[16] || 0) || 0)))); + const importedMysteryDrugCount = Math.min(MAD_SCIENTIST_TARGET, Math.max(0, Math.floor(Number(packed[17] || 0) || 0))); + while (progress.mysteryDrugTarinaiIds.length < importedMysteryDrugCount) { + progress.mysteryDrugTarinaiIds.push(`imported:${progress.mysteryDrugTarinaiIds.length + 1}`); + } const progressChanged = previousProgress !== JSON.stringify(progress); changed = changed || progressChanged; @@ -1570,6 +1587,19 @@ tarinai._achievementFightMochiSerial = serial; tarinai._achievementFightMochiWorld = world; } + if (type === "mystery_drug" && tarinai && !state.unlocked.mad_scientist) { + const progress = ensureProgress(); + const identity = String(tarinai.familyKey || tarinai.id || ""); + if (identity && !progress.mysteryDrugTarinaiIds.includes(identity)) { + progress.mysteryDrugTarinaiIds = [...progress.mysteryDrugTarinaiIds, identity].slice(0, MAD_SCIENTIST_TARGET); + if (progress.mysteryDrugTarinaiIds.length >= MAD_SCIENTIST_TARGET) { + changed = unlock("mad_scientist", { ...detail, world, tarinai, count: progress.mysteryDrugTarinaiIds.length }) || changed; + } else { + saveState(); + renderIfDialogOpen(); + } + } + } if (MEDICINE_LEDGER_TYPES.includes(type) && !state.unlocked.medicine_ledger_all) { const progress = ensureProgress(); if (!progress.medicineTypes.includes(type)) { @@ -1857,6 +1887,9 @@ } function recordManualTarinaiAdded(detail = {}) { + // Adding a Tarinai is a direct player intervention and must invalidate + // the no-operation window used by the surveillance-camera achievement. + recordIntervention({ ...detail, tool: detail.tool || "new-tarinai" }); if (state.unlocked.lively_making) return false; const progress = ensureProgress(); progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount + 1); diff --git a/js/ants.js b/js/ants.js index f615baf..9fcd59d 100644 --- a/js/ants.js +++ b/js/ants.js @@ -100,7 +100,7 @@ class AntActor { this.targetRef = null; this.burnWanderAngle = rand(0, Math.PI * 2); this.hpBarTimer = Math.max(this.hpBarTimer || 0, 1.0); - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(10, this.r * 2.8), life: 0.18, color: "rgba(255,110,36,0.56)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(10, this.r * 2.8), life: 0.18, color: "rgba(255,110,36,0.56)" }); return true; } @@ -108,7 +108,7 @@ class AntActor { if (!this.burning && !(this.burnTimer > 0)) return false; this.burning = false; this.burnTimer = 0; - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(9, this.r * 2.2), life: 0.16, color: "rgba(108,192,236,0.58)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(9, this.r * 2.2), life: 0.16, color: "rgba(108,192,236,0.58)" }); return true; } @@ -158,7 +158,7 @@ class AntActor { if (distXY(this.x, this.y, it.x, it.y) <= contactRadius + Math.max(it.r || 16, 12)) globalThis.TarinaiItemDynamicToolSystem?.igniteGrassByFire?.(it, this, this.world, dt); } if (deterministicChance(this.world, "ant-fire-effect", dt * 2.0, this)) { - this.world?.effects?.push(new Effect("flame", this.x, this.y - this.r, { vx: rand(-2, 2), vy: rand(-14, -6), size: Math.max(4, this.r * 1.0), life: 0.22, color: "rgba(236,46,24,0.72)" })); + this.world?.queueEffect?.("flame", this.x, this.y - this.r, { vx: rand(-2, 2), vy: rand(-14, -6), size: Math.max(4, this.r * 1.0), life: 0.22, color: "rgba(236,46,24,0.72)" }); } if (this.burnTimer <= 0.01) this.extinguishFire("timeout"); return true; @@ -300,6 +300,7 @@ class AntActor { updateSearch(dt, home) { const target = this.chooseTarget(52); if (target) { + const alreadyCaptured = Boolean((target.antDraggedTimer || 0) > 0 && target.antDraggedBy); this.state = "drag"; this.targetId = target.id; this.targetToken = target.liveToken || 0; @@ -310,6 +311,7 @@ class AntActor { target.fearTimer = Math.max(target.fearTimer || 0, 0.55); target.thought = "\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b"; target.recordChangeCause?.("\u30a2\u30ea\u306b\u904b\u3070\u308c\u305f", "\u72b6\u614b"); + if (!alreadyCaptured) this.world?.log?.(`${target.name}\u304c\u30a2\u30ea\u306b\u6355\u307e\u3063\u305f\u3002`, "event", { participants: [target] }); audio.antDrag?.(); this.world.drawListDirty = true; return; @@ -416,10 +418,6 @@ class AntActor { globalThis.TarinaiAchievements?.recordAntDamageSource?.(this, target, { world: this.world, reason: "tarinai_struggle" }); this.hp -= dt * struggle; - if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) { - this.carryLogAt = this.world.time || 0; - this.world.log(`${target.name}\u304c\u30a2\u30ea\u306b\u5f15\u3063\u5f35\u3089\u308c\u3066\u3044\u308b\u3002`, "event", { participants: [target] }); - } if (this.hp <= 0) this.dieAsCorpse(home); } @@ -431,7 +429,7 @@ class AntActor { const corpse = new Item("ant_corpse", this.x, this.y); corpse.amount = 24; this.world.addItem?.(corpse, "ant-corpse"); - this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" })); + this.world.queueEffect?.("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }); if (home) syncAntNestCount(this.world, home); this.world.ensureItemBuckets?.("ant-corpse"); this.world.drawListDirty = true; diff --git a/js/audio.js b/js/audio.js index 1620371..99402d6 100644 --- a/js/audio.js +++ b/js/audio.js @@ -297,6 +297,7 @@ const audio = { explode() { if (!this.playSample("sfx_firecracker_explode", 0.30, "ops")) this.play("sfx_firecracker_explode", { category: "ops", minGap: 0.30 }); }, phase() { this.play("sfx_phase", { category: "notify", minGap: 1.0 }); }, notify() { this.play("sfx_notify", { category: "notify", minGap: 0.12 }); }, + achievementUnlock() { this.ensure(); this.play("sfx_achievement_unlock", { category: "notify", minGap: 0.35 }); }, uiClick() { this.play("sfx_ui_click", { category: "ops", minGap: 0.035 }); }, uiFold() { this.play("sfx_ui_fold", { category: "ops", minGap: 0.08 }); }, place(type = "") { diff --git a/js/circuit_board_system.js b/js/circuit_board_system.js index 38cdc4e..6f5a470 100644 --- a/js/circuit_board_system.js +++ b/js/circuit_board_system.js @@ -1099,7 +1099,7 @@ dialog.querySelector("[data-action=cancel]").onclick = close; dialog.querySelector("[data-action=save]").onclick = () => { applySerializedConfig(board, draft); evaluate(board); remapAttachedWireEndpoints(board, worldRef); - if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("circuit-board-edited"); worldRef.log?.("\u57fa\u76e4\u306e\u56de\u8def\u3092\u7d44\u307f\u66ff\u3048\u305f\u3002", "observe"); } + if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("circuit-board-edited"); } global.showToast?.("\u57fa\u76e4\u306e\u56de\u8def\u3092\u53cd\u6620\u3057\u307e\u3057\u305f\u3002"); global.render?.(); close(); }; diff --git a/js/colony_situation_system.js b/js/colony_situation_system.js index 038aefd..545f6d5 100644 --- a/js/colony_situation_system.js +++ b/js/colony_situation_system.js @@ -55,11 +55,18 @@ const crisisCollapseSignal = m.weakRatio >= 0.70 && m.criticalRatio >= 0.60; add("devastation", 120, m.n <= 10 && devastationSignal); add("crisis", 110, !m.populationIncreasing && ((crisisMortalitySignal && crisisSystemicStress) || crisisCollapseSignal)); - add("confusion", 100, (m.recentShock && (m.panicRatio >= 0.10 || m.safetyRatio >= 0.18)) || m.panicRatio >= 0.25); + const confusionPanicThreshold = Math.max(4, Math.ceil(m.n * 0.32)); + const confusionShockPanicThreshold = Math.max(3, Math.ceil(m.n * 0.18)); + const confusionShockSafetyThreshold = Math.max(5, Math.ceil(m.n * 0.30)); + const confusionSignal = m.panicCount >= confusionPanicThreshold + || (m.recentShock && m.panicCount >= confusionShockPanicThreshold && m.safetyHighCount >= confusionShockSafetyThreshold); + const breedingThreshold = Math.max(4, Math.ceil(m.n * 0.18)); + const breedingSignal = m.breedingCount >= breedingThreshold && m.breedingRatio >= 0.18; + add("confusion", 100, confusionSignal); add("fearful", 90, (m.avgSafety >= 50 && m.safetyRatio >= 0.28) || (m.recentShock && m.safetyRatio >= 0.18)); add("disease_spread", 82, m.diseaseRatio >= 0.25 || (m.diseasedCount >= 3 && m.diseaseRatio >= 0.16)); add("weakened", 74, !m.populationIncreasing && ((m.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34)); - add("breeding", 66, m.breedingRatio >= 0.10 || m.breedingCount >= 2 || m.now - (worldRef?.lastBirthAt || -999) <= 45); + add("breeding", 66, breedingSignal); add("happy", 46, m.avgStress < 28 && m.panicRatio < 0.08 && m.diseaseRatio < 0.12 && m.weakRatio < 0.18); add("isolated", 28, m.n <= 1); if (!candidates.length) return "relaxed"; diff --git a/js/data.js b/js/data.js index d270a43..e32a970 100644 --- a/js/data.js +++ b/js/data.js @@ -51,7 +51,7 @@ const CONFIG = { hungerPerMinute: 36.0, lonelinessPerMinute: 7.6, energyPerMinute: 8.2, - reproductionCooldown: 18, + reproductionCooldown: 30, dayLength: 120, standardTemperature: 15, summerTemperatureBoost: 0, diff --git a/js/fire_runtime_system.js b/js/fire_runtime_system.js index f6f0daf..a286aea 100644 --- a/js/fire_runtime_system.js +++ b/js/fire_runtime_system.js @@ -81,7 +81,7 @@ function updateFirecracker(item, dt, worldRef) { function consumeWaterForFire(worldRef, water, amount = 8) { if (!water) return false; water.amount = Math.max(0, Number(water.amount || 0) - amount); - worldRef?.effects?.push(new Effect("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" })); + worldRef?.queueEffect?.("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" }); worldRef?.markItemBucketsDirty?.("fire-water"); worldRef?.markSpatialDirty?.("fire-water"); return true; @@ -160,7 +160,7 @@ function updateFirecracker(item, dt, worldRef) { box.flameWaterCooldown = 0; box.nestFireEffectCooldown = 0; worldRef?.log?.("\u5de3\u7bb1\u306b\u706b\u304c\u71c3\u3048\u79fb\u3063\u305f\u3002", "danger"); - worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.35, life: 0.30, color: "rgba(255,122,48,0.56)" })); + worldRef?.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.35, life: 0.30, color: "rgba(255,122,48,0.56)" }); } worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire"); worldRef?.markItemBucketsDirty?.("nest-box-fire"); @@ -179,7 +179,7 @@ function updateFirecracker(item, dt, worldRef) { box.nestFireWetUntil = reason === "water" ? ((Number(worldRef?.time || box.world?.time || 0) || 0) + NEST_BOX_FIRE_WET_REIGNITE_DELAY) : 0; box.flameWaterCooldown = 0; box.nestFireEffectCooldown = 0; - worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.22, life: 0.24, color: "rgba(108,192,236,0.62)" })); + worldRef?.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.22, life: 0.24, color: "rgba(108,192,236,0.62)" }); worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire-extinguished"); worldRef?.markItemBucketsDirty?.("nest-box-fire-extinguished"); worldRef?.markSpatialDirty?.("nest-box-fire-extinguished"); @@ -207,7 +207,7 @@ function updateFirecracker(item, dt, worldRef) { } if (count) { worldRef.log?.(`\u708e\u4e0a\u3057\u305f\u5de3\u7bb1\u304b\u3089${count}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u8ffd\u3044\u51fa\u3055\u308c\u305f\u3002`, "danger", { participants: occupants }); - worldRef.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.55, life: 0.32, color: "rgba(255,92,38,0.58)" })); + worldRef.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.55, life: 0.32, color: "rgba(255,92,38,0.58)" }); worldRef.markSpatialDirty?.(reason); worldRef.drawListDirty = true; } @@ -231,7 +231,7 @@ function updateFirecracker(item, dt, worldRef) { box.dead = true; } worldRef?.log?.("\u5de3\u7bb1\u304c\u713c\u5931\u3057\u305f\u3002", "danger"); - worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.45, life: 0.34, color: "rgba(90,54,38,0.58)" })); + worldRef?.queueEffect?.("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.45, life: 0.34, color: "rgba(90,54,38,0.58)" }); worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-burnout"); worldRef?.markItemBucketsDirty?.("nest-box-burnout"); worldRef?.markSpatialDirty?.("nest-box-burnout"); @@ -290,7 +290,7 @@ function updateFirecracker(item, dt, worldRef) { fire.flameLife = Math.max(0, Number(fire.flameLife || 0) - 0.85); grass.health = clamp((grass.health ?? 1) - 0.035, 0, 1); if ((fire.amount || 0) <= 24 || (fire.flameLife || 0) <= 0.35) fire.amount = 0; - worldRef?.effects?.push(new Effect("ring", fire.x, fire.y, { size: Math.max(18, (fire.r || 14) * 1.8), life: 0.18, color: "rgba(118,188,94,0.46)" })); + worldRef?.queueEffect?.("ring", fire.x, fire.y, { size: Math.max(18, (fire.r || 14) * 1.8), life: 0.18, color: "rgba(118,188,94,0.46)" }); worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 28), "grass-extinguish-fire"); worldRef?.markItemBucketsDirty?.("grass-extinguish-fire"); worldRef?.markSpatialDirty?.("grass-extinguish-fire"); @@ -590,7 +590,7 @@ function updateFirecracker(item, dt, worldRef) { } pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item); } - worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * (2.4 + sizeScale * 0.65)), life: 0.30 + sizeScale * 0.045, color: "rgba(255,126,54,0.58)" })); + worldRef.queueEffect?.("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * (2.4 + sizeScale * 0.65)), life: 0.30 + sizeScale * 0.045, color: "rgba(255,126,54,0.58)" }); item.amount = 0; worldRef.markItemBucketsDirty?.("flame-firecracker"); worldRef.markSpatialDirty?.("flame-firecracker"); diff --git a/js/health.js b/js/health.js index e9868e9..1218c08 100644 --- a/js/health.js +++ b/js/health.js @@ -196,14 +196,14 @@ const HEALTH = (() => { if (typeof Effect === "undefined") return false; if ((world.effectCounts?.bubble || 0) >= (CONFIG?.bubbleLimit || 80)) return false; const r = Math.max(16, t.radius || 22); - world.effects?.push(new Effect("bubble", t.x, t.y - r * 1.38, { + world.queueEffect?.("bubble", t.x, t.y - r * 1.38, { vx: (Math.random() - 0.5) * 2.4, vy: -4.8 - Math.random() * 2.8, life: 2.4 + Math.random() * 0.6, size: 9, text, color, - })); + }); world.effectCounts.bubble = (world.effectCounts.bubble || 0) + 1; t.nextBubbleAt = Math.max(t.nextBubbleAt || 0, (world.time || 0) + 1.55); t._damageBubblePriorityUntil = Math.max(Number(t._damageBubblePriorityUntil || 0), (world.time || 0) + 1.55); diff --git a/js/history_system.js b/js/history_system.js index 6620c0d..626a3fd 100644 --- a/js/history_system.js +++ b/js/history_system.js @@ -2,8 +2,8 @@ // User-facing undo/redo for direct editing actions. (function (global) { - const MAX_HISTORY = 48; - const MAX_HISTORY_BYTES = 16 * 1024 * 1024; + const MAX_HISTORY = 32; + const MAX_HISTORY_BYTES = 8 * 1024 * 1024; function ensure(worldRef) { if (!worldRef) return null; diff --git a/js/impact_response_system.js b/js/impact_response_system.js index 5ac7c0a..0d81576 100644 --- a/js/impact_response_system.js +++ b/js/impact_response_system.js @@ -78,7 +78,7 @@ ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (10 + p * 12), -48, 48); ball.lastPokedAt = worldRef.time || 0; ballHit += 1; - worldRef.effects?.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, (ball.r || 18) * 1.1), life: 0.20, color: "rgba(255,255,255,0.52)" })); + worldRef.queueEffect?.("ring", ball.x, ball.y, { size: Math.max(18, (ball.r || 18) * 1.1), life: 0.20, color: "rgba(255,255,255,0.52)" }); } if (ballHit > 0) worldRef.markSpatialDirty?.("genkotsu-ball-impact"); } diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index 2b71cde..b513735 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -138,7 +138,7 @@ item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016)); if ((item.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(item.vx || 0, item.vy || 0) > 120) { item.lastHaySlowLogAt = worldRef.time || 0; - worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" })); + worldRef.queueEffect?.("ring", item.x, item.y, { size: Math.max(14, item.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }); } } @@ -217,7 +217,7 @@ 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)")); - worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color })); + worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }); } function normalizeAngleLocal(angle = 0, fallback = 0) { @@ -342,7 +342,7 @@ const phase = Math.sin(item.fanPulse || 0); const px = item.x + ax * (42 + 24 * phase); const py = item.y + ay * (42 + 24 * phase); - worldRef?.effects?.push(new Effect("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" })); + worldRef?.queueEffect?.("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" }); } return true; } @@ -372,13 +372,13 @@ // A fan should carry embers forward instead of merely dimming the source flame. // The source loses a little fuel, while the downwind flame handles normal fire contacts. fire.amount = Math.max(4, (fire.amount || 0) - (2.2 + falloff * 3.8)); - worldRef.effects?.push(new Effect("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), { + worldRef.queueEffect?.("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), { size: Math.max(7, (fire.r || 14) * 0.72), life: 0.22, color: "rgba(255,126,54,0.58)", vx: ax * (80 + falloff * 120), vy: ay * (80 + falloff * 120), - })); + }); worldRef.markItemBucketsDirty?.("fan-fire-spread"); worldRef.markSpatialDirty?.("fan-fire-spread"); worldRef.drawListDirty = true; @@ -429,7 +429,7 @@ t.lastFanSurpriseAt = worldRef?.time || 0; t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12); if (typeof t.spawnBubble === "function") t.spawnBubble("~", { life: 0.55 }); - worldRef?.effects?.push(new Effect("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" })); + worldRef?.queueEffect?.("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" }); } } @@ -522,13 +522,13 @@ if (!addedDrop) continue; spawned += 1; if (i < 8) { - worldRef.effects?.push(new Effect("fight", drop.x, drop.y, { + worldRef.queueEffect?.("fight", drop.x, drop.y, { size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i), life: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-life", 0.24, 0.42, balloon, i), color: "rgba(96,196,244,0.66)", vx: drop.vx * 0.42, vy: drop.vy * 0.42, - })); + }); } } if (spawned) { @@ -566,7 +566,7 @@ } if (worldRef && ((item.lastSplashTrailAt || -999) + 0.12 < (worldRef.time || 0)) && Math.hypot(item.vx || 0, item.vy || 0) > 72) { item.lastSplashTrailAt = worldRef.time || 0; - worldRef.effects?.push(new Effect("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 })); + worldRef.queueEffect?.("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 }); } worldRef?.markSpatialDirty?.("water-drop-motion"); worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(28, r * 3.2), "water-drop-motion"); @@ -624,11 +624,11 @@ // through amount/hp instead of assigning to .dead directly. balloon.amount = 0; balloon.hp = 0; - worldRef?.effects?.push(new Effect("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" })); + worldRef?.queueEffect?.("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" }); for (let i = 0; i < 9; i++) { const a = deterministicAngle(worldRef || balloon, "water-balloon-pop-piece", balloon, i); const s = deterministicRange(worldRef || balloon, "water-balloon-pop-speed", 24, 96, balloon, i); - worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s })); + worldRef?.queueEffect?.("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s }); } spawnWaterFromBalloon(balloon, worldRef); const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "\u706b" diff --git a/js/item_dynamic_pin_system.js b/js/item_dynamic_pin_system.js index c4612d8..bb62c03 100644 --- a/js/item_dynamic_pin_system.js +++ b/js/item_dynamic_pin_system.js @@ -63,7 +63,7 @@ if (!ball.stuckPinIds.includes(item.id)) ball.stuckPinIds.push(item.id); ball.stuckPinId = ball.stuckPinIds[0] || item.id; updateBallLodged(item, 0, worldRef); - worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(12, (item.r || 8) * 1.4), life: 0.16, color: item.type === "oshibyo" ? "rgba(73,119,205,0.42)" : "rgba(214,72,72,0.42)" })); + worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(12, (item.r || 8) * 1.4), life: 0.16, color: item.type === "oshibyo" ? "rgba(73,119,205,0.42)" : "rgba(214,72,72,0.42)" }); return true; } @@ -212,7 +212,7 @@ if (isOshibyo) { t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f"; worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); - worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); + worldRef.queueEffect?.("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }); return true; } if (t.enterPanic) { @@ -229,7 +229,7 @@ item.pinLogAt = worldRef.time || 0; worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); } - worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); + worldRef.queueEffect?.("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }); return true; } @@ -342,7 +342,7 @@ item.vx = clamp(baseVx * 0.45 + nx * 18, -90, 90); item.vy = clamp(baseVy * 0.45 + Math.max(12, ny * 18), -60, 110); item.spinVelocity = clamp((item.spinVelocity || 0) * 0.35 + (Math.sign(nx) || 1) * 0.9, -2.2, 2.2); - worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); + worldRef?.queueEffect?.("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }); if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306E\u753B\u92F2\u304C\u629C\u3051\u843D\u3061\u305F\u3002`, "observe", { participants: [t] }); } if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { diff --git a/js/item_dynamic_zunchi_system.js b/js/item_dynamic_zunchi_system.js index aa26681..fab788e 100644 --- a/js/item_dynamic_zunchi_system.js +++ b/js/item_dynamic_zunchi_system.js @@ -108,7 +108,7 @@ if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); } worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); - worldRef.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); + worldRef.queueEffect?.("zunchi_miasma", item.x, item.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" }); item.vx *= -0.18; item.vy *= -0.18; break; @@ -139,7 +139,7 @@ item.burning = false; item.burnTimer = 0; item.amount = 0; - worldRef?.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: Math.max(13, (item.r || 15) * 1.25), life: 0.42, color: "rgba(64,44,34,0.44)" })); + worldRef?.queueEffect?.("zunchi_miasma", item.x, item.y - 4, { size: Math.max(13, (item.r || 15) * 1.25), life: 0.42, color: "rgba(64,44,34,0.44)" }); } worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-burning"); worldRef?.markItemBucketsDirty?.("zunchi-burning"); @@ -183,7 +183,7 @@ } if ((item.amount || 0) <= 0.5) { item.amount = 0; - worldRef?.effects?.push(new Effect("zunchi_miasma", item.x, item.y - 4, { size: Math.max(12, (item.r || 15) * 1.15), life: 0.34, color: "rgba(64,44,34,0.34)" })); + worldRef?.queueEffect?.("zunchi_miasma", item.x, item.y - 4, { size: Math.max(12, (item.r || 15) * 1.15), life: 0.34, color: "rgba(64,44,34,0.34)" }); worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-decay-out"); worldRef?.markItemBucketsDirty?.("zunchi-decay-out"); worldRef?.markSpatialDirty?.("zunchi-decay-out"); diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js index 4c4482e..2eba1ed 100644 --- a/js/item_lifecycle_support.js +++ b/js/item_lifecycle_support.js @@ -69,7 +69,7 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o worldRef?.markItemBucketsDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); worldRef?.markSpatialDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); worldRef?.markTerrainDirty?.(changed ? "duplicator-direct-reloaded" : "duplicator-direct-loaded"); - if (options.log !== false) worldRef?.log?.(`\u8907\u88fd\u6a5f\u306b${duplicator.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, isPinType(loadType) ? "observe" : "food"); + // Direct duplicator loading is a player operation and is not an event-log entry. return true; } @@ -102,7 +102,7 @@ function duplicatorApplyStoredPin(duplicator, worldRef) { duplicator.usedCount = (duplicator.usedCount || 0) + 1; duplicator.nextDuplicatedPinAt = now + 0.82; duplicator.duplicatorPinFlash = 0.24; - worldRef.effects?.push(new Effect("ring", dish.x, dish.y, { size: Math.max(15, (duplicator.r || 34) * 0.62), life: 0.18, color: storedType === "oshibyo" ? "rgba(73,119,205,0.40)" : "rgba(214,72,72,0.42)" })); + worldRef.queueEffect?.("ring", dish.x, dish.y, { size: Math.max(15, (duplicator.r || 34) * 0.62), life: 0.18, color: storedType === "oshibyo" ? "rgba(73,119,205,0.40)" : "rgba(214,72,72,0.42)" }); worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("duplicator-pin-lodged"); return true; diff --git a/js/physics_helpers.js b/js/physics_helpers.js index 8d9219e..11d2b77 100644 --- a/js/physics_helpers.js +++ b/js/physics_helpers.js @@ -188,7 +188,7 @@ const now = worldRef.time || 0; if ((item.lastBounceFenceEffectAt || -999) + 0.08 <= now) { item.lastBounceFenceEffectAt = now; - worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(12, radius * 1.15), life: 0.16, color: "rgba(82,153,230,0.45)" })); + worldRef.queueEffect?.("ring", item.x, item.y, { size: Math.max(12, radius * 1.15), life: 0.16, color: "rgba(82,153,230,0.45)" }); } } } diff --git a/js/render.js b/js/render.js index 172eccb..6ec7897 100644 --- a/js/render.js +++ b/js/render.js @@ -1108,6 +1108,150 @@ function isEntityVisibleInRect(entity, rect, extra = 0) { return isPointVisibleInRect(entity.x, entity.y, renderRadiusForEntity(entity) + extra, rect); } +// Stable field objects are expensive because their vector art and shadows were +// rebuilt every frame even when nothing about the object changed. Cache only +// genuinely static visual types; dynamic/mechanical/consumable items keep the +// normal draw path. This preserves exact depth sorting because each item remains +// an individual render-stack entry and only its pixels are reused. +const STATIC_ITEM_BITMAP_TYPES = new Set([ + // Cache only artwork whose vector reconstruction is expensive enough to + // justify a dedicated backing canvas. Small consumables, zunchi, water and + // 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" +]); + +function canUseStaticItemBitmapCache(it, worldRef) { + if (!it || it.dead || !STATIC_ITEM_BITMAP_TYPES.has(String(it.type || ""))) return false; + if (isCachedTerrainItem(it) || isLinkRenderItem(it)) return false; + if (it.playerHeld || it._heldByPlayer || it.dragging || (it.dropTimer || 0) > 0) return false; + if (it.burning || (it.burnTimer || 0) > 0.02 || (it.nestFireEvacuationTimer || 0) > 0.02) return false; + if (it.isStructure && it.type === "plushie" && (it.carriedById || it.onHead)) return false; + if (Math.hypot(it.vx || 0, it.vy || 0) > 0.02) return false; + if (worldRef?.spatial?.isDynamicItem?.(it)) return false; + return true; +} + +function staticItemBitmapState(it, lighting) { + return { + lightBucket: Math.round((lighting?.light || 0) * 6), + warmthBucket: Math.round((lighting?.warmth || 0) * 3), + r: Number(it.r || 0), + angle: Number(it.angle || 0), + amount: Number(it.amount || 0), + servings: Number(it.foodServingsRemaining ?? -1), + servingsMax: Number(it.foodServingsMax ?? -1), + variant: String(it.zunchiVariant || it.variant || it.plushieVariant || it.plushieKind || ""), + toolSize: String(it.toolSize || ""), + text: String(it.text || it.label || ""), + state: String(it.state || it.stage || ""), + freshness: Number(it.freshness ?? -1), + fertility: Number(it.fertility ?? -1), + flash: Number(it.toiletFlash ?? 0), + color: String(it.color || ""), + active: Boolean(it.active), + }; +} + +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; + // Resetting canvas dimensions releases the backing pixel buffer in browsers + // before the item itself is eventually collected. + if (cache.canvas) { + cache.canvas.width = 0; + cache.canvas.height = 0; + } + it._staticRenderBitmapCache = null; + return true; +} + +function drawStaticItemBitmapCached(ctx, it, time, lighting, worldRef) { + if (!canUseStaticItemBitmapCache(it, worldRef)) { + releaseStaticItemBitmapCache(it); + it.draw(ctx, time, lighting); + return false; + } + // Close-up rendering remains vector-based, so cached rasterization never + // softens artwork. The expensive whole-field/zoomed-out path is cached. + if ((worldRef?.viewScale?.() || 1) > 1.35) { + releaseStaticItemBitmapCache(it); + it.draw(ctx, time, lighting); + return false; + } + 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; + 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(); + } + } + ctx.drawImage(cache.canvas, (it.x || 0) - radius, (it.y || 0) - 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; + if (pointer?.inside && distXY(it.x || 0, it.y || 0, pointer.x || 0, pointer.y || 0) < (it.r || 18) * 2.2) { + ctx.save(); + ctx.globalAlpha = 0.32; + ctx.strokeStyle = "rgba(255, 250, 220, 0.82)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.ellipse(it.x || 0, (it.y || 0) + (it.r || 18) * 0.1, (it.r || 18) * 1.35, (it.r || 18) * 0.92, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + return true; +} + +function drawRenderEntity(ctx, entity, time, lighting, worldRef) { + if (!entity) return false; + if (typeof Item !== "undefined" && entity instanceof Item) return drawStaticItemBitmapCached(ctx, entity, time, lighting, worldRef); + entity.draw(ctx, time, lighting); + return true; +} + function carriedPlushieRenderPose(it, worldRef) { if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return null; const owner = worldRef.liveTarinaiById?.(it.carriedById) || (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById); @@ -1849,7 +1993,7 @@ function render() { window.TarinaiPerf.setMetric("render.visibleLayered", renderStack.layered.length); const endBackItems = renderPerfBegin("render.draw.backItems"); for (const it of renderStack.backItems) { - it.draw(ctx, world.time, lighting); + drawStaticItemBitmapCached(ctx, it, world.time, lighting, world); } renderPerfEnd(endBackItems); @@ -1897,7 +2041,7 @@ function render() { const endLayered = renderPerfBegin("render.draw.layered"); for (const entry of renderStack.layered) { - entry.entity.draw(ctx, world.time, lighting); + drawRenderEntity(ctx, entry.entity, world.time, lighting, world); drawAttachedPinsForEntity(entry.entity); // A carried plushie shares its owner's depth position instead of being // rendered above every entity as a global attachment layer. @@ -1963,7 +2107,7 @@ function render() { // Light affecting the whole visible scene, including characters and ground. const endLighting = renderPerfBegin("render.lightingWeather"); if (renderVisualLevel("details") !== "low") drawAtmosphericLighting(ctx, screenW, screenH, lighting); - if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time, { alphaScale: effectLevel === "off" ? 0.42 : 1 }); + if (world.weather === "light_rain" && effectLevel !== "off") drawRain(ctx, screenW, screenH, world.time); renderPerfEnd(endLighting); const endUiOverlay = renderPerfBegin("render.uiOverlay"); diff --git a/js/robot_cleaner_system.js b/js/robot_cleaner_system.js index 4e72cc4..8195def 100644 --- a/js/robot_cleaner_system.js +++ b/js/robot_cleaner_system.js @@ -96,12 +96,12 @@ 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) && !robotTargetBlocked(item, t, worldRef) ? t : null; + return robotTargetAlive(t) ? 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 matchesKey = found && (found.type === key || (key === "ant_corpse" && (found.type === "trace" || found.type === "splat"))); - return matchesKey && robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null; + return matchesKey && robotTargetAlive(found) ? found : null; } function robotTargetCandidates(item, worldRef) { @@ -116,7 +116,6 @@ 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 (robotTargetBlocked(item, t, worldRef)) continue; out.push({ target: t, type: key }); } continue; @@ -126,7 +125,6 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains : (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key)); for (const it of buckets || []) { if (!it || it.dead || it === item) continue; - if (robotTargetBlocked(item, it, worldRef)) continue; out.push({ target: it, type: key }); } } @@ -145,7 +143,11 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains const target = rec.target; if (!robotTargetAlive(target)) continue; const d = distXY(item.x, item.y, target.x, target.y); - if (d < bestD) { best = target; bestType = rec.type; bestD = d; } + // Do not discard a target merely because a nest box or fence blocks the + // direct segment. The movement step can route around it. Prefer direct + // targets when distances are otherwise similar to avoid needless detours. + const score = d + (robotTargetBlocked(item, target, worldRef) ? 120 : 0); + if (score < bestD) { best = target; bestType = rec.type; bestD = score; } } if (best) { item.robotCleanerTargetId = best.id || ""; @@ -232,16 +234,12 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains item.robotCleanerCleanFlash = 0.42; item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1; global.TarinaiAchievements?.recordRobotClean?.({ world: worldRef, robot: item, target, type }); - worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" })); + worldRef.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; - if ((worldRef.time || 0) - Number(item.robotCleanerLastLogAt || -999) > 1.2) { - worldRef.log?.(`\u30ed\u30dc\u6383\u9664\u6a5f\u304c${label}\u3092\u7247\u4ed8\u3051\u305f\u3002`, "observe"); - item.robotCleanerLastLogAt = worldRef.time || 0; - } return true; } @@ -259,6 +257,9 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains if (!type) continue; const capture = robotCleanerCaptureRadius(item, target); if (distXY(item.x, item.y, target.x, target.y) <= capture) { + // Keep walls meaningful: a nearby target is collected only after the + // cleaner has reached a side with an unobstructed contact segment. + if (robotTargetBlocked(item, target, worldRef)) continue; if (cleanRobotTarget(item, target, worldRef, type)) changed = true; } } @@ -271,8 +272,19 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains let desiredAngle = Number(item.robotCleanerWanderAngle || 0) || 0; let desiredSpeed = speed; if (target && robotTargetAlive(target)) { - desiredAngle = Math.atan2((target.y || 0) - (item.y || 0), (target.x || 0) - (item.x || 0)); - desiredSpeed = speed; + let moveTarget = target; + const blocked = robotTargetBlocked(item, target, worldRef); + if (blocked && typeof worldRef.findTarinaiPathWaypoint === "function") { + const waypoint = worldRef.findTarinaiPathWaypoint(item, target, { + padding: Math.max(18, r * 0.72), + state: "robot_cleaner", + preferFullPath: true, + maxChecks: 54, + }); + if (waypoint) moveTarget = waypoint; + else desiredSpeed = speed * 0.35; + } + desiredAngle = Math.atan2((moveTarget.y || 0) - (item.y || 0), (moveTarget.x || 0) - (item.x || 0)); } else if ((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); diff --git a/js/signal_system.js b/js/signal_system.js index 750d993..bbbf547 100644 --- a/js/signal_system.js +++ b/js/signal_system.js @@ -64,9 +64,11 @@ function emitEffect(worldRef, type, x, y, options = {}) { if (!worldRef?.effects) return false; + if (typeof worldRef.queueEffect === "function") return Boolean(worldRef.queueEffect(type, x, y, options)); + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return false; const EffectCtor = global.TarinaiEffect || (typeof Effect === "function" ? Effect : null); if (typeof EffectCtor !== "function") return false; - worldRef.effects.push(new EffectCtor(type, x, y, options)); + worldRef.queueEffect?.(type, x, y, options); return true; } diff --git a/js/simulation_creature_system.js b/js/simulation_creature_system.js index 61dfb10..b1c5151 100644 --- a/js/simulation_creature_system.js +++ b/js/simulation_creature_system.js @@ -96,15 +96,40 @@ return true; } + function updateCursorCare(worldRef, dt) { + const pointer = worldRef?.pointer; + if (!pointer?.inside || !global.TarinaiCursorCareSystem?.updateOne) return 0; + // Cursor care is a direct pointer interaction and must remain responsive even + // when the expensive creature pipeline is cadence-throttled. One spatial + // query per frame is cheaper than testing every creature individually. + const candidates = worldRef.nearbyTarinai?.(pointer.x || 0, pointer.y || 0, 220, true) || []; + let updated = 0; + for (const t of candidates) { + if (!t || t.dead) continue; + if (global.TarinaiCursorCareSystem.updateOne(t, dt)) updated += 1; + } + return updated; + } + function updateCreatures(worldRef, dt) { const h = helpers; const end = global.TarinaiPerf.begin("update.tarinai.individual") || null; const visibleRect = h.updateVisibleWorldRect(worldRef, 160); const nearVisibleRect = h.updateVisibleWorldRect(worldRef, 520); const now = worldRef.time || 0; + updateCursorCare(worldRef, dt); const pressure = prepareMechanicalCrowdPressure(worldRef, visibleRect); const pressureActive = Boolean(pressure?.active); - const visibleFullBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000; + // Prevent a low-FPS feedback loop: at large populations a long frame makes + // nearly every visible creature's full update become due at once, which makes + // the next frame even longer. Under normal frame times this cap is disabled; + // only slow frames spread expensive full updates across subsequent frames. + const creatureCountForBudget = (worldRef.tarinai || []).length; + const antiSpiralVisibleBudget = creatureCountForBudget >= 160 && dt > 0.028 + ? Math.max(36, Math.min(96, Math.floor(2.8 / Math.max(0.028, dt)))) + : 1000000000; + const pressureVisibleBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000; + const visibleFullBudget = Math.min(antiSpiralVisibleBudget, pressureVisibleBudget); const visibleMotionBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureMotionBudget", 36)) : 1000000000; const collectDiagnostics = global.TarinaiPerf?.diagnosticsEnabled?.() === true; const stats = { @@ -169,6 +194,11 @@ if (creatureIndex >= creatureCount) creatureIndex = 0; const t = creatureList[creatureIndex++]; if (!t || t.dead) continue; + // Reproduction cooldown is simulation time, not AI-think time. Keep it + // advancing even when the expensive creature pipeline is cadence/budget + // throttled; otherwise large colonies can stay reproductively locked for + // many in-game minutes after the early population burst. + t.reproductionTimer = (Number(t.reproductionTimer || 0) || 0) - dt; stats.total += 1; const behaviorId = updatePolicy.behaviorId(t); inc(stats.states, behaviorId); @@ -205,8 +235,17 @@ const jitter = stableUnit(t.id || t.familyKey || Math.random(), `visible-creature-budget:${Math.floor(now * 5)}`); t._nextLowFreqUpdateAt = now + Math.max(dt, interval * (0.20 + jitter * 0.25)); stats.visibleFullSkips += 1; - stats.skipped += 1; - inc(stats.skippedStates, behaviorId); + // Keep movement visually continuous while deferring only the heavy + // AI/environment portion of the update. + if (cadence.smoothMotion && stats.visibleMotionUsed < stats.visibleMotionBudget + && global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) { + stats.smooth += 1; + stats.visibleMotionUsed += 1; + inc(stats.smoothStates, behaviorId); + } else { + stats.skipped += 1; + inc(stats.skippedStates, behaviorId); + } continue; } if (usesBackgroundBudget && stats.fullBudgetUsed >= stats.fullBudget) { @@ -225,7 +264,15 @@ stats.full += 1; if (cadence.visible) stats.visibleFullUsed += 1; if (usesBackgroundBudget) stats.fullBudgetUsed += 1; - global.TarinaiCreatureRuntime.updateOne(t, runDt, { context }); + // 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. + const runContext = cadence.visible + ? context + : { ...context, motionDt: Math.min(runDt, cadence.near ? 0.45 : 0.75), offscreenCatchup: true }; + global.TarinaiCreatureRuntime.updateOne(t, runDt, { context: runContext }); } else if (cadence.smoothMotion) { if (cadence.visible && stats.visibleMotionUsed >= stats.visibleMotionBudget) { stats.visibleMotionSkips += 1; diff --git a/js/simulation_effects_system.js b/js/simulation_effects_system.js index 275f36f..c8cf771 100644 --- a/js/simulation_effects_system.js +++ b/js/simulation_effects_system.js @@ -60,8 +60,22 @@ return true; } + function clearDisabledEffects(worldRef, effects) { + if (!effects?.length) return; + for (const effect of effects) if (effect) worldRef.releaseEffect?.(effect); + effects.length = 0; + worldRef._effectUpdateCursor = 0; + worldRef._renderEffectCursor = 0; + } + function updateEffects(worldRef, dt) { const effects = worldRef.effects || []; + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") { + clearDisabledEffects(worldRef, effects); + worldRef._effectUpdateStats = null; + return; + } + const beforePressure = effects.length; const pressure = compactEffectPressure(worldRef, effects); const total = effects.length; @@ -69,36 +83,36 @@ worldRef._effectUpdateFrameId = frameId; let lightweight = 0; - const budgeted = []; + let active = 0; for (const effect of effects) { if (!effect || effect.dead) continue; if (effect.type === "ring") { effect.life -= dt; effect._effectUpdatedFrame = frameId; lightweight += 1; - } else { - budgeted.push(effect); - } + } else active += 1; } - const active = budgeted.length; const budget = effectUpdateBudgetFor(active); let updated = 0; let runScale = 1; if (budget >= active) { - for (const ef of budgeted) { - if (updateEffect(ef, dt, frameId, 1)) updated += 1; + for (const effect of effects) { + if (!effect || effect.dead || effect.type === "ring") continue; + if (updateEffect(effect, dt, frameId, 1)) updated += 1; } } else if (active > 0) { + // Walk the source array directly instead of allocating a temporary + // budgeted-effects array every animation frame. runScale = Math.min(3.0, Math.max(1, active / Math.max(1, budget))); - let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % active; + let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % Math.max(1, effects.length); let scanned = 0; - while (updated < budget && scanned < active) { - const ef = budgeted[cursor]; - cursor = (cursor + 1) % active; + while (updated < budget && scanned < effects.length) { + const effect = effects[cursor]; + cursor = (cursor + 1) % Math.max(1, effects.length); scanned += 1; - if (!ef || ef.dead) continue; - updateEffect(ef, dt, frameId, runScale); + if (!effect || effect.dead || effect.type === "ring") continue; + updateEffect(effect, dt, frameId, runScale); updated += 1; } worldRef._effectUpdateCursor = cursor; diff --git a/js/simulation_maintenance_system.js b/js/simulation_maintenance_system.js index fdaa012..d3b642c 100644 --- a/js/simulation_maintenance_system.js +++ b/js/simulation_maintenance_system.js @@ -12,7 +12,7 @@ worldRef._nextItemCompactAt = now + 1.65; worldRef._nextAntCompactAt = now + 3.35; worldRef._nextEffectCountRefreshAt = now + 2.15; - worldRef._nextFamilyPruneAt = now + 9.1; + worldRef._nextTransientRuntimePruneAt = now + 4.75; if (!Number.isFinite(worldRef.nextGrassLimitCheckAt) || worldRef.nextGrassLimitCheckAt <= now) worldRef.nextGrassLimitCheckAt = now + 1.25; if (!Number.isFinite(worldRef.nextFireCompactAt) || worldRef.nextFireCompactAt <= now) worldRef.nextFireCompactAt = now + 0.45; if (!Number.isFinite(worldRef.nextGrassBedExpiryCheckAt) || worldRef.nextGrassBedExpiryCheckAt <= now) worldRef.nextGrassBedExpiryCheckAt = now + 2.85; @@ -50,12 +50,42 @@ worldRef._nextAntCompactAt = now + 4.3; worldRef.compactAnts?.(); } - if (worldRef.familyPrunePending && now >= (worldRef._nextFamilyPruneAt || 0)) { - worldRef._nextFamilyPruneAt = now + 8.0; - worldRef.familyPrunePending = false; - worldRef.pruneExtinctFamilies?.(); + // Runtime-only collision/social dedupe caches must expire independently of + // deaths. Stable live population does not imply stable entity identities, and + // the old death-triggered cleanup let these collections grow for many seasons. + if (now >= (worldRef._nextTransientRuntimePruneAt || 0)) { + worldRef._nextTransientRuntimePruneAt = now + 5.0; + if (worldRef.ballCollisionMemo instanceof Map) { + for (const [key, at] of worldRef.ballCollisionMemo) { + if (now - Number(at || 0) > 2.0) worldRef.ballCollisionMemo.delete(key); + } + } + if (worldRef.tarinaiCollisionMemo instanceof Map) { + for (const [key, at] of worldRef.tarinaiCollisionMemo) { + if (now - Number(at || 0) > 2.0) worldRef.tarinaiCollisionMemo.delete(key); + } + } + if (worldRef.relationNotices && typeof worldRef.relationNotices === "object") { + for (const [key, at] of Object.entries(worldRef.relationNotices)) { + if (now - Number(at || 0) > 120) delete worldRef.relationNotices[key]; + } + } + if (worldRef.fightPairCooldowns && typeof worldRef.fightPairCooldowns === "object") { + for (const [key, until] of Object.entries(worldRef.fightPairCooldowns)) { + if (Number(until || 0) <= now) delete worldRef.fightPairCooldowns[key]; + } + } + if (worldRef.resolvedFightIds && typeof worldRef.resolvedFightIds === "object") { + for (const [key, at] of Object.entries(worldRef.resolvedFightIds)) { + if (!Number.isFinite(Number(at)) || now - Number(at) > 8.0) delete worldRef.resolvedFightIds[key]; + } + } } + // Family graph normalization/component pruning is intentionally deferred to + // the family view. It is unrelated to simulation correctness and its full- + // graph sweep becomes increasingly expensive as generations accumulate. + const itemCountChanged = worldRef.items.length !== itemCountBefore || worldRef.items.length !== (worldRef._lastMaintenanceItemCount ?? worldRef.items.length); const effectCountChanged = worldRef.effects.length !== (worldRef._lastMaintenanceEffectCount ?? worldRef.effects.length); if (itemsCompacted || itemCountChanged || worldRef.itemBucketsDirty) { diff --git a/js/sound_pack.js b/js/sound_pack.js index 31cc592..26e78a7 100644 --- a/js/sound_pack.js +++ b/js/sound_pack.js @@ -7,6 +7,7 @@ categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 }, idGain: { sfx_notify: 2.60, + sfx_achievement_unlock: 1.00, sfx_ui_click: 2.35, sfx_ui_fold: 2.20, sfx_place: 2.10, @@ -36,6 +37,7 @@ sfx_zunchi: 0.14, sfx_ant_die: 0.14, sfx_notify: 0.10, + sfx_achievement_unlock: 0.35, voice_sunbath: 0.95, sfx_shoot: 0.045, sfx_bolt_action: 0.10, @@ -62,7 +64,8 @@ voice_major_damage_03: "assets/sounds/major_damage_03.wav", voice_major_damage_04: "assets/sounds/major_damage_04.wav", voice_temp_cold: "assets/sounds/voice_008_temperature_buruburu.wav", - voice_temp_hot: "assets/sounds/voice_009_temperature_achui.wav" + voice_temp_hot: "assets/sounds/voice_009_temperature_achui.wav", + sfx_achievement_unlock: "assets/sounds/achievement_unlock.mp3" }, categories: { voice: "\u305f\u308a\u306a\u3044\u306e\u58f0", @@ -76,7 +79,7 @@ sfx_eat: "voice", sfx_sleep: "voice", sfx_wake: "voice", sfx_birth: "voice", sfx_death: "voice", sfx_damage_soft: "voice", sfx_damage_heavy: "voice", sfx_fight_hit: "voice", sfx_fight_finish: "voice", sfx_disease: "voice", sfx_heal: "voice", sfx_ant_drag: "voice", sfx_ant_die: "voice", sfx_queen_ant: "voice", - sfx_notify: "notify", sfx_phase: "notify", + sfx_notify: "notify", sfx_phase: "notify", sfx_achievement_unlock: "notify", 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", diff --git a/js/structures.js b/js/structures.js index 41482ff..3425936 100644 --- a/js/structures.js +++ b/js/structures.js @@ -214,7 +214,7 @@ world?.markItemBucketsDirty?.("plushie-fling"); world?.markSpatialDirty?.("plushie-fling"); if (world) world.drawListDirty = true; - if (options.log !== false) world?.log?.("\u306C\u3044\u3050\u308B\u307F\u304C\u56DE\u8EE2\u3057\u306A\u304C\u3089\u98DB\u3093\u3067\u3044\u3063\u305F\u3002", "observe", { eventType: "plushie_fling", participants: owner ? [owner] : [] }); + // Player-triggered plushie flings are intentionally excluded from the event log. return true; } diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index 154b8a7..2e9726e 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -423,7 +423,7 @@ function createApproachMateActionSpec() { phrase: "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", weight: 48, selectTarget(t, world) { return selectMateTarget(t, world, 520); }, - canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && (world?.canAddTarinai?.(1) ?? true) && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + canStart(t, world, ctx = {}) { return !t.juvenile && (t.reproductionTimer || 0) <= 12 && (world?.canAddTarinai?.(1) ?? true) && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, start(t, world, ctx = {}) { const other = ctx.target ?? this.selectTarget(t, world, ctx); if (!other) return false; diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js index ab75305..f44d99e 100644 --- a/js/tarinai_building_behavior.js +++ b/js/tarinai_building_behavior.js @@ -230,6 +230,7 @@ function continueBuildPlan(t, world, dt) { structure.use?.(t, world); forceBuiltStructureVisualRefresh(world, structure, `build:${plan.type}:visual-refresh`); t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); + world.log?.(`${t.name}\u304c${label}\u3092\u4f5c\u3063\u305f\u3002`, "event", { participants: [t] }); applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type); t.buildPlan = null; return true; diff --git a/js/tarinai_cursor_care_system.js b/js/tarinai_cursor_care_system.js index f0513d7..b1bf01c 100644 --- a/js/tarinai_cursor_care_system.js +++ b/js/tarinai_cursor_care_system.js @@ -18,10 +18,12 @@ this.goodMode = "smile"; if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) { this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10); - for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { - this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { - vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" - })); + if (typeof this.world?.queueEffect === "function") { + for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { + this.world.queueEffect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { + vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" + }); + } } } diff --git a/js/tarinai_direct_feeding_system.js b/js/tarinai_direct_feeding_system.js index d7c606f..2d61e66 100644 --- a/js/tarinai_direct_feeding_system.js +++ b/js/tarinai_direct_feeding_system.js @@ -60,7 +60,7 @@ const radius = Math.max(18, Number(tarinai.radius || 22) || 22); for (let i = 0; i < count; i += 1) { const side = i % 2 ? 1 : -1; - worldRef.effects.push(new global.TarinaiEffect("heart", + worldRef.queueEffect?.("heart", tarinai.x + side * global.rand(radius * 0.06, radius * 0.82), tarinai.y - global.rand(radius * 0.38, radius * 1.72), { vx: side * global.rand(9, 30) + global.rand(-9, 9), @@ -68,11 +68,11 @@ size: global.rand(9, 17), life: global.rand(0.82, 1.36), color: "rgba(240,91,135,0.94)", - })); + }); } - worldRef.effects.push(new global.TarinaiEffect("ring", tarinai.x, tarinai.y, { + worldRef.queueEffect?.("ring", tarinai.x, tarinai.y, { size: Math.max(24, radius * 1.35), life: 0.28, color: "rgba(240,91,135,0.42)", - })); + }); return true; } @@ -138,10 +138,7 @@ global.TarinaiAchievements?.recordDirectCare?.({ world: worldRef, tarinai, item, type, beneficialRecovery, source: options.source || "direct" }); } - const sourceLabel = options.source === "pinch" ? "\u624b\u6e21\u3057\u3067" : "\u76f4\u63a5"; - worldRef.log?.(`${tarinai.name}\u306b${label}\u3092${sourceLabel}\u4e0e\u3048\u305f\u3002`, "food", { - participants: [tarinai], eventType: "direct_feed", sound: false, - }); + // Direct feeding is a player operation and is intentionally not logged. const removed = options.removeItem !== false ? removeGivenWorldItem(worldRef, item, label) : false; if (!removed && (worldRef.items || []).includes(item)) { diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index a8b51a1..b24e155 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -290,7 +290,7 @@ 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 && box.type === "nest_box" && (this.state === "seek_bed" || this.state === "sleep" || this.sleeping)); + 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); @@ -301,6 +301,9 @@ 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); } } @@ -595,13 +598,14 @@ this.burnWanderAngle = Number.isFinite(this.burnWanderAngle) ? this.burnWanderAngle : deterministicAngle(this.world, "tarinai-burn-angle", this, source || null); if (typeof this.enterPanic === "function") this.enterPanic({ threat: source, target: null, reason: text, fear: 2.2, wake: true, cause: "burning", surpriseTimer: 0.65, awakeLockTimer: 0.8 }); else this.setActionState?.("panic", { target: null, reason: text, wake: true }); - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(26, this.radius * 1.35), life: 0.26, color: "rgba(255,110,36,0.58)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(26, this.radius * 1.35), life: 0.26, color: "rgba(255,110,36,0.58)" }); const now = Number(this.world?.time || 0); if (force || !already || now >= Number(this.nextBurnVoiceAt || -999)) { this.nextBurnVoiceAt = now + 2.6; this.bubble?.("\u3042\u3061\u3085\u3044\uff01", 1.35, "rgba(214,82,38,0.90)", { force: true }); } this.recordChangeCause?.("\u708e\u4e0a", "\u708e\u4e0a"); + if (!already) this.world?.log?.(`${this.name}\u306b\u706b\u304c\u71c3\u3048\u79fb\u3063\u305f\u3002`, "accident", { participants: [this] }); if (ignitedDuringBirthRitual) globalThis.TarinaiAchievements?.recordIgnition?.({ world: this.world, tarinai: this, source }); return true; }, @@ -614,7 +618,8 @@ this.firePanicTimer = Math.max(this.firePanicTimer || 0, 0.25); this.hurtTimer = Math.max(this.hurtTimer || 0, 0.35); if (reason) this.thought = reason; - this.world?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(22, this.radius * 1.2), life: 0.22, color: "rgba(108,192,236,0.60)" })); + this.world?.queueEffect?.("ring", this.x, this.y, { size: Math.max(22, this.radius * 1.2), life: 0.22, color: "rgba(108,192,236,0.60)" }); + this.world?.log?.(`${this.name}\u306e\u706b\u304c\u6d88\u3048\u305f\u3002`, "accident", { participants: [this] }); return true; }, @@ -689,9 +694,9 @@ if (this.addStress) this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }); if (deterministicChance(this.world, "explosion-disease-spark", dt * 2.1, this)) { const a = deterministicAngle(this.world, "explosion-disease-spark-angle", this); - this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-rx", 0.2, 0.9, this), this.y + Math.sin(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-ry", 0.1, 0.8, this), { + this.world?.queueEffect?.("explosion", this.x + Math.cos(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-rx", 0.2, 0.9, this), this.y + Math.sin(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-ry", 0.1, 0.8, this), { vx: Math.cos(a) * deterministicRange(this.world, "explosion-disease-spark-vx", 10, 38, this), vy: Math.sin(a) * deterministicRange(this.world, "explosion-disease-spark-vy", 10, 38, this) - deterministicRange(this.world, "explosion-disease-spark-up", 8, 22, this), size: deterministicRange(this.world, "explosion-disease-spark-size", 5, 12, this), life: deterministicRange(this.world, "explosion-disease-spark-life", 0.18, 0.42, this), color: "rgba(255,64,48,0.86)" - })); + }); } if (this.explosionDiseaseTimer <= 0.01) { this.world?.explodeDiseaseTarinai?.(this); diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index 0734130..7a0757c 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -78,11 +78,19 @@ syncGrowthScale() { if (!Number.isFinite(this.adultScale)) this.adultScale = adultScaleFromGenetics(this.familyKey || this.id, this.genetics); - const nextScale = this.generation > 1 ? scaleForGrowth(this.adultScale, this.growth) : this.adultScale; + const currentGrowth = this.growth; + const isJuvenileNow = this.generation > 1 && currentGrowth < 1; + if (this.generation > 1) { + if (this._growthWasJuvenile === true && !isJuvenileNow) { + this.world?.log?.(`${this.name}\u304c\u6210\u9577\u3057\u3066\u6210\u4f53\u306b\u306a\u3063\u305f\u3002`, "birth", { participants: [this] }); + } + this._growthWasJuvenile = isJuvenileNow; + } + const nextScale = this.generation > 1 ? scaleForGrowth(this.adultScale, currentGrowth) : this.adultScale; this.scale = clamp(nextScale, 0.10, 0.42); if (this.refreshEffectiveSize) this.refreshEffectiveSize(); else this.radius = 56 * this.scale; - const growthBucket = Math.floor(clamp(this.growth || 0, 0, 1) * 20); + const growthBucket = Math.floor(clamp(currentGrowth || 0, 0, 1) * 20); if (this.generation > 1 && this.world?.recordFamily && this.familyJoined?.() && this.familyGrowthBucket !== growthBucket) { this.familyGrowthBucket = growthBucket; this.world.recordFamily(this); @@ -257,13 +265,15 @@ adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") { if (!other || !other.id || other === this) return; const rel = this.relationTo(other.id); + const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14; + const reverseBefore = other.relationships?.[this.id] || null; + const wasFriend = Math.max(rel.affinity || 0, reverseBefore?.affinity || 0) >= threshold; rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40); rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40); rel.fightsWon = rel.fightsWon || 0; rel.fightsLost = rel.fightsLost || 0; if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } this.relationCache = null; - const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14; if (affinityDelta > 0 && rel.affinity >= threshold && other.relationTo) { const reverse = other.relationTo(this.id); const mirrored = Math.max(reverse.affinity || 0, threshold + 0.25, Math.min(rel.affinity, rel.affinity * 0.78)); @@ -272,6 +282,11 @@ if (event && !reverse.lastEvent) { reverse.lastEvent = event; reverse.lastTime = this.world?.time || other.world?.time || 0; } other.relationCache = null; } + const reverseAfter = other.relationships?.[this.id] || null; + const isFriend = Math.max(rel.affinity || 0, reverseAfter?.affinity || 0) >= threshold; + if (!wasFriend && isFriend) { + this.world?.log?.(`${this.name}\u3068${other.name}\u306f\u53cb\u9054\u306b\u306a\u3063\u305f\u3002`, "relation", { participants: [this, other] }); + } }, strongestRelation(kind = "friend", liveOnly = true) { diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index b5aaac7..ad21ec1 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -115,7 +115,13 @@ function updateNeeds(tarinai, world, dt = 0) { ? (world?.nearestOther?.(tarinai, 780, other => other && other !== tarinai && !other.dead && other.isTarinaiChampion && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease && (other.reproductionTimer || 0) <= 10) || null) : null; if (championMate) { socialParts.mate += 48; socialParts.championMate = 1; raw.social += 48; } - else if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; } + else if (!tarinai.juvenile && (tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired && (world?.canAddTarinai?.(1) ?? true)) { + // Mating is its own relationship sub-need. It must remain actionable even + // after ordinary loneliness has been relieved by a larger colony; otherwise + // only the initial lonely generation ever seeks a partner. + socialParts.mate += 28 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); + raw.social += socialParts.mate; + } const enemy = tarinai.strongestRelation?.("fear"); if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; } const conflict = evaluateConflictUrge(tarinai, world, 112); @@ -334,9 +340,17 @@ function shouldStartActionBySubNeed(tarinai, action, needs) { if (subValue >= threshold) return true; return isContinuingSameAction && subValue >= threshold * 0.55; } - if (subNeed === "mate" || subNeed === "conflict") { - const relationDrive = Math.max(Number(parts.mate || 0) || 0, Number(parts.conflict || 0) || 0, compatibleSocialTotal * 0.36); - const drugBoosted = (tarinai?.loveMochiTimer || 0) > 0.04 || (tarinai?.fightMochiTimer || 0) > 0.04; + if (subNeed === "mate") { + // A ready mating drive is not the same thing as loneliness. Requiring the + // aggregate social need to exceed 52 made reproduction disappear once a + // colony became socially dense, even though new unpaired adults were ready. + if (subValue >= threshold) return true; + if (isContinuingSameAction && subValue >= threshold * 0.55) return true; + return (tarinai?.loveMochiTimer || 0) > 0.04 && subValue > 0; + } + if (subNeed === "conflict") { + const relationDrive = Math.max(Number(parts.conflict || 0) || 0, compatibleSocialTotal * 0.36); + const drugBoosted = (tarinai?.fightMochiTimer || 0) > 0.04; return compatibleSocialTotal >= needThreshold("social", "start") && (drugBoosted || relationDrive >= Math.min(socialSubNeedStartThreshold("mate"), socialSubNeedStartThreshold("conflict")) * 0.48); } if (subValue >= threshold) return true; diff --git a/js/tarinai_nest_sleep_system.js b/js/tarinai_nest_sleep_system.js index 4fb5cdd..6856850 100644 --- a/js/tarinai_nest_sleep_system.js +++ b/js/tarinai_nest_sleep_system.js @@ -35,15 +35,16 @@ function nestBoxCapacity(worldRef, box = null) { if (typeof worldRef?.nestBoxCapacity === "function") return worldRef.nestBoxCapacity(box); - if (box?.type === "pipe") return Math.max(3, pipeItems(worldRef).length * 3); + if (box?.type === "pipe") return 3; return 5; } function nestBoxOccupants(worldRef, box, limit = 5) { if (!worldRef || !box || box.dead || !isNestContainer(box)) return []; - const occupants = box.type === "pipe" - ? (worldRef.tarinai || []).filter(t => tarinaiInsidePipeNetwork(worldRef, t)) - : (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id); + // Each pipe mouth has its own local sleeping capacity. The underground + // network is still shared for exits, but occupancy/reservations must stay + // attached to the entrance pipe or every tarinai piles into the nearest one. + const occupants = (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id); return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; } @@ -65,9 +66,7 @@ if (box.type === "pipe" && t.insideNestBoxId && tarinaiInsidePipeNetwork(t.world || null, t)) return false; if (box.type !== "pipe" && t.insideNestBoxId === box.id) return false; const target = tarinaiTargetContainer(t); - if (box.type === "pipe") { - if (target?.type !== "pipe") return false; - } else if (target !== box) return false; + if (target !== box) return false; if (t._nestBoxAvoidId === box.id && now < (t._nestBoxAvoidUntil || -Infinity)) return false; const seekingNest = t.state === "seek_bed" || t.state === "sleep" || t.sleeping || t.state === "seek_temperature" || t.behavior?.actionId === "sleep_in_bed"; if (!seekingNest) return false; @@ -159,7 +158,12 @@ const d = distFn(tarinai, item); if (d > maxDist) continue; const comfort = bedComfort(worldRef, item); - const score = d - comfort * 18; + const occupancy = bedOccupancy(worldRef, item); + const pipeLoadPenalty = type === "pipe" ? occupancy * 44 : 0; + const spread = type === "pipe" && typeof global.stableUnit === "function" + ? global.stableUnit(tarinai?.id || tarinai?.familyKey || "tarinai", `pipe-choice:${item.id || "pipe"}`) * 14 + : 0; + const score = d - comfort * 18 + pipeLoadPenalty + spread; if (score < bestScore) { best = item; bestScore = score; } } return best; diff --git a/js/tarinai_render.js b/js/tarinai_render.js index c37bf6e..b24e4b3 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -275,8 +275,12 @@ function tarinaiSpriteReversesFacing(sprite) { drawH *= 1.32; } if (sprite === "birth_ritual") { - drawW *= 0.64; - drawH *= 0.64; + // The ritual source image has a larger source canvas than ordinary tarinai + // sprites. Normalize its displayed bounds to the regular smile sprite so + // entering the ritual does not make the actor visibly grow. + const normalMetrics = getImageMetrics("smile") || { w: 512, h: 468 }; + drawW = (normalMetrics.w || 512) * visualScale * 0.30; + drawH = (normalMetrics.h || 468) * visualScale * 0.30; } if (fallPose > 0) { drawW *= 1 + fallPose * 0.08; diff --git a/js/tarinai_social_action_runtime.js b/js/tarinai_social_action_runtime.js index bc1a455..c05b72a 100644 --- a/js/tarinai_social_action_runtime.js +++ b/js/tarinai_social_action_runtime.js @@ -6,7 +6,7 @@ function mateTargetFor(t, world, maxDist = 520) { if (!t || !world) return null; const current = currentBehaviorTarget(t) || t.target; - const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; + const ok = o => o && o !== t && !o.dead && !t.juvenile && !o.juvenile && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; const championOk = o => ok(o) && !!t.isTarinaiChampion && !!o.isTarinaiChampion; if (championOk(current) && dist(t, current) <= maxDist + 260) return current; if (t.isTarinaiChampion) { @@ -61,6 +61,7 @@ function socialFightMateWeights(t, other, world, options = {}) { : (loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44)); const lonelyBond = championPair || (loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24)); const mateEligible = sameStatus + && !t.juvenile && !other.juvenile && timerOk && mateDistanceOk && !world.areParentChild?.(t, other) diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 835cd05..02a4330 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -25,6 +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", ]); function isNestContainerItem(item) { @@ -199,19 +200,24 @@ const scanLimit = (this.fightTimer > 0.04 || this.birthRitualTimer > 0.04) ? 12 : 8; const selfRadius = Math.max(12, Number(this.radius || 22) || 22); const scanRadius = Math.max(92, selfRadius * 2.0 + 52); + const candidateBudget = Math.max(20, scanLimit + 8); const selfProfile = this.personalityProfile(); const detailed = []; - // nearbyTarinai() has already paid the spatial lookup cost and returns only - // candidates inside the requested radius when precise=true. Do not stop at - // an arbitrary iteration count here: the incremental grid intentionally does - // not preserve bucket insertion order, so an early break can miss the actual - // nearest mate/contact candidates after enough cell crossings. Keep only the - // nearest scanLimit entries with a tiny bounded insertion list instead. + let inspected = 0; + const currentBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : null; + const currentMateTarget = (currentBehavior?.actionId === "approach_mate" || this.actionId === "approach_mate") + ? (currentBehavior?.target || this.target || null) + : null; + // Contact physics/social work must stay bounded in dense colonies. Scan a + // small candidate budget, retain the nearest entries, and explicitly add the + // already-selected mate target so reproduction does not depend on spatial + // bucket insertion order. for (const o of this.world.nearbyTarinai(this.x, this.y, scanRadius, true)) { if (o === this || o.dead) continue; const dx = o.x - this.x, dy = o.y - this.y; const d2 = dx * dx + dy * dy; if (d2 > scanRadius * scanRadius || d2 < 0.0001) continue; + inspected += 1; const entry = { o, dx, dy, d2 }; let pos = detailed.length; while (pos > 0 && detailed[pos - 1].d2 > d2) pos--; @@ -219,6 +225,19 @@ detailed.splice(pos, 0, entry); if (detailed.length > scanLimit) detailed.pop(); } + if (inspected >= candidateBudget) break; + } + if (currentMateTarget && currentMateTarget !== this && !currentMateTarget.dead) { + let alreadyIncluded = false; + for (const entry of detailed) { + if (entry.o === currentMateTarget) { alreadyIncluded = true; break; } + } + if (!alreadyIncluded) { + const dx = currentMateTarget.x - this.x; + const dy = currentMateTarget.y - this.y; + const d2 = dx * dx + dy * dy; + if (d2 > 0.0001 && d2 <= scanRadius * scanRadius) detailed.push({ o: currentMateTarget, dx, dy, d2 }); + } } for (const entry of detailed) { const o = entry.o; @@ -251,9 +270,6 @@ avoider.surpriseTimer = Math.max(avoider.surpriseTimer || 0, 0.20); avoider.fearTimer = Math.max(avoider.fearTimer || 0, 0.32 * avoider.personalityProfile().fear); if (typeof applyNeedShock === "function") applyNeedShock(avoider, { safety: dt * 26, social: dt * 5 }, champion); - if (this.world.relationNotice?.(avoider.id, champion.id, "tarinai-champion-avoid", 18)) { - this.world.log(`${avoider.name}\u306F\u305F\u308A\u306A\u3044\u738B\u8005\u306E${champion.name}\u306B\u3076\u3064\u304B\u308A\u305D\u3046\u306B\u306A\u3063\u3066\u907F\u3051\u305F\u3002`, "relation", { participants: [avoider, champion], eventType: "avoid_relationship", sound: false }); - } } forceKingFlee(avoider, champion, dt); avoider.adjustRelation?.(champion, -dt * 0.025, dt * 0.18, "tarinai_champion_avoid"); diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js index d66bbf1..637fe39 100644 --- a/js/tarinai_update_step_frame.js +++ b/js/tarinai_update_step_frame.js @@ -25,7 +25,8 @@ const minute = dt / 60; t.age += dt; - t.reproductionTimer -= dt; + // reproductionTimer is advanced in the outer creature pass so low-frequency + // AI updates do not stretch the cooldown as population grows. t.eatTimer = Math.max(0, t.eatTimer - dt); if (t.state === "eat" && t.eatTimer <= 0.02) { t.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true }); @@ -85,7 +86,6 @@ if (!t.dead) t._achievementLaxativeActiveThisFrame = false; return { done: true }; } - global.TarinaiCursorCareSystem.updateOne(t, dt); const wasFighting = t.fightTimer > 0.04; const endingFightTargetIds = wasFighting ? [...new Set([t.fightTargetId, ...(Array.isArray(t.fightTargetIds) ? t.fightTargetIds : [])].filter(Boolean))] : []; t.fightTimer = Math.max(0, t.fightTimer - dt); diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index 579e649..1cfbeb9 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -236,7 +236,8 @@ t._lastPhysicalVelocityY = 0; return { done: false, held: true }; } - const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016)); + const motionDtCap = context.offscreenCatchup ? 0.75 : 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); } @@ -249,11 +250,12 @@ global.TarinaiSunbathSystem.updateOne(t, motionDt); t.maintainTargetProgress(motionDt); const prevMotionOnlyFlag = t._motionOnlyPhysicsPass; - if (context.motionOnly) t._motionOnlyPhysicsPass = true; + const useLightMotion = Boolean(context.motionOnly || context.offscreenCatchup); + if (useLightMotion) t._motionOnlyPhysicsPass = true; try { t.move(motionDt); } finally { - if (context.motionOnly) t._motionOnlyPhysicsPass = prevMotionOnlyFlag; + if (useLightMotion) t._motionOnlyPhysicsPass = prevMotionOnlyFlag; } if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(motionDt); applyVelocityShockDamage(t, before, motionDt); diff --git a/js/ui.js b/js/ui.js index 48b9fce..695d6eb 100644 --- a/js/ui.js +++ b/js/ui.js @@ -75,8 +75,11 @@ const uiCache = { showSelectedEmpty: false, selectedCollapsedCategories: new Set(), logPushEnabled: false, - logPushKinds: new Set(["death", "accident", "birth"]), + logEventCategories: new Set(["life", "danger", "social", "living", "environment", "observe"]), archiveUpdateEnabled: false, + activePanelTab: "", + archiveViewActivated: false, + panelTabScrollRaf: 0, resetFieldType: "garden", resetPreset: "default", toolButtons: [], diff --git a/js/ui_bind.js b/js/ui_bind.js index 40ab925..3cd3de2 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -37,9 +37,12 @@ function bindUI() { } function setActivePanelTab(tab = "") { + const previous = uiCache.activePanelTab || ""; + uiCache.activePanelTab = tab; for (const btn of ui.panelQuickTabs?.querySelectorAll("[data-panel-tab]") || []) { btn.classList.toggle("active", btn.dataset.panelTab === tab); } + if (previous === "family" && tab !== "family") window.cancelArchiveBackgroundRender?.(); } function isTouchMobileLayout() { @@ -109,6 +112,10 @@ function bindUI() { function scrollPanelToTab(tab = "") { const panel = ui.panel; if (!panel) return false; + // Explicit tab selection wins immediately. In particular, family rendering is + // asynchronous and must not be cancelled by a scroll-sync pass fired by the + // navigation scroll itself before the selected tab has been recorded. + setActivePanelTab(tab); window.TarinaiTooltips.hide(); if (tab === "selected" && (!world.selected || world.selected.dead || !world.tarinai.includes(world.selected))) { uiCache.showSelectedEmpty = true; @@ -119,9 +126,10 @@ function bindUI() { if (!target) return false; target.classList?.remove?.("hidden"); if (tab === "family") { - // \u66F4\u65B0OFF\u3067\u3082\u3001\u30E6\u30FC\u30B6\u30FC\u304C\u5BB6\u7CFB\u56F3\u3078\u79FB\u52D5\u3057\u305F\u6642\u3060\u3051\u73FE\u5728\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u63CF\u753B\u3059\u308B\u3002 - window.resetArchiveRenderState?.(); - renderArchive?.(); + // Family data is expensive to lay out. Build it only after the user + // actually opens the family section, and keep the cache across tab switches. + uiCache.archiveViewActivated = true; + renderArchive?.({ userRequested: true }); } const isDesktop = window.matchMedia?.("(min-width: 981px)")?.matches ?? window.innerWidth >= 981; if (isDesktop && tab !== "game" && tab !== "family") { @@ -132,10 +140,14 @@ function bindUI() { // \u30B9\u30DE\u30DB\u306F\u30DA\u30FC\u30B8\u5168\u4F53\u30B9\u30AF\u30ED\u30FC\u30EB\u3002\u76EE\u7684\u5730UI\u306E\u4E0A\u7AEF\u3092\u753B\u9762\u4E0A\u7AEF\u3078\u5408\u308F\u305B\u308B\u3002 scrollWindowToElement(target, 0, "auto"); } - setActivePanelTab(tab); return true; } + window.scrollToSelectedDataPanel = () => { + renderSelected?.(); + return scrollPanelToTab("selected"); + }; + const activatePanelQuickTab = (e) => { const btn = e.target.closest("[data-panel-tab]"); if (!btn) return; @@ -147,9 +159,9 @@ function bindUI() { scrollPanelToTab(btn.dataset.panelTab || ""); }; + // One physical tap should execute one switch. Click already covers mouse, pen, + // and synthesized touch activation, so parallel pointer/touch handlers are avoided. ui.panelQuickTabs?.addEventListener("click", activatePanelQuickTab); - ui.panelQuickTabs?.addEventListener("pointerup", activatePanelQuickTab, { passive: false }); - ui.panelQuickTabs?.addEventListener("touchend", activatePanelQuickTab, { passive: false }); const applyTemperatureSlider = () => { if (!world || !ui.temperatureSlider) return; @@ -207,7 +219,8 @@ function bindUI() { ui.tarinaiPopulationLimitInput?.addEventListener("change", () => applyColonyLimit("tarinai", "number")); ui.objectLimitInput?.addEventListener("change", () => applyColonyLimit("object", "number")); - ui.panel?.addEventListener("scroll", () => { + const updateActivePanelTabFromScroll = () => { + uiCache.panelTabScrollRaf = 0; const panel = ui.panel; if (!panel || !ui.panelQuickTabs) return; const tabsH = ui.panelQuickTabs.offsetHeight || 0; @@ -220,12 +233,29 @@ function bindUI() { ["family", document.querySelector(".lineage-main-card")], ].filter(([, el]) => el && !el.classList.contains("hidden")); let active = entries[0]?.[0] || ""; - const y = panel.scrollTop + tabsH + 18; - for (const [key, el] of entries) { - if (el.offsetTop <= y) active = key; + const threshold = Math.max(8, tabsH + 18); + const familyEl = entries.find(([key]) => key === "family")?.[1] || null; + const familyRect = familyEl?.getBoundingClientRect?.() || null; + if (familyRect && familyRect.top <= threshold && familyRect.bottom > threshold) { + active = "family"; + } else if (isTouchMobileLayout()) { + for (const [key, el] of entries) { + if ((el.getBoundingClientRect?.().top ?? Infinity) <= threshold) active = key; + } + } else { + const y = panel.scrollTop + tabsH + 18; + for (const [key, el] of entries) { + if (key !== "family" && el.offsetTop <= y) active = key; + } } setActivePanelTab(active); - }, { passive: true }); + }; + const scheduleActivePanelTabSync = () => { + if (uiCache.panelTabScrollRaf) return; + uiCache.panelTabScrollRaf = requestAnimationFrame(updateActivePanelTabFromScroll); + }; + ui.panel?.addEventListener("scroll", scheduleActivePanelTabSync, { passive: true }); + window.addEventListener("scroll", scheduleActivePanelTabSync, { passive: true }); syncToolSizeBadges(); setActivePanelTab(ui.selectedCard?.classList.contains("hidden") ? "tools" : "selected"); @@ -401,13 +431,17 @@ function bindUI() { showToast(uiCache.logPushEnabled ? "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002"); }); ui.logPushControls?.addEventListener("change", (e) => { - const input = e.target.closest("[data-push-kind]"); + const input = e.target.closest("[data-event-category]"); if (!input) return; - const kind = input.dataset.pushKind || ""; + const category = input.dataset.eventCategory || ""; audio.uiClick?.(); - if (input.checked) uiCache.logPushKinds.add(kind); - else uiCache.logPushKinds.delete(kind); + if (input.checked) uiCache.logEventCategories.add(category); + else uiCache.logEventCategories.delete(category); saveLogPushSettings(); + renderLog(world.logs); + if (!input.checked) { + for (const toast of ui.logPushOverlay?.querySelectorAll?.(`[data-event-category="${category}"]`) || []) toast.remove(); + } }); ui.soundCategoryControls?.addEventListener("change", (e) => { const input = e.target.closest("[data-sound-category]"); @@ -449,6 +483,7 @@ function bindUI() { if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") { uiCache.archiveAutoRefreshTimer = window.setInterval(() => { if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return; + if (uiCache.activePanelTab !== "family") return; if (typeof lineageArchiveNeedsRender === "function" && !lineageArchiveNeedsRender()) return; scheduleArchiveWindowRender(); }, 5000); diff --git a/js/ui_family_render.js b/js/ui_family_render.js index 6b54f19..25f18d5 100644 --- a/js/ui_family_render.js +++ b/js/ui_family_render.js @@ -134,7 +134,10 @@ function lineageFamilyHtml(component, index, family, signature = "") { function lineageArchiveNeedsRender() { const familyVersion = world.familyVersion || 0; const familyDirty = Boolean(world.familyTreeDirty); - return !uiCache.archiveVersion || uiCache.archiveFamilyVersion !== familyVersion || familyDirty; + // 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; } function lineageDeferArchiveIfOffscreen(hiddenInterval = 30000) { @@ -156,12 +159,18 @@ function lineageScheduleArchiveIdle(timeout = 1200) { return true; } -function renderArchive() { +function renderArchive(options = {}) { if (!ui.archiveContent) return; + const userRequested = Boolean(options?.userRequested); + if (!userRequested && uiCache.activePanelTab !== "family") { + uiCache.archiveScheduled = false; + return; + } + if (userRequested) uiCache.archiveViewActivated = true; // Auto update OFF now means "do not keep refreshing", not "hide the tree". // Draw the current snapshot once so the family tree is always available under // the event UI, then mark it stale when the family data changes. - if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + if (!userRequested && typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { uiCache.archiveScheduled = false; if (uiCache.archiveVersion && world.familyTreeDirty) { const hasRenderedGraph = Boolean(ui.archiveContent.querySelector(".lineage-family-tree, [data-family-tarinai-id]")); @@ -187,10 +196,10 @@ function renderArchive() { return; } if (!lineageArchiveNeedsRender()) return; - if (lineageDeferArchiveIfOffscreen()) return; + if (!userRequested && lineageDeferArchiveIfOffscreen()) return; const now = performance.now(); const minUpdateGap = 4800; - if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { + if (!userRequested && uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { if (!uiCache.archiveRenderTimer) { uiCache.archiveRenderTimer = setTimeout(() => { uiCache.archiveRenderTimer = 0; @@ -315,6 +324,10 @@ function resetArchiveRenderState() { } function scheduleArchiveWindowRender() { + if (!uiCache.archiveViewActivated || uiCache.activePanelTab !== "family") { + uiCache.archiveScheduled = false; + return; + } if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { uiCache.archiveScheduled = false; if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true); @@ -331,6 +344,7 @@ function scheduleArchiveWindowRender() { } function schedulePendingArchiveRender() { + if (!uiCache.archiveViewActivated || uiCache.activePanelTab !== "family") return; if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return; if (!uiCache.archivePendingOffscreen) return; if (!lineageArchiveNearViewport()) return; @@ -338,4 +352,27 @@ function schedulePendingArchiveRender() { if (lineageArchiveNeedsRender()) scheduleArchiveWindowRender(); } +function cancelArchiveBackgroundRender() { + const wasRunning = Boolean(uiCache.archiveRenderRunning); + if (uiCache.archiveRenderTimer) { + clearTimeout(uiCache.archiveRenderTimer); + uiCache.archiveRenderTimer = 0; + } + // All asynchronous family-layout pumps validate this token before doing work. + // Invalidating it stops an in-progress offscreen build without discarding the + // reusable component HTML cache. An interrupted render is marked incomplete so + // the next explicit visit resumes with a clean render pass. + uiCache.archiveRenderToken = `paused:${performance.now()}`; + uiCache.archiveRenderRunning = false; + uiCache.archiveScheduled = false; + uiCache.archivePendingOffscreen = false; + uiCache.archivePendingDirtyAfterRun = false; + if (wasRunning) { + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + if (world) world.familyTreeDirty = true; + } +} + window.resetArchiveRenderState = resetArchiveRenderState; +window.cancelArchiveBackgroundRender = cancelArchiveBackgroundRender; diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 4250767..171a08f 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -525,7 +525,6 @@ world.rebuildSpatial?.(); renderStats(); if (removed > 0) { - world.log?.(`\u7bc4\u56f2\u524a\u9664\u3067${removed}\u500b\u6d88\u3057\u305f\u3002`, "observe"); showToast(`${removed}\u500b\u6d88\u3057\u307e\u3057\u305f\u3002`); } return true; diff --git a/js/ui_log.js b/js/ui_log.js index 38f6184..cf03f64 100644 --- a/js/ui_log.js +++ b/js/ui_log.js @@ -2,29 +2,79 @@ 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", + danger: "\u5371\u967a", + social: "\u4ea4\u6d41", + living: "\u751f\u6d3b", + environment: "\u74b0\u5883", + observe: "\u89b3\u5bdf", +}); +const LOG_KIND_TO_EVENT_CATEGORY = Object.freeze({ + birth: "life", + death: "life", + accident: "danger", + danger: "danger", + fight: "danger", + relation: "social", + food: "living", + event: "living", + weather: "environment", + grass: "environment", + observe: "observe", + note: "observe", +}); + +function logEventCategoryForKind(kind = "note") { + return LOG_KIND_TO_EVENT_CATEGORY[String(kind || "note")] || "observe"; +} + +function logEventCategoryLabel(category = "observe") { + return LOG_EVENT_CATEGORY_LABELS[String(category || "observe")] || LOG_EVENT_CATEGORY_LABELS.observe; +} + +function allLogEventCategories() { + return new Set(LOG_EVENT_CATEGORIES); +} + function loadLogPushSettings() { try { - const raw = localStorage.getItem("tarinai_log_push_settings_v2"); - if (!raw) return; - const parsed = JSON.parse(raw); - uiCache.logPushEnabled = Boolean(parsed.enabled); - if (Array.isArray(parsed.kinds)) uiCache.logPushKinds = new Set(parsed.kinds); + const raw = localStorage.getItem("tarinai_event_category_settings_v1"); + if (raw) { + const parsed = JSON.parse(raw); + uiCache.logPushEnabled = Boolean(parsed.pushEnabled ?? parsed.enabled); + if (Array.isArray(parsed.categories)) { + const categories = parsed.categories.filter(category => LOG_EVENT_CATEGORIES.includes(category)); + uiCache.logEventCategories = new Set(categories); + } + 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 (_) {} } function saveLogPushSettings() { try { - localStorage.setItem("tarinai_log_push_settings_v2", JSON.stringify({ - enabled: Boolean(uiCache.logPushEnabled), - kinds: Array.from(uiCache.logPushKinds || []), + localStorage.setItem("tarinai_event_category_settings_v1", JSON.stringify({ + pushEnabled: Boolean(uiCache.logPushEnabled), + categories: Array.from(uiCache.logEventCategories || []), })); } catch (_) {} } function syncLogPushControls() { if (ui.logPushEnabled) ui.logPushEnabled.checked = Boolean(uiCache.logPushEnabled); - for (const input of ui.logPushControls?.querySelectorAll("[data-push-kind]") || []) { - input.checked = uiCache.logPushKinds?.has(input.dataset.pushKind || "") ?? false; + for (const input of ui.logPushControls?.querySelectorAll("[data-event-category]") || []) { + input.checked = uiCache.logEventCategories?.has(input.dataset.eventCategory || "") ?? false; } } @@ -57,15 +107,17 @@ function toggleSoundPanel() { function pushLogNotification(entry = {}) { if (!uiCache.logPushEnabled || !entry || entry.hiddenFromObservation) return; const kind = entry.kind || "note"; - if (!uiCache.logPushKinds?.has(kind)) return; + const category = logEventCategoryForKind(kind); + if (!uiCache.logEventCategories?.has(category)) return; const overlay = ui.logPushOverlay; if (!overlay) return; audio.notify?.(); const card = document.createElement("div"); - card.className = `log-push-toast log-push-${kind}`; + card.className = `log-push-toast log-push-${category}`; + card.dataset.eventCategory = category; const tag = document.createElement("span"); tag.className = "log-push-tag"; - tag.textContent = logKindLabel(kind); + tag.textContent = logEventCategoryLabel(category); const time = document.createElement("span"); time.className = "log-push-time"; time.textContent = formatLogTime(entry.time || 0); @@ -91,7 +143,8 @@ function shouldShowInEventLog(entry = {}) { const text = String(entry.text || ""); if (type === "player_item_placement" || type.includes("player") || type.includes("tool") || type.includes("robot")) return false; if (/\u30ed\u30dc\u6383\u9664\u6a5f|\u6383\u9664\u6a5f/.test(text)) return false; - return true; + const category = logEventCategoryForKind(entry.kind || "note"); + return uiCache.logEventCategories?.has(category) ?? true; } function renderLog(logs) { @@ -105,11 +158,13 @@ function renderLog(logs) { const rows = visibleLogs.slice(0, limit); for (const entry of rows) { const kind = entry.kind || "note"; + const category = logEventCategoryForKind(kind); const div = document.createElement("div"); - div.className = `log-entry log-${kind}`; + div.className = `log-entry log-${category}`; + div.dataset.eventCategory = category; const tag = document.createElement("span"); tag.className = "log-tag"; - tag.textContent = logKindLabel(kind); + tag.textContent = logEventCategoryLabel(category); const time = document.createElement("span"); time.className = "time"; time.textContent = formatLogTime(entry.time || 0); @@ -132,10 +187,7 @@ function formatLogTime(timeValue) { } function logKindLabel(kind) { - return { - birth: "\u8a95\u751f", death: "\u6b7b\u4ea1", fight: "\u55a7\u5629", food: "\u98df\u4e8b", grass: "\u8349", - weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", event: "\u51fa\u6765\u4e8b", note: "\u8a18\u9332" - }[kind] || "\u8a18\u9332"; + return logEventCategoryLabel(logEventCategoryForKind(kind)); } function tarinaiSpritePathForId(id) { diff --git a/js/version.js b/js/version.js index b56b766..cfb0386 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.16.98"; - const APP_BUILD = "tarinai-ui-v39-16-95"; + const APP_VERSION = "39.16.109"; + const APP_BUILD = "tarinai-ui-v39-16-109"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/weather_system.js b/js/weather_system.js index 3b008f5..db8233a 100644 --- a/js/weather_system.js +++ b/js/weather_system.js @@ -25,13 +25,13 @@ return fixed || null; } - function enforceFixedWeather(worldRef) { + function enforceFixedWeather(worldRef, options = {}) { const fixed = fixedWeatherFor(worldRef); if (!fixed || !worldRef) return false; const previous = canonicalWeather(worldRef.weather || "sunny"); worldRef.weather = canonicalWeather(fixed); worldRef.nextWeatherChange = resetTimer(); - if (previous !== worldRef.weather) { + if (previous !== worldRef.weather && options.silentLog !== true) { worldRef.log?.(`\u5929\u6C17: ${weatherLabel(worldRef.weather)}`); } return true; diff --git a/js/world.js b/js/world.js index 786a7b9..df8939d 100644 --- a/js/world.js +++ b/js/world.js @@ -84,6 +84,8 @@ class World { constructor() { this.countsTimer = 0; this.compactTimer = 0; this.drawSortTimer = 0; + this._maintenanceScheduleReady = false; + this._nextTransientRuntimePruneAt = 0; this.drawListDirty = true; this.antUpdatePhase = 0; this.selected = null; @@ -117,6 +119,8 @@ class World { constructor() { this.relationNotices = {}; this.resolvedFightIds = {}; this.fightPairCooldowns = {}; + this.ballCollisionMemo = new Map(); + this.tarinaiCollisionMemo = new Map(); this.deadTarinaiSinceRuntimePrune = 0; this.tarinaiRuntimePrunePending = false; this._deadTarinaiIdsPendingCleanup = new Set(); diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index fd564dc..9918e4b 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -4,16 +4,15 @@ (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"]); + 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_LIMIT_PER_TYPE = 96; const effectPool = new Map(); function acquireEffect(type, x, y, options) { const key = String(type || ""); const bucket = EFFECT_POOL_TYPES.has(key) ? effectPool.get(key) : null; - const effect = bucket?.length ? bucket.pop() : new Effect(key, x, y, options); - if (bucket?.length >= 0) effect.reset(key, x, y, options); - return effect; + if (bucket?.length) return bucket.pop().reset(key, x, y, options); + return new Effect(key, x, y, options); } function releaseEffect(effect) { @@ -28,7 +27,25 @@ } 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 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; + this.effects.push(effect); + if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) { + this._effectSpawnStats = this._effectSpawnStats || {}; + this._effectSpawnStats.requested = (this._effectSpawnStats.requested || 0) + 1; + if (EFFECT_POOL_TYPES.has(String(type || ""))) this._effectSpawnStats.poolEligible = (this._effectSpawnStats.poolEligible || 0) + 1; + } + return effect; + }, + spawnEffect(type, x, y, options = {}) { + if (global.TarinaiPerf?.visualLevel?.("effects", "high") === "off") return null; const opts = options || {}; 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) { @@ -45,15 +62,7 @@ return null; } } - const effect = acquireEffect(type, x, y, opts); - effect._effectImportance = importance; - this.effects.push(effect); - if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) { - this._effectSpawnStats = this._effectSpawnStats || {}; - this._effectSpawnStats.requested = (this._effectSpawnStats.requested || 0) + 1; - if (EFFECT_POOL_TYPES.has(String(type || ""))) this._effectSpawnStats.poolEligible = (this._effectSpawnStats.poolEligible || 0) + 1; - } - return effect; + return this.queueEffect(type, x, y, opts); }, releaseEffect(effect) { @@ -463,7 +472,7 @@ if (this.addItem?.(it, "zunchi-burst")) spawned += 1; } if (!spawned) return 0; - this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" })); + this.queueEffect?.("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" }); this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] }); audio.place?.("zunchi"); this.ensureSpatial?.("zunchi-burst"); @@ -677,10 +686,13 @@ resolveFightOutcome(winner, loser) { if (!winner || !loser || winner.dead || loser.dead) return; - const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; + // Keep only one timestamp per directional pair. The old time-bucket key + // created a new permanent property every two seconds for recurring fights. + const key = `${winner.id}:${loser.id}`; if (!this.resolvedFightIds) this.resolvedFightIds = {}; - if (this.resolvedFightIds[key]) return; - this.resolvedFightIds[key] = true; + const resolvedAt = Number(this.resolvedFightIds[key]); + if (Number.isFinite(resolvedAt) && this.time - resolvedAt < 2) return; + this.resolvedFightIds[key] = this.time; winner.defeatedById = null; winner.defeatedTimer = 0; winner.fearTimer = Math.max(0, winner.fearTimer - 0.75); @@ -797,7 +809,6 @@ ball.amount = Math.max(ball.amount || 999, 999); audio.ballHit?.(); this.spawnEffect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: isBalloon ? "rgba(94,188,236,0.56)" : "rgba(255,255,255,0.58)" }); - if (!isBalloon) this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke" }); return true; }, diff --git a/js/world_environment.js b/js/world_environment.js index 0655c1d..d925aa3 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -398,10 +398,7 @@ nestBoxCapacity(box = null) { - if (box?.type === "pipe") { - const count = (this.items || []).reduce((n, it) => n + (it && !it.dead && it.type === "pipe" ? 1 : 0), 0); - return Math.max(3, count * 3); - } + if (box?.type === "pipe") return 3; return 5; }, @@ -839,7 +836,7 @@ } if (now >= (t.lastBounceFenceEffectAt || -999) + 0.08) { t.lastBounceFenceEffectAt = now; - this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" })); + this.queueEffect?.("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" }); } } else if (r.mechanical && opts.pressureCheap) { const omega = r.rotator ? clamp(Number(r.angularVelocity || 0) || 0, -4.5, 4.5) : 0; @@ -1034,15 +1031,24 @@ const cy = (y1 + y2) / 2; const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; const exclude = opts?.exclude || null; + const seenNestBoxes = new Set(); for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) { if (exclude && rect?.item === exclude) continue; if (!this.isFenceRoutingRect?.(rect)) continue; - if (rect?.oneWay && this.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) continue; - if (rect?.oriented) { - const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }; + let routingRect = rect; + const routingItem = rect?.item || null; + if (routingItem?.type === "nest_box") { + const key = routingItem.id || routingItem; + if (seenNestBoxes.has(key)) continue; + seenNestBoxes.add(key); + routingRect = this.nestBoxBaseRect?.(routingItem) || rect; + } + if (routingRect?.oneWay && this.oneWayFenceAllowsPath?.(x1, y1, x2, y2, routingRect)) continue; + if (routingRect?.oriented) { + const r = { ...routingRect, halfW: (routingRect.halfW || 0) + padding, halfH: (routingRect.halfH || 0) + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; } else { - const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding }; + const r = { left: routingRect.left - padding, right: routingRect.right + padding, top: routingRect.top - padding, bottom: routingRect.bottom + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; } } @@ -1058,12 +1064,21 @@ pointBlockedByObstacle(x, y, padding = 14, opts = {}) { const exclude = opts?.exclude || null; const searchRadius = Math.max(48, Number(padding || 0) + 72); + const seenNestBoxes = new Set(); for (const rect of this.nearbySolidObstacleRects?.(x, y, searchRadius, { exclude, maxChecks: Math.max(10, Number(opts.maxChecks || 0) || 24) }) || []) { if (exclude && rect?.item === exclude) continue; // Directional pathfinding evaluates one-way fences on edges, not as // permanently blocked grid points. Other callers retain solid occupancy. if (rect?.oneWay && opts?.directionalOneWay === true) continue; - if (this.pointInRect?.(x, y, rect, padding)) return true; + let routingRect = rect; + const routingItem = rect?.item || null; + if (routingItem?.type === "nest_box") { + const key = routingItem.id || routingItem; + if (seenNestBoxes.has(key)) continue; + seenNestBoxes.add(key); + routingRect = this.nestBoxBaseRect?.(routingItem) || rect; + } + if (this.pointInRect?.(x, y, routingRect, padding)) return true; } return false; }, diff --git a/js/world_family_social.js b/js/world_family_social.js index 893af98..dc4e921 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -143,6 +143,53 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { return ["\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", `\u6301\u3061\u4e3b: ${owner?.name || "\u4e0d\u660e"}`, worldRef.temperatureTooltipLineFor?.(bed, "\u5468\u8fba\u6c17\u6e29")].filter(Boolean); } + + function pointerObjectTooltipInfoLocal(worldRef) { + const p = worldRef?.pointer; + if (!p?.inside) return null; + const nearby = worldRef.nearbyItems?.(p.x, p.y, 150) || worldRef.nearbyNonGrassItems?.(p.x, p.y, 150) || []; + let best = null; + let bestDistance = Infinity; + const sleepFurniture = global.TarinaiItemToolMetadata?.ITEM_TRAITS?.sleepFurniture || null; + 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 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)); + let hit = false; + let hitDistance = fallbackDistance; + const hitTest = global.TarinaiCollisionFootprints?.hitTestItem; + if (typeof hitTest === "function") { + const result = hitTest(worldRef, item, p.x, p.y, { + padding: worldRef.screenSizeToWorld ? worldRef.screenSizeToWorld(5) : 5, + radiusMultiplier: 1.15, + }) || null; + hit = Boolean(result?.hit); + if (Number.isFinite(Number(result?.distance))) hitDistance = Number(result.distance); + } else { + hit = fallbackDistance <= Math.max(30, (Number(item.r) || 18) * 1.35); + } + if (!hit || hitDistance >= bestDistance) continue; + best = item; + bestDistance = hitDistance; + } + if (!best) return null; + if (global.TarinaiToolRuntime?.isNestContainerItem?.(best)) { + return { target: best, lines: worldRef.nestBoxTooltipLines?.(best) || [], kind: best.type === "pipe" ? "pipe" : "nest_box" }; + } + if (best.type === "grass_bed") { + return { target: best, lines: ownedBedTooltipLinesLocal(worldRef, best), kind: "grass_bed" }; + } + 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); + const temperatureLine = worldRef.temperatureTooltipLineFor?.(best, "\u5468\u8fba\u6c17\u6e29"); + if (temperatureLine && (best.isStructure || best.type === "toilet" || best.type === "rain_shelter")) lines.push(temperatureLine); + return { target: best, lines: lines.filter(Boolean), kind: "object" }; + } + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ reset(seedPopulation = null, fieldType = this.fieldType || "garden", presetId = "default") { const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; @@ -690,17 +737,18 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) { }, pointerItemTooltipInfo() { + // Precise object hit-testing comes first; only if no object is actually + // under the pointer do we fall back to the Tarinai tooltip. + const objectInfo = pointerObjectTooltipInfoLocal(this); + if (objectInfo?.target) return objectInfo; const tarinaiInfo = pointerTarinaiTooltipInfoLocal(this); if (tarinaiInfo?.target) return tarinaiInfo; - const nest = this.pointerNestBoxInfo?.(); - if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: nest.box?.type === "pipe" ? "pipe" : "nest_box" }; - const bedInfo = pointerOwnedBedInfoLocal(this); - if (bedInfo?.bed) return { target: bedInfo.bed, lines: ownedBedTooltipLinesLocal(this, bedInfo.bed), kind: "grass_bed" }; return null; }, startBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return false; + if (a.juvenile || b.juvenile) return false; if (!(this.canAddTarinai?.(1) ?? true)) return false; if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) return false; if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false; diff --git a/js/world_pathfinding_system.js b/js/world_pathfinding_system.js index c39b4c8..6043280 100644 --- a/js/world_pathfinding_system.js +++ b/js/world_pathfinding_system.js @@ -145,8 +145,17 @@ }; let blockers = 0; - for (const rect of rects) { - if (!this.isFenceRoutingRect?.(rect)) continue; + const seenNestBoxes = new Set(); + for (const rawRect of rects) { + if (!this.isFenceRoutingRect?.(rawRect)) continue; + let rect = rawRect; + const routingItem = rawRect?.item || null; + if (routingItem?.type === "nest_box") { + const key = routingItem.id || routingItem; + if (seenNestBoxes.has(key)) continue; + seenNestBoxes.add(key); + rect = this.nestBoxBaseRect?.(routingItem) || rawRect; + } const inflated = rect?.oriented ? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding } : { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding }; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 1eddc18..2a59cd5 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -100,7 +100,6 @@ const commit = (value) => { const next = sanitizeSignboardText(value); sign.text = next; - worldRef?.log?.(sign.text ? `\u770b\u677f\u3092\u66f8\u304d\u63db\u3048\u305f\u3002` : `\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f\u3002`, "observe"); worldRef.drawListDirty = true; global.render?.(); close(); @@ -210,7 +209,6 @@ robot.robotCleanerScanTimer = 0; worldRef?.markItemBucketsDirty?.("robot-cleaner-settings"); if (worldRef) worldRef.drawListDirty = true; - worldRef?.log?.("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u5bfe\u8c61\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002", "observe"); global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot, source: "robot-cleaner-settings" }); if (typeof showToast === "function") showToast("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u3092\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002"); global.render?.(); @@ -308,7 +306,6 @@ item.angle = item.fanSwingCenter; worldRef?.markSpatialDirty?.("fan-swing-settings"); if (worldRef) worldRef.drawListDirty = true; - worldRef?.log?.(item.fanSwingOn ? "\u6247\u98a8\u6a5f\u306e\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002" : "\u6247\u98a8\u6a5f\u3092\u56fa\u5b9a\u5411\u304d\u306b\u3057\u305f\u3002", "observe"); if (typeof showToast === "function") showToast(item.fanSwingOn ? "\u6247\u98a8\u6a5f: \u9996\u632f\u308aON" : "\u6247\u98a8\u6a5f: \u56fa\u5b9a\u5411\u304d"); global.render?.(); close(); @@ -444,7 +441,6 @@ global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, isPoisonBlock ? "poison-block-link-remap" : "rotator-link-remap"); worldRef?.markSpatialDirty?.(isPoisonBlock ? "poison-block-edited" : "rotator-edited"); worldRef.drawListDirty = true; - worldRef?.log?.(isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002" : "\u56de\u8ee2\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe"); global.render?.(); close(); }; @@ -588,7 +584,6 @@ global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, "reciprocator-link-remap"); worldRef?.markSpatialDirty?.("reciprocator-edited"); worldRef.drawListDirty = true; - worldRef?.log?.("\u5f80\u5fa9\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe"); global.render?.(); close(); }; @@ -1062,7 +1057,6 @@ item.gateOpen = !item.gateOpen; worldRef?.markSpatialDirty?.("gate-toggle"); worldRef.drawListDirty = true; - worldRef?.log?.(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5\u3092\u958b\u3051\u305f\u3002" : "\u30b2\u30fc\u30c8\u67f5\u3092\u9589\u3081\u305f\u3002", "observe"); if (typeof showToast === "function") showToast(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5: \u958b" : "\u30b2\u30fc\u30c8\u67f5: \u9589"); global.render?.(); return true; @@ -1170,6 +1164,9 @@ const search = placementSoftSearchRadius(item); for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) { if (!soft || soft === item || soft.dead || !isSoftPlacementIgnoredItemType(soft.type)) continue; + // Easy beds are intentionally valid under a parasol. Placing the parasol + // after the bed must preserve the same layout that AI can build later. + if (item.type === "rain_shelter" && soft.type === "grass_bed") continue; if (placementItemOverlapsSoftItem(worldRef, item, soft)) out.push(soft); } return out; @@ -1240,7 +1237,6 @@ } this.copyBuffer = makeCopyBufferForItem(target); showToast(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u307e\u3057\u305f\u3002`); - this.log?.(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u305f\u3002`, "observe"); return true; } const item = this.copyPreviewItemAt?.(x, y); @@ -1251,7 +1247,6 @@ if (placed.type === "grass" || placed.type === "trace" || placed.type === "splat") this.markTerrainDirtyAt?.(placed.x, placed.y, Math.max(placed.r || 24, 36), "copy-paste"); this._forceImmediateToolVisualRefresh?.("tool-change"); const label = toolLabel(item.type); - this.log?.(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u305f\u3002`, "observe"); showToast(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u307e\u3057\u305f\u3002`); return true; } @@ -1540,10 +1535,12 @@ if (!found && !this.findDeleteToolTargetAt?.(x, y)) { global.TarinaiAchievements?.recordEmptyClick?.({ world: this, x, y }); } - const wasSelectionEmpty = !this.selected; - if (found && wasSelectionEmpty) this._scrollSelectedDataTopOnNextSelection = true; - else this._scrollSelectedDataTopOnNextSelection = false; - global.TarinaiCommands.setSelection(this, found) || (this.selected = found); + this._scrollSelectedDataTopOnNextSelection = Boolean(found); + const selectionResult = global.TarinaiCommands.setSelection(this, found); + if (!selectionResult) this.selected = found; + if (found && selectionResult?.changed === false) { + global.setTimeout?.(() => global.scrollToSelectedDataPanel?.(), 0); + } return; } @@ -1564,7 +1561,7 @@ const vx = dx / length * speed; const vy = Math.min(dy / length * speed - 95, -145); if (target.fling(this, x, y, { source: "poke", vx, vy, speed, life: 5.2, destroyOffscreen: true })) { - this.effects?.push(new Effect("ring", target.x, target.y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" })); + this.queueEffect?.("ring", target.x, target.y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" }); this.drawListDirty = true; audio.poke?.(); global.TarinaiAchievements?.recordPlushiePokeFling?.({ world: this, item: target, owner }); @@ -1579,16 +1576,14 @@ if (global.TarinaiMechanicalSystem.applyPokeImpulse(target, x, y, this)) { const label = target.type === "rotator" ? "\u56de\u8ee2\u4f53" : (target.type === "poison_block" ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u5f80\u5fa9\u4f53"); const color = target.type === "rotator" ? "rgba(184,132,230,0.40)" : (target.type === "poison_block" ? "rgba(92,188,68,0.40)" : "rgba(90,150,214,0.40)"); - this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color })); + this.queueEffect?.("ring", x, y, { size: 22, life: 0.18, color }); this.drawListDirty = true; audio.poke?.(); - this.log(`${label}\u3092\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f\u3002`, "observe", { eventType: "mechanism_poke" }); } return; } if (typeof target.poke === "function") { target.poke(); - if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] }); } return; } @@ -1599,7 +1594,6 @@ if (!t) { showToast(`\u305f\u308a\u306a\u3044\u500b\u4f53\u6570\u306f${this.tarinaiPopulationLimit}\u5339\u307e\u3067\u3067\u3059\u3002`); return; } t.wanderAngle = spot.angle; global.TarinaiAchievements?.recordManualTarinaiAdded?.({ world: this, tarinai: t, tool: "new" }); - this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); return; } diff --git a/js/world_spatial_budget.js b/js/world_spatial_budget.js index 316d888..bef484d 100644 --- a/js/world_spatial_budget.js +++ b/js/world_spatial_budget.js @@ -108,6 +108,14 @@ fps: window.__tarinaiFps || 0, dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0, }, + runtimeCaches: { + ballCollisions: this.ballCollisionMemo?.size || 0, + tarinaiCollisions: this.tarinaiCollisionMemo?.size || 0, + relationNotices: Object.keys(this.relationNotices || {}).length, + fightCooldowns: Object.keys(this.fightPairCooldowns || {}).length, + resolvedFights: Object.keys(this.resolvedFightIds || {}).length, + familyRecords: Object.keys(this.family || {}).length, + }, work: { budget: this.workBudget || null, stats: this.workStats || null, diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js index c029fde..02910a6 100644 --- a/js/world_tool_actions.js +++ b/js/world_tool_actions.js @@ -45,8 +45,10 @@ global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "delete", x, y }); const target = this.findDeleteToolTargetAt?.(x, y); if (!target) { showToast("\u6d88\u305b\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return true; } - if (isPinType(target.type) && target.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3001\u3064\u307e\u3093\u3067\u629c\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return true; } const label = toolLabel(target.type); + if (isPinType(target.type) && target.pinState === "lodged") { + global.TarinaiItemDynamicPinSystem?.detach?.(target, this, "pinch"); + } const removed = global.TarinaiStructureLifecycle.deleteItem(this, target, { reason: "delete-tool", userReason: target.type === "grass_bed" ? "\u30d9\u30c3\u30c9\u304c\u306a\u304f\u306a\u3063\u305f" : "\u7f6e\u304d\u3082\u306e\u304c\u306a\u304f\u306a\u3063\u305f", @@ -58,7 +60,6 @@ this.compactItems?.(); this.updateItemCounts?.(); this.rebuildSpatial?.(); - this.log?.(`${label}\u3092\u6d88\u3057\u305f\u3002`, "observe"); return true; }, @@ -116,7 +117,6 @@ if (options.drag) this._areaDeleteDragRemoved = (this._areaDeleteDragRemoved || 0) + removedCount; if (!options.silentLog) { const detail = [...kindCounts.entries()].slice(0, 4).map(([label, count]) => `${label}${count > 1 ? `\u00d7${count}` : ""}`).join("\u3001"); - this.log?.(`\u7bc4\u56f2\u524a\u9664\u3067${removedCount}\u500b\u6d88\u3057\u305f${detail ? `\uff08${detail}\uff09` : ""}\u3002`, "observe"); showToast(`${removedCount}\u500b\u6d88\u3057\u307e\u3057\u305f\u3002`); } return true; @@ -256,7 +256,6 @@ global.TarinaiAchievements?.recordLinkPlaced?.(constraintType, { world: this, item, type: constraintType }); global.TarinaiPhysicsSoftClear.clearForItem(this, item, `${constraintType}-soft-overlap-clear`); this._forceImmediateToolVisualRefresh?.("tool-action"); - this.log?.(`${toolName}\u3092\u3064\u306a\u3044\u3060\u3002`, "observe"); showToast(`${toolName}\u3092\u3064\u306a\u304e\u307e\u3057\u305f\u3002`); return true; }, @@ -405,8 +404,8 @@ global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "shoot", x: aim.x, y: aim.y }); const hit = this.findShootToolTargetAt?.(aim.x, aim.y); audio.shoot?.(); - this.effects?.push(new Effect("shoot_impact", aim.x, aim.y, { size: hit ? 22 : 16, life: hit ? 0.28 : 0.22, color: hit ? "rgba(255,208,116,0.96)" : "rgba(190,212,240,0.64)" })); - this.effects?.push(new Effect("ring", aim.x, aim.y, { size: hit ? 20 : 14, life: hit ? 0.18 : 0.14, color: hit ? "rgba(255,246,206,0.74)" : "rgba(196,220,255,0.42)" })); + this.queueEffect?.("shoot_impact", aim.x, aim.y, { size: hit ? 22 : 16, life: hit ? 0.28 : 0.22, color: hit ? "rgba(255,208,116,0.96)" : "rgba(190,212,240,0.64)" }); + this.queueEffect?.("ring", aim.x, aim.y, { size: hit ? 20 : 14, life: hit ? 0.18 : 0.14, color: hit ? "rgba(255,246,206,0.74)" : "rgba(196,220,255,0.42)" }); let handled = false; if (hit?.kind === "tarinai") { const t = hit.target; @@ -453,7 +452,7 @@ target.noStickUntil = (this.time || 0) + 0.22; target.lastPokedAt = this.time || 0; } - this.effects?.push(new Effect("ring", target.x || aim.x, target.y || aim.y, { size: Math.max(12, (target.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.48)" })); + this.queueEffect?.("ring", target.x || aim.x, target.y || aim.y, { size: Math.max(12, (target.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.48)" }); this.markSpatialDirty?.("shoot-pin-blast"); this.drawListDirty = true; } else { diff --git a/js/world_view.js b/js/world_view.js index 8ed9b85..2f41c35 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -196,7 +196,7 @@ if (next !== previous && !opts.silent) { window.TarinaiAchievements?.recordGroundChange?.({ world: this, previous, next, silent: false }); } - window.TarinaiWeatherSystem.enforceFixedWeather(this); + window.TarinaiWeatherSystem.enforceFixedWeather(this, { silentLog: true }); this.markTerrainDirty?.("ground-type"); this.enforceGrassLimit?.("ground-type"); this.updateItemCounts?.("ground-type"); @@ -211,7 +211,6 @@ } if (!opts.silent) { const label = this.groundLabel?.() || "\u5730\u9762"; - this.log?.(`\u5730\u9762: ${label}`); showToast(`\u5730\u9762\u3092 ${label} \u306b\u3057\u307e\u3057\u305f\u3002`); } } diff --git a/scripts/achievement_audio_tooltip_regression_audit.js b/scripts/achievement_audio_tooltip_regression_audit.js new file mode 100644 index 0000000..8ae8dd2 --- /dev/null +++ b/scripts/achievement_audio_tooltip_regression_audit.js @@ -0,0 +1,22 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const root = path.resolve(__dirname, ".."); +const read = file => fs.readFileSync(path.join(root, file), "utf8"); +let failed = 0; +function ok(cond, msg) { if (!cond) { failed++; console.error(`FAIL: ${msg}`); } else console.log(`OK: ${msg}`); } +const achievements = read("js/achievements.js"); +const audio = read("js/audio.js"); +const pack = read("js/sound_pack.js"); +const family = read("js/world_family_social.js"); +const data = read("js/data.js"); +ok(fs.existsSync(path.join(root, "assets/sounds/achievement_unlock.mp3")), "achievement unlock audio asset exists"); +ok(pack.includes('sfx_achievement_unlock: "assets/sounds/achievement_unlock.mp3"'), "achievement audio is registered as a sample"); +ok(pack.includes('sfx_achievement_unlock: "notify"'), "achievement audio respects the notify category"); +ok(audio.includes('achievementUnlock() { this.ensure(); this.play("sfx_achievement_unlock"'), "achievement audio initializes samples before playback"); +ok(achievements.includes('global.TarinaiAudio?.achievementUnlock?.();'), "achievement toast triggers the attached audio"); +const tooltipBody = family.slice(family.indexOf('pointerItemTooltipInfo() {'), family.indexOf('startBirthRitual', family.indexOf('pointerItemTooltipInfo() {'))); +ok(tooltipBody.indexOf('pointerObjectTooltipInfoLocal(this)') >= 0 && tooltipBody.indexOf('pointerObjectTooltipInfoLocal(this)') < tooltipBody.indexOf('pointerTarinaiTooltipInfoLocal(this)'), "object tooltip resolution runs before Tarinai tooltip resolution"); +ok(family.includes('TarinaiCollisionFootprints?.hitTestItem'), "object tooltip priority uses precise item hit testing"); +ok(/reproductionCooldown:\s*30\b/.test(data), "reproduction base cooldown is 30 seconds"); +if (failed) process.exit(1); diff --git a/scripts/achievement_audit.js b/scripts/achievement_audit.js index 8ab5aa3..50b25a5 100644 --- a/scripts/achievement_audit.js +++ b/scripts/achievement_audit.js @@ -115,13 +115,13 @@ const expectedIds = [ "daily_play_7_days", "wire_shock_7_tarinai", "information_industry", "strength_in_numbers", "elite_few", "zunchi_overflow", "comfortable_beds", "stone_pillow", "across_seasons", "favorite_one", "statistician", - "well_informed", "lively_making", "memento_mori", + "well_informed", "lively_making", "memento_mori", "mad_scientist", ]; (async function run() { let h = createHarness(); let storage; - assert(h.api.definitions.length === 75, "definition count must be 75"); + assert(h.api.definitions.length === 76, "definition count must be 76"); { const spellHarness = createHarness(); spellHarness.api.unlock("statistician"); @@ -131,6 +131,9 @@ const expectedIds = [ spellHarness.api.unlock("memento_mori"); const extendedSpell = spellHarness.api.exportSpellState(); assert((extendedSpell[3] & 0x0400) !== 0, "achievement 75 is not stored in the second extended mask byte"); + spellHarness.api.unlock("mad_scientist"); + const latestSpell = spellHarness.api.exportSpellState(); + assert((latestSpell[3] & 0x0800) !== 0, "achievement 76 is not stored in the extended mask"); const oldSpellHarness = createHarness(); const imported = oldSpellHarness.api.importSpellState([7, 1, 0, 0, 100, [0], []]); assert(!imported.included && !oldSpellHarness.api.isUnlocked("first_birth"), "obsolete achievement spell version was accepted"); @@ -141,7 +144,7 @@ const expectedIds = [ assert(new Set(categorizedIds).size === expectedIds.length, "achievement category contains duplicates"); assert(expectedIds.every(id => categorizedIds.includes(id)), "achievement category misses an ID"); assert(h.api.categories.length === 6, "achievement categories must be reorganized into six groups"); - assert(JSON.stringify(h.api.categories.map(category => category.title)) === JSON.stringify(["生態", "繁殖・人口", "実験", "建築", "操作", "その他"]), "achievement category titles/order mismatch"); + assert(JSON.stringify(h.api.categories.map(category => category.title)) === JSON.stringify(["生態", "人口", "実験", "建築", "操作", "その他"]), "achievement category titles/order mismatch"); h.api.open(); const titles = Object.fromEntries(h.api.definitions.map(def => [def.id, def.title])); assert(titles.natural_zunchi_slave === "ずんちどれい", "slave title mismatch"); @@ -230,14 +233,14 @@ const expectedIds = [ assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "population", "experiment", "construction", "operation", "other"]), "achievement category order mismatch"); assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details"); const categoryTitles = h.api.categories.map(category => category.title); - assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "繁殖・人口", "実験", "建築", "操作", "その他"]), "achievement category titles mismatch"); + assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "人口", "実験", "建築", "操作", "その他"]), "achievement category titles mismatch"); const operationIds = h.api.categories.find(category => category.id === "operation").ids; const otherIds = h.api.categories.find(category => category.id === "other").ids; const populationIds = h.api.categories.find(category => category.id === "population").ids; assert(otherIds.at(-1) === "true_tarinai_observer", "completionist is not at the bottom of その他"); assert(otherIds.includes("across_seasons"), "across_seasons is not in その他"); assert(operationIds.includes("statistician") && operationIds.includes("well_informed"), "requested 操作 achievements are missing"); - assert(populationIds.includes("lively_making") && populationIds.includes("memento_mori"), "population achievements are missing from 繁殖・人口"); + assert(populationIds.includes("lively_making") && populationIds.includes("memento_mori"), "population achievements are missing from 人口"); h.api.unlock("natural_zunchi_slave"); const rerenderedGroups = h.elements.get("achievementList").children; const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId); @@ -698,6 +701,13 @@ const expectedIds = [ assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field"); + h = createHarness(); + for (let i = 0; i < 9; i += 1) h.api.recordConsumableUse({ type: "mystery_drug", tarinai: { id: `mad-${i}` } }); + h.api.recordConsumableUse({ type: "mystery_drug", tarinai: { id: "mad-0" } }); + assert(!unlocked(h, "mad_scientist"), "mad scientist counted the same tarinai twice"); + h.api.recordConsumableUse({ type: "mystery_drug", tarinai: { id: "mad-9" } }); + assert(unlocked(h, "mad_scientist"), "mad scientist did not unlock at 10 distinct tarinai"); + h = createHarness(); const livelyWorldA = { time: 0, tarinai: [] }; for (let i = 0; i < 60; i += 1) h.api.recordManualTarinaiAdded({ world: livelyWorldA }); @@ -707,7 +717,7 @@ const expectedIds = [ assert(!unlocked(h, "lively_making"), "lively making unlocked before 100 additions across resets"); h.api.recordManualTarinaiAdded({ world: livelyWorldB }); assert(unlocked(h, "lively_making"), "lively making did not persist across field reset"); - console.log("[OK] all 75 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed"); + console.log("[OK] all 76 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed"); })().catch(error => { console.error(error); process.exitCode = 1; diff --git a/scripts/achievement_integration_audit.py b/scripts/achievement_integration_audit.py index ae33782..9368f61 100644 --- a/scripts/achievement_integration_audit.py +++ b/scripts/achievement_integration_audit.py @@ -17,14 +17,14 @@ 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) == 75, 'JS achievement count is not 75') +require(len(ids_js) == 76, 'JS achievement count is not 76') 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') require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing') require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing') require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing') -require(all(item in ids_js for item in ['eternal_history_generation_10','king_full_satisfaction','slave_zero_satisfaction','town_doctor_50','poke_plushie_fling','chaos_seeker_666_fights','clean_freak_robot_only','true_tarinai_observer','sniper_333_shots','megalopolis','fertility_seeker_721_love_births','low_fps_single_digit','continuous_play_24_hours','sandbox_five_toilets','park_ground_changed','ground_change_4_in_1_second','ant_nest_without_tarinai','sticky_bomb_15_passes','daily_play_7_days','wire_shock_7_tarinai','well_informed','lively_making','memento_mori']), 'latest achievement IDs are missing') +require(all(item in ids_js for item in ['eternal_history_generation_10','king_full_satisfaction','slave_zero_satisfaction','town_doctor_50','poke_plushie_fling','chaos_seeker_666_fights','clean_freak_robot_only','true_tarinai_observer','sniper_333_shots','megalopolis','fertility_seeker_721_love_births','low_fps_single_digit','continuous_play_24_hours','sandbox_five_toilets','park_ground_changed','ground_change_4_in_1_second','ant_nest_without_tarinai','sticky_bomb_15_passes','daily_play_7_days','wire_shock_7_tarinai','well_informed','lively_making','memento_mori','mad_scientist']), 'latest achievement IDs are missing') require(all(item in ids_js for item in ['undo_mass_revival','robot_cleaner_100','held_30_seconds','minimalist_happy','overprotective','self_sufficient','unplanned_city_30','sauna_cold_plunge','rain_shelter_all','medicine_ledger_all','mercury_lifespan','enemy_enemy_friend','revolution','fuel_to_fire','undo_20','redo_20']), 'playstyle achievement IDs are missing') checks = { diff --git a/scripts/effect_runtime_regression_audit.js b/scripts/effect_runtime_regression_audit.js index 472ca87..9b73fd8 100644 --- a/scripts/effect_runtime_regression_audit.js +++ b/scripts/effect_runtime_regression_audit.js @@ -6,10 +6,13 @@ const combat = fs.readFileSync("js/world_combat_effects.js", "utf8"); const all = fs.readdirSync("js").filter(x => x.endsWith(".js")).map(x => fs.readFileSync(`js/${x}`, "utf8")).join("\n"); ok(!/\.map\([\s\S]{0,300}\.sort\(/.test(sim), "effect pressure compaction no longer map/sorts all effects"); ok(sim.includes("for (let priority = 1; priority <= 3"), "effect pressure drops by priority tiers"); -ok(combat.includes('new Set(["ring", "fight", "flame", "fall"])'), "effect pool is limited to simple high-frequency types"); +ok(combat.includes('"bleed", "bubble", "eat", "explosion", "heart", "shoot_impact", "zunchi_miasma", "electric_shock", "wind"'), "all transient effect variants use the reusable pool path"); ok(combat.includes("offscreenCulled"), "low-importance offscreen spawns are tracked and culled"); +ok(combat.includes('visualLevel?.("effects", "high") === "off"'), "effect creation is gated by the visual-effects setting"); +ok(sim.includes('visualLevel?.("effects", "high") === "off"') && sim.includes("clearDisabledEffects"), "disabled effects are released instead of updated"); ok(sim.includes('if (effect.type === "ring")') && sim.includes("lightweight"), "rings bypass the budgeted physics update path"); +ok(!sim.includes("const budgeted = []"), "effect updates do not allocate a temporary active-effect array each frame"); ok(sim.includes("spawnRequested") && sim.includes("poolEligible") && sim.includes("pooled"), "effect runtime diagnostics expose spawn and pool counters"); -const direct = [...all.matchAll(/effects\.push\(new Effect\(/g)].length; -ok(direct === 0, "normal Effect construction is routed through spawnEffect"); +const direct = [...all.matchAll(/(?:effects(?:\?\.)?\.push|effects\.push)\(new\s+(?:global\.)?(?:TarinaiEffect|EffectCtor|Effect)\(/g)].length; +ok(direct === 0, "normal Effect construction is routed through queueEffect"); console.log("[OK] effect runtime regression audit passed"); diff --git a/scripts/long_session_runtime_regression_audit.js b/scripts/long_session_runtime_regression_audit.js new file mode 100644 index 0000000..ec474c6 --- /dev/null +++ b/scripts/long_session_runtime_regression_audit.js @@ -0,0 +1,84 @@ +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const ROOT = path.resolve(__dirname, ".."); +const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8"); + +// Long-session transient caches must expire without relying on Tarinai deaths. +{ + const context = { console, Map, Set, Object, Number, Math, Array, CONFIG: { dayLength: 120 } }; + context.window = context; + context.globalThis = context; + vm.createContext(context); + vm.runInContext(read("js/simulation_maintenance_system.js"), context); + const phases = context.TarinaiMaintenanceSimulationSystem.phases; + let familyPrunes = 0; + const world = { + time: 100, compactTimer: 0, drawSortTimer: 0, + _maintenanceScheduleReady: true, + _nextStructureDependencyCheckAt: 999, _nextItemCompactAt: 999, _nextAntCompactAt: 999, + _nextEffectCountRefreshAt: 999, _nextTransientRuntimePruneAt: 0, + nextGrassLimitCheckAt: 999, nextFireCompactAt: 999, nextGrassBedExpiryCheckAt: 999, outOfBoundsCheckAt: 100, + itemCounts: {}, items: [], effects: [], itemBucketsDirty: false, familyPrunePending: true, + ballCollisionMemo: new Map([["old", 90], ["new", 99.5]]), + tarinaiCollisionMemo: new Map([["old", 90], ["new", 99.2]]), + relationNotices: { old: -30, recent: 10 }, + fightPairCooldowns: { old: 99, active: 110 }, + resolvedFightIds: { old: 90, recent: 99 }, + pruneExtinctFamilies() { familyPrunes += 1; }, + compactTarinai() {}, compactItems() { return false; }, compactAnts() {}, + updateItemCounts() {}, updateEffectCounts() {}, sanitizeOutOfBounds() {}, + }; + phases.runMaintenance(world, 0.01, 0); + assert(!world.ballCollisionMemo.has("old") && world.ballCollisionMemo.has("new")); + assert(!world.tarinaiCollisionMemo.has("old") && world.tarinaiCollisionMemo.has("new")); + assert(!Object.prototype.hasOwnProperty.call(world.relationNotices, "old") && Object.prototype.hasOwnProperty.call(world.relationNotices, "recent")); + assert(!Object.prototype.hasOwnProperty.call(world.fightPairCooldowns, "old") && Object.prototype.hasOwnProperty.call(world.fightPairCooldowns, "active")); + assert(!Object.prototype.hasOwnProperty.call(world.resolvedFightIds, "old") && Object.prototype.hasOwnProperty.call(world.resolvedFightIds, "recent")); + assert.strictEqual(familyPrunes, 0, "family graph pruning must stay dormant outside the family view"); + assert.strictEqual(world.familyPrunePending, true, "pending family cleanup should be preserved for the family view"); + console.log("[OK] transient runtime caches expire independently of deaths"); + console.log("[OK] hidden family graph is not swept by simulation maintenance"); +} + +// Effects-off must release the live queue without running Effect.update. +{ + let updates = 0; + let releases = 0; + const context = { console, Math, Number, Array, Uint8Array }; + context.window = context; + context.globalThis = context; + context.TarinaiPerf = { + visualLevel() { return "off"; }, + performanceProfile() { return { effectCap: 1e9, effectUpdateBudget: 1e9 }; }, + diagnosticsEnabled() { return false; }, + }; + vm.createContext(context); + vm.runInContext(read("js/simulation_effects_system.js"), context); + const world = { + effects: [ + { dead: false, type: "flame", update() { updates += 1; } }, + { dead: false, type: "ring", update() { updates += 1; } }, + ], + releaseEffect() { releases += 1; }, + }; + context.TarinaiEffectsSimulationSystem.phases.updateEffects(world, 1 / 60); + assert.strictEqual(updates, 0); + assert.strictEqual(releases, 2); + assert.strictEqual(world.effects.length, 0); + console.log("[OK] effects-off performs no transient Effect updates and clears the queue"); +} + +// Fight-resolution dedupe must be pair-timestamp based rather than an ever-growing time bucket key. +{ + const combat = read("js/world_combat_effects.js"); + assert(combat.includes('const key = `${winner.id}:${loser.id}`;')); + assert(!combat.includes('Math.floor(this.time / 2)')); + assert(combat.includes('this.resolvedFightIds[key] = this.time;')); + console.log("[OK] repeated fight outcomes no longer create permanent time-bucket keys"); +} + +console.log("Long-session runtime regression audit passed."); diff --git a/scripts/object_render_cursor_regression_audit.js b/scripts/object_render_cursor_regression_audit.js new file mode 100644 index 0000000..7ac9de7 --- /dev/null +++ b/scripts/object_render_cursor_regression_audit.js @@ -0,0 +1,25 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const root = path.resolve(__dirname, ".."); +const read = rel => fs.readFileSync(path.join(root, rel), "utf8"); +let failed = false; +function ok(name, cond) { + if (cond) console.log(`[OK] ${name}`); + else { console.error(`[FAIL] ${name}`); failed = true; } +} +const render = read("js/render.js"); +const sim = read("js/simulation_creature_system.js"); +const frame = read("js/tarinai_update_step_frame.js"); +const care = read("js/tarinai_cursor_care_system.js"); + +ok("cursor care is updated from one spatial query per frame", sim.includes("function updateCursorCare") && sim.includes("worldRef.nearbyTarinai?.") && sim.includes("updateCursorCare(worldRef, dt)")); +ok("cursor care is no longer cadence-throttled inside full Tarinai updates", !frame.includes("TarinaiCursorCareSystem.updateOne")); +ok("cursor hearts use the central effect queue", care.includes('this.world.queueEffect("heart"') && !care.includes("new EffectCtor")); +ok("stable object raster cache exists", render.includes("STATIC_ITEM_BITMAP_TYPES") && render.includes("drawStaticItemBitmapCached")); +ok("cached objects preserve individual depth-stack entries", render.includes("drawStaticItemBitmapCached(ctx, it, world.time, lighting, world)") && render.includes("drawRenderEntity(ctx, entry.entity, world.time, lighting, world)")); +ok("close-up object art bypasses raster cache", render.includes("viewScale?.() || 1) > 1.35")); +ok("slow-frame visible-creature anti-spiral budget is bounded", sim.includes("antiSpiralVisibleBudget") && sim.includes("Math.floor(2.8 / Math.max(0.028, dt))")); +ok("budget-deferred visible creatures keep motion-only updates", sim.includes("updateMotionOnly(t, dt, { context })")); +if (failed) process.exit(1); +console.log("Object render/cursor regression audit passed."); diff --git a/scripts/offscreen_motion_performance_regression_audit.js b/scripts/offscreen_motion_performance_regression_audit.js new file mode 100644 index 0000000..45204b7 --- /dev/null +++ b/scripts/offscreen_motion_performance_regression_audit.js @@ -0,0 +1,16 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const root = path.resolve(__dirname, ".."); +const read = p => fs.readFileSync(path.join(root, p), "utf8"); +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(/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"); +ok(!/const mateDetailed = \[\]/.test(social), "full local-population mate reserve scan is removed"); +console.log("Off-screen motion/performance regression audit passed."); diff --git a/scripts/playstyle_achievement_audit.js b/scripts/playstyle_achievement_audit.js index 6cb8648..aacc24c 100644 --- a/scripts/playstyle_achievement_audit.js +++ b/scripts/playstyle_achievement_audit.js @@ -35,7 +35,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st (function run(){ let h=harness(); - assert(h.api.definitions.length===75,"must have 75 achievements"); + assert(h.api.definitions.length===76,"must have 76 achievements"); h.api.open(); const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title])); assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title"); diff --git a/scripts/regression_check.py b/scripts/regression_check.py index a557696..498cd9b 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -306,6 +306,7 @@ const api = context.TarinaiDirectFeedingSystem; if (!api || !api.isDirectFeedType('sweet') || !api.isDirectFeedType('protein') || api.isDirectFeedType('ball')) throw new Error('direct-feed eligibility mismatch'); const world = {{ tarinai: [], items: [], effects: [], logs: [], time: 12, hoverGiveTarget: null, + queueEffect(type, x, y, options = {{}}) {{ const effect = new TestEffect(type, x, y, options); this.effects.push(effect); return effect; }}, isTarinaiHiddenInNestBox() {{ return false; }}, log(text, kind, meta) {{ this.logs.push({{ text, kind, meta }}); }}, recordFamily(t) {{ this.recorded = t; }}, @@ -338,7 +339,7 @@ if (top.currentPersonality.openness <= 0.006) throw new Error('food gift does no const expectedPersonalityReason = '\u30ab\u30fc\u30bd\u30eb\u304b\u3089\u76f4\u63a5\u98df\u3079\u7269\u3092\u98df\u3079\u305f'; if (!top.personalityReasons?.length || top.personalityReasons.some(reason => reason !== expectedPersonalityReason)) throw new Error('direct-feed personality reason mismatch'); if (top.lastBubble !== '\u2661' || context.uiCache.selectedSnapshot !== '') throw new Error('food gift feedback/cache refresh missing'); -if (!world.logs.some(entry => entry.meta?.eventType === 'direct_feed')) throw new Error('direct-feed observation log missing'); +if (world.logs.some(entry => entry.meta?.eventType === 'direct_feed')) throw new Error('direct-feed player operation must not enter the event log'); const priorEffects = world.effects.length; const medicine = {{ type: 'protein', amount: 5, foodServingsRemaining: 5, x: 20, y: 20 }}; world.items.push(medicine); @@ -520,7 +521,9 @@ def check_social_balance_guards() -> None: "approach_mate: 72", "approach_friend: 66", "return ({ conflict: 18, mate: 24, family: 12, bond: 14", - "const relationDrive = Math.max(Number(parts.mate || 0)", + 'if (subNeed === "mate")', + "if (subValue >= threshold) return true", + "const relationDrive = Math.max(Number(parts.conflict || 0)", "drugBoosted || relationDrive >= Math.min", "social-fight-vs-mate-weighted", ] @@ -1197,8 +1200,12 @@ def check_tarinai_update_pipeline_boundary() -> None: fail("Tarinai need planner core system export missing") if "TarinaiItemInteractionSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_ai.js"): fail("Tarinai AI step does not delegate item interactions to subsystem") - if "TarinaiCursorCareSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_frame.js"): - fail("Tarinai frame step does not delegate cursor care to subsystem") + cursor_care_frame = read_text(ROOT / "js/tarinai_update_step_frame.js") + cursor_care_sim = read_text(ROOT / "js/simulation_creature_system.js") + if "TarinaiCursorCareSystem.updateOne" not in cursor_care_sim: + fail("Creature simulation does not delegate cursor care to subsystem") + if "TarinaiCursorCareSystem.updateOne" in cursor_care_frame: + fail("Cursor care is still duplicated inside the cadence-throttled Tarinai frame step") if "TarinaiLocalEnvironmentSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_environment.js"): fail("Tarinai environment step does not delegate local environment to subsystem") if "TarinaiSunbathSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_movement.js"): diff --git a/scripts/reproduction_memory_sprite_regression_audit.js b/scripts/reproduction_memory_sprite_regression_audit.js new file mode 100644 index 0000000..2ba7cd3 --- /dev/null +++ b/scripts/reproduction_memory_sprite_regression_audit.js @@ -0,0 +1,26 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const root = path.resolve(__dirname, ".."); +const read = rel => fs.readFileSync(path.join(root, rel), "utf8"); +const ok = (cond, label) => { + if (!cond) throw new Error(`[FAIL] ${label}`); + console.log(`[OK] ${label}`); +}; + +const data = read("js/data.js"); +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(/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"); +ok(!/STATIC_ITEM_BITMAP_TYPES = new Set\(\[[\s\S]*"zunchi"[\s\S]*\]\);/.test(render), "high-count zunchi no longer allocate per-item bitmap canvases"); +ok(!/STATIC_ITEM_BITMAP_TYPES = new Set\(\[[\s\S]*"food"[\s\S]*\]\);/.test(render), "high-count consumables no longer allocate per-item bitmap canvases"); +ok(/function releaseStaticItemBitmapCache\(/.test(render) && /cache\.canvas\.width = 0;/.test(render), "obsolete item bitmap backing buffers are explicitly released"); +ok(/MAX_HISTORY = 32/.test(history) && /MAX_HISTORY_BYTES = 8 \* 1024 \* 1024/.test(history), "undo history is capped at 32 entries and 8 MiB"); + +console.log("Reproduction/memory/sprite regression audit passed."); diff --git a/scripts/reproduction_regression_audit.js b/scripts/reproduction_regression_audit.js new file mode 100644 index 0000000..30a8d44 --- /dev/null +++ b/scripts/reproduction_regression_audit.js @@ -0,0 +1,27 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const root = path.resolve(__dirname, ".."); +const read = p => fs.readFileSync(path.join(root, p), "utf8"); +const creature = read("js/simulation_creature_system.js"); +const frame = read("js/tarinai_update_step_frame.js"); +const social = read("js/tarinai_social_move_life.js"); +const achievements = read("js/achievements.js"); + +const needs = read("js/tarinai_needs_items.js"); +const socialRuntime = read("js/tarinai_social_action_runtime.js"); +const actionDefs = read("js/tarinai_action_definitions.js"); +const family = read("js/world_family_social.js"); +function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); } +ok(/t\.reproductionTimer\s*=\s*\(Number\(t\.reproductionTimer/.test(creature), "reproduction cooldown advances in the outer per-frame creature pass"); +ok(!/reproductionTimer\s*-=\s*dt/.test(frame), "full AI cadence no longer double-advances reproduction cooldown"); +ok(/const candidateBudget = Math\.max\(20, scanLimit \+ 8\)/.test(social) && /if \(inspected >= candidateBudget\) break;/.test(social), "dense contact scans are bounded instead of walking the full local population"); +ok(/const currentMateTarget =/.test(social) && /detailed\.push\(\{ o: currentMateTarget/.test(social), "the selected mate target is injected into bounded contact checks"); +ok(/id: "population",\s*\n\s*title: "\\u4eba\\u53e3"/.test(achievements), "achievement population category is titled 人口"); + +ok(/socialParts\.mate \+= 28/.test(needs), "ready unpaired adults receive a mating sub-need above the start threshold"); +ok(/if \(subNeed === "mate"\) \{[\s\S]*subValue >= threshold/.test(needs), "mating sub-need can start independently of aggregate loneliness"); +ok(/!tarinai\.juvenile/.test(needs) && /!t\.juvenile/.test(actionDefs), "juveniles do not proactively start mating actions"); +ok(/!t\.juvenile && !other\.juvenile/.test(socialRuntime), "contact mating excludes juveniles"); +ok(/if \(a\.juvenile \|\| b\.juvenile\) return false;/.test(family), "birth ritual rejects juvenile participants centrally"); +console.log("Reproduction regression audit passed."); diff --git a/scripts/secondary_achievement_regression_audit.js b/scripts/secondary_achievement_regression_audit.js index d4f0a77..e29eeda 100644 --- a/scripts/secondary_achievement_regression_audit.js +++ b/scripts/secondary_achievement_regression_audit.js @@ -88,6 +88,19 @@ function assert(value, message) { if (!value) throw new Error(message); } observerWorld.time = 860; h.api.evaluateWorld(observerWorld, {}); assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three uninterrupted game days"); + // Manually adding a Tarinai is also a gameplay intervention. This must reset + // the surveillance-camera window even if the separate lively-making achievement + // has already been completed. + h = harness(); + const addWorld = { time: 0, tarinai: [], ants: [], items: [], fieldType: "garden", groundType: "soil" }; + h.api.evaluateWorld(addWorld, {}); + addWorld.time = 359.9; + h.api.recordManualTarinaiAdded({ world: addWorld, tarinai: { id: "manual-1" }, tool: "new" }); + addWorld.time = 719.8; h.api.evaluateWorld(addWorld, {}); + assert(!h.api.isUnlocked("idle_observer_5_minutes"), "manual Tarinai addition did not reset passive-observation progress"); + addWorld.time = 719.9; h.api.evaluateWorld(addWorld, {}); + assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three game days following manual Tarinai addition"); + // Minimalist needs player-placement identity even after Unplanned City is already unlocked. const snapshotSource = fs.readFileSync(path.join(root, "js", "snapshot_system.js"), "utf8"); assert(snapshotSource.includes('const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;'), "minimalist placement metadata is not retained independently of quick-delete progress"); @@ -102,5 +115,5 @@ function assert(value, message) { if (!value) throw new Error(message); } const damageAt = shockBody.indexOf("target.damage?."); assert(recordAt >= 0 && damageAt >= 0 && recordAt < damageAt, "wire-shock achievement is still recorded after potentially lethal damage"); - console.log("[OK] secondary achievement regressions: sauna onset, reset cleanup, observer interventions, minimalist save metadata, and lethal wire shocks passed"); + console.log("[OK] secondary achievement regressions: sauna onset, reset cleanup, observer interventions including manual Tarinai addition, minimalist save metadata, and lethal wire shocks passed"); })(); diff --git a/scripts/social_interaction_regression_audit.js b/scripts/social_interaction_regression_audit.js index eda29a3..db19dfa 100644 --- a/scripts/social_interaction_regression_audit.js +++ b/scripts/social_interaction_regression_audit.js @@ -10,8 +10,9 @@ ok(!needSrc.includes('const crowdSample = tarinai.socialCrowdSample'), "relation ok(!actionSrc.includes('createCrowdEscapeActionSpec(),'), "crowd retreat action is not registered"); ok(!colonySrc.includes('nearestAverage('), "colony status no longer performs O(N^2) crowd distance scans"); ok(!colonySrc.includes('add("overcrowded"'), "overcrowded colony status is no longer selected"); -ok(!moveSrc.includes('const candidateBudget = Math.max(20, scanLimit + 8)'), "contact candidates are no longer truncated by spatial bucket order"); -ok(!moveSrc.includes('if (inspected >= candidateBudget) break'), "contact scans do not stop before considering nearer later candidates"); +ok(moveSrc.includes('const candidateBudget = Math.max(20, scanLimit + 8)'), "dense contact work has a fixed candidate budget"); +ok(moveSrc.includes('if (inspected >= candidateBudget) break'), "dense contact scans stop before becoming population-squared work"); +ok(moveSrc.includes('const currentMateTarget =') && moveSrc.includes('detailed.push({ o: currentMateTarget'), "selected mating partners bypass contact candidate truncation"); ok(moveSrc.includes('if (pos < scanLimit)'), "contact scan keeps a bounded nearest-candidate list"); ok(moveSrc.includes('if (detailed.length > scanLimit) detailed.pop()'), "nearest-candidate list remains bounded under dense populations"); ok(moveSrc.includes('const pairLeader = String(this.id) < String(o.id)'), "symmetric pair work has one deterministic leader"); diff --git a/scripts/tooltip_nail_colony_focus_regression_audit.js b/scripts/tooltip_nail_colony_focus_regression_audit.js new file mode 100644 index 0000000..0af0d13 --- /dev/null +++ b/scripts/tooltip_nail_colony_focus_regression_audit.js @@ -0,0 +1,23 @@ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const root = path.resolve(__dirname, ".."); +const read = file => fs.readFileSync(path.join(root, file), "utf8"); +let failed = 0; +function ok(cond, msg) { if (!cond) { failed++; console.error(`FAIL: ${msg}`); } else console.log(`OK: ${msg}`); } + +const family = read("js/world_family_social.js"); +const tools = read("js/world_tool_actions.js"); +const colony = read("js/colony_situation_system.js"); +const placement = read("js/world_placement_log.js"); +const bind = read("js/ui_bind.js"); + +ok(family.includes('ITEM_TRAITS?.sleepFurniture') && family.includes('["bed", "nest_box", "pipe", "grass_bed"]'), "world object tooltips are restricted to sleep furniture"); +ok(tools.includes('TarinaiItemDynamicPinSystem?.detach?.(target, this, "pinch")'), "delete tool detaches lodged pins with pinch-equivalent effects before deletion"); +ok(!tools.includes('刺さった鋲は、つまんで抜いてください'), "lodged pin delete rejection was removed"); +ok(colony.includes('confusionPanicThreshold') && colony.includes('Math.ceil(m.n * 0.32)'), "confusion threshold was raised"); +ok(colony.includes('breedingThreshold') && colony.includes('Math.ceil(m.n * 0.18)') && !colony.includes('lastBirthAt || -999) <= 45'), "breeding state requires stronger active participation and no longer triggers from one recent birth"); +ok(placement.includes('this._scrollSelectedDataTopOnNextSelection = Boolean(found)') && placement.includes('selectionResult?.changed === false'), "clicking a Tarinai requests selected-data scrolling even when reselecting the same actor"); +ok(bind.includes('window.scrollToSelectedDataPanel = () =>') && bind.includes('scrollPanelToTab("selected")'), "selected-data panel exposes a direct scroll target helper"); + +if (failed) process.exit(1); diff --git a/scripts/ui_grass_limits_regression_audit.py b/scripts/ui_grass_limits_regression_audit.py index 56afb03..ef947c3 100644 --- a/scripts/ui_grass_limits_regression_audit.py +++ b/scripts/ui_grass_limits_regression_audit.py @@ -75,7 +75,7 @@ components = text('css/components.css') must('achievementToastCondition' in index and 'achievement-toast-condition' in components, 'achievement unlock toast condition line missing') must('if (dom.toastCondition) dom.toastCondition.textContent = definition.description || "";' in ach, 'achievement toast does not render the completion condition') must('lively_making' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach and 'manualTarinaiAddedCount' in ach, 'Lively Making persistent/pre-unlock progress is missing') -must(r'title: "\u7e41\u6b96\u30fb\u4eba\u53e3"' in ach and r'title: "\u64cd\u4f5c"' in ach and 'id: "other"' in ach, 'six-category achievement reorganization missing') +must(r'title: "\u4eba\u53e3"' in ach and r'title: "\u64cd\u4f5c"' in ach and 'id: "other"' in ach, 'six-category achievement reorganization missing') must('num(rect.bottom) > (worldRef.h || 0)' in placement_preview, 'placement preview still reserves an artificial bottom padding') must('rect.bottom > this.h' in placement_log, 'confirmed placement still reserves an artificial bottom padding') must('Math.max(5, Math.ceil(m.n * 0.10))' in colony, 'crisis mortality threshold is still too loose') diff --git a/service-worker.js b/service-worker.js index 25719a8..592af65 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "39.16.98"; +const APP_VERSION = "39.16.109"; 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.