From 2cc2f003df139ad36ecf854b42cc114226ca61ba Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 16 Jul 2026 22:12:03 +0900 Subject: [PATCH] 1.9 --- .achievement_data/.gitkeep | 0 .achievement_data/.htaccess | 6 - AI_MAP.md | 43 +- BUILD_NOTE.txt | 91 +- FILES.json | 19 +- GAME_FEATURES.md | 17 +- README.md | 33 +- achievement_api.php | 286 ++++- app_manifest.json | 6 +- css/base.css | 122 ++ css/components.css | 2 +- index.html | 354 +++--- js/achievements.js | 323 +++-- js/circuit_board_system.js | 1117 +++++++++++++++++ js/collision_footprint_system.js | 6 + js/colony_situation_system.js | 10 +- js/deterministic_helpers.js | 39 +- js/geometry_helpers.js | 9 +- js/item_dynamic_tool_system.js | 1 + js/item_lifecycle_support.js | 1 + js/item_render_runtime.js | 112 +- js/item_tool_definitions.js | 10 +- js/item_tool_metadata.js | 17 +- js/item_type_initializers.js | 23 +- js/mechanical_system.js | 4 +- js/physics_projection_system.js | 2 +- js/physics_world_system.js | 13 +- js/placement_preview_system.js | 8 +- js/render.js | 59 +- js/save_codec.js | 85 +- js/save_schema.js | 4 +- js/seesaw_system.js | 217 ++++ js/signal_system.js | 135 +- js/snapshot_system.js | 33 +- js/structures.js | 4 +- js/system_order.js | 1 + js/tarinai_action_definitions.js | 16 + js/tarinai_action_spec.js | 10 +- js/tarinai_behavior_state.js | 14 +- js/tarinai_behavior_text.js | 6 +- js/tarinai_food_interaction_system.js | 1 + js/tarinai_food_prototype_mixin.js | 6 +- js/tarinai_item_targeting.js | 2 +- js/tarinai_needs_core.js | 1 + js/tarinai_needs_items.js | 9 +- js/tarinai_social_move_life.js | 7 +- js/tarinai_update_policy.js | 2 +- js/ui_charts.js | 2 +- js/ui_family_layout.js | 2 + js/ui_family_render.js | 2 + js/ui_layout_dialogs.js | 2 + js/ui_log.js | 2 + js/ui_selected.js | 3 +- js/ui_tools.js | 2 +- js/version.js | 4 +- js/world.js | 2 + js/world_placement_log.js | 166 ++- js/world_tool_actions.js | 7 + js/world_view.js | 3 + scripts/achievement_audit.js | 105 +- ...vement_completed_counter_omission_audit.js | 15 - scripts/achievement_integration_audit.py | 26 +- scripts/achievement_save_metadata_audit.js | 69 - scripts/achievement_server_state_audit.py | 341 +++++ scripts/achievement_spell_audit.js | 160 --- ...ievement_spell_binary_compression_audit.js | 55 - scripts/playstyle_achievement_audit.js | 35 +- scripts/regression_check.py | 10 +- scripts/ui_dialog_achievement_audit.js | 19 - scripts/v391655_gameplay_fix_audit.js | 64 - service-worker.js | 4 +- 71 files changed, 3359 insertions(+), 1027 deletions(-) delete mode 100644 .achievement_data/.gitkeep delete mode 100644 .achievement_data/.htaccess create mode 100644 js/circuit_board_system.js create mode 100644 js/seesaw_system.js delete mode 100644 scripts/achievement_completed_counter_omission_audit.js delete mode 100644 scripts/achievement_save_metadata_audit.js create mode 100644 scripts/achievement_server_state_audit.py delete mode 100644 scripts/achievement_spell_audit.js delete mode 100644 scripts/achievement_spell_binary_compression_audit.js delete mode 100644 scripts/ui_dialog_achievement_audit.js delete mode 100644 scripts/v391655_gameplay_fix_audit.js diff --git a/.achievement_data/.gitkeep b/.achievement_data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.achievement_data/.htaccess b/.achievement_data/.htaccess deleted file mode 100644 index fa39801..0000000 --- a/.achievement_data/.htaccess +++ /dev/null @@ -1,6 +0,0 @@ - - Require all denied - - - Deny from all - diff --git a/AI_MAP.md b/AI_MAP.md index c1759d3..bab0e6e 100644 --- a/AI_MAP.md +++ b/AI_MAP.md @@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th - Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order. - Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader. -- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.16.29`. +- Version/cache: `app_manifest.json`, `js/version.js`, generated `service-worker.js`, and `index.html` query params share `39.16.74`. - Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render. - Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches. - Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`. @@ -82,31 +82,24 @@ This section records current boundaries only. Version-by-version cleanup history - UI/text-side meaning consolidation, including labels, wrapping/measurement helpers, and duplicated scalar helpers such as `num`, `finiteOr`, `isObject`, and `clonePlain`. - Further `World.prototype` reduction is possible, but only for helpers proven not to be runtime-facing canvas/UI/tool APIs. -## v39.16.30 routing notes +## Current Feature Routing -- War preset composition: `js/world_reset_presets.js`. -- Fence restitution: `js/world_environment.js`; ball response: `js/item_dynamic_ball_system.js`. -- Ball-to-Tarinai contact, including sleepers: `js/collision_response_system.js`. -- Movable placement stacking: `js/item_tool_metadata.js`, `js/collision_footprint_system.js`, `js/world_placement_log.js`. -- Delete-tool target lookup: `js/world_tool_actions.js`. -- Nest-box tooltip offset: `js/render.js`. +- Achievement system: `js/achievements.js`, `achievement_api.php`, `js/save_schema.js`, `js/save_codec.js`, `js/snapshot_system.js`, and achievement audit scripts. Current save schema is `49`; current app/cache version is `39.16.74`. +- Achievement UI: `index.html` achievement dialog, `css/components.css` achievement rows/toast styles, and `js/achievements.js` DOM rendering. +- Achievement spell payload: `TarinaiAchievements.exportSpellState()`, `snapshot.g.s`, `save_system.js` spell export inclusion, and `save_codec.js` achievement metadata readers/writers. +- Logic board: `js/circuit_board_system.js`, `js/signal_system.js`, signal-related item definitions/metadata, and the generated circuit editor markup/styles. +- Shared play: `js/seesaw_system.js`, tarinai action/need modules, placement preview, and item/structure definitions. +- Reset presets: `js/world_reset_presets.js`; UI reset selection is in `js/ui_layout_dialogs.js`; social archive reset/fallback routing touches `js/world_family_social.js`. +- Physics tool icons and previews: `js/ui_tools.js`, `js/render.js`, `js/placement_preview_system.js`, `js/physics_shape_editor_system.js`, and `js/item_tool_metadata.js`. -## v39.16.31 routing notes +## Cleanup Candidate Audit - resolved -- Hair Trigger preset: `js/world_reset_presets.js`; physics tool icon renderer: `js/ui_tools.js`; world tooltip offset: `js/render.js`. +The previous cleanup candidate queue has been applied or adjusted. Current state: - -## Achievement system (v39.16.60) -- `js/achievements.js`: 54 definitions, local unlock state, persistent placement/ant-kill/reproduction/fight-mochi/shot/love-mochi/Undo/Redo/quick-delete progress, field-scoped direct-feed/cleaner/self-sufficiency/enemy-ant progress, individual care/hold/temperature/fight markers, allowlisted inline disclosures and local progress, shared-rate bars/counts, event-driven generation/status-effect/first-aid/plushie checks, completionist chaining, cached hidden-dialog rendering, debounced pending synchronization, and periodic additive full-state recovery synchronization. -- `achievement_api.php`: validates current IDs, reads legacy verbose or compact v2 aggregate state, writes compact indexed arrays, migrates the intersection of the retired automation/link achievements into `mechanized_industry`, accepts idempotent bulk unlock synchronization, and returns anonymous global percentages plus achiever counts. -- `css/components.css`: achievement rows use `--achievement-rate` to render horizontal background fills; unlocked rows use a distinct fill. -- `js/world.js`, `js/snapshot_system.js`, and `js/save_codec.js`: field-local achievement counters and per-entity/item markers reset with a new field and persist within current-format saves; schema 43 is current; compact schema 42 and legacy JSON schema 41 remain readable for migration. -- Hooks span placement/deletion/history, robot cleaning, direct care and consumables, fight starts/deaths, ant damage attribution, save slots, pause input, hold state, population/temperature/weather evaluation, first-aid recovery, shared poke targeting/highlights, plushie fling, and nest-box/pipe weather blocking. -- Audits: `scripts/achievement_audit.js` checks legacy/current boundaries and recovery; `scripts/playstyle_achievement_audit.js` checks the play-style conditions, reset scopes, false-positive guards, tooltips, and production hooks; `scripts/achievement_integration_audit.py` checks IDs and integration wiring. -### Achievement spell payload (v39.16.60) -- `TarinaiAchievements.exportSpellState()` emits a compact array: unlock bitset, delta-coded timestamps, and packed persistent counters. -- `snapshot_system.js` includes it as `snapshot.g.s` only for exported spells and merges it through `importSpellState()` on load. -- `save_system.js` requests achievement inclusion for spell export; internal save slots do not include global achievement state. - -- `save_codec.js` schema 42 writes achievement metadata directly as sparse binary fields instead of a JSON string and can still decode schema 41 spell saves. -- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements. +- Version/docs routing was aligned to `39.16.74`; `js/version.js` build identity now matches the app version, and `README.md` no longer points at an older cache-query script URL. +- One-version and overlapping audit scripts from the candidate list were removed; the broad audit suite remains `scripts/achievement_audit.js`, `scripts/playstyle_achievement_audit.js`, `scripts/achievement_integration_audit.py`, `scripts/achievement_server_state_audit.py`, and `scripts/regression_check.py`. +- Shared scalar/object/clone/hash/text helpers now live on `TarinaiCoreHelpers` in `js/deterministic_helpers.js`; action, behavior, structure, snapshot, save, geometry, physics, seesaw, placement-preview, circuit, and signal code route repeated helper logic through that shared surface or through `TarinaiGeometry`. +- UI HTML escaping is owned by `js/ui_helpers.js`; consumers bind explicitly to `TarinaiUIHelpers.htmlEscape`, and `index.html` loads `ui_helpers.js` before the consumers that need it. +- `js/achievements.js` uses `renderIfDialogOpen()` for progress refreshes instead of repeating the hidden-dialog check at each event recorder. +- `js/world_placement_log.js` uses shared close/backdrop binding for editor dialogs; deeper editor extraction can still be done as feature work, but the repeated close process from the cleanup list is removed. +- `FILES.json` now includes JavaScript audit scripts and newer core bootstrap files (`domain_ids`, deterministic helpers, item registries/definitions) in machine routing. diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index b87f01e..3bc1dba 100644 --- a/BUILD_NOTE.txt +++ b/BUILD_NOTE.txt @@ -5,8 +5,8 @@ tarinai_compact_save_stage4 - The binary header contains one schema byte; its high bit signals whether a string table follows. Empty string tables and the duplicate snapshot version are omitted. - Deflate state is folded into the low bit of the frame-length varuint, eliminating the separate compression byte. - 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map. -- Binary schema 41 is accepted for legacy v39.16.57 spells; new exports use schema 42. -- Version: 39.16.60 +- Binary schema 46 is the only accepted save/spell format; it stores the current achievement mask and sticky-bomb pass count. +- Version: 39.16.74 - 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. @@ -90,3 +90,90 @@ tarinai_compact_save_stage4 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.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.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. + +## v39.16.64 +- 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 +- Re-audited the not-yet-deployed shared achievement server with generated randomized unlock data instead of a fixed production sample. +- Shared summaries are read-only; only POST session/unlock/sync/reset calls register or update players, and GET mutations are rejected. +- State writes now use a separate lock file plus atomic same-directory replacement, preserving the previous state across interrupted writes. +- Invalid or structurally unknown JSON returns `storage_corrupt` without overwriting the source file. +- `completionistEligible: false` now prevents `true_tarinai_observer` from being re-added by the same sync payload. +- The randomized server audit covers 32 v2/verbose migrations and 72 concurrent player syncs with exact semantic comparison. + +## v39.16.66 +- 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 +- 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 +- 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 +- Circuit-board internal wiring now uses straight grid segments with explicit branch junctions and unlimited fan-out per terminal. +- Removed OR and added a one-input/one-output rectifier; internal logic parts rotate in 90-degree steps. +- Circuit-board external terminal counts and positions are editable; the board footprint and default terminal spacing were expanded. +- Detector numeric conditions now use a dual-thumb minimum/maximum range plus direct numeric inputs. +- Seesaw riders render slightly above the seat surface. +- Save snapshot/schema: 39/48. Older saves are intentionally unsupported. + + +## v39.16.70 +- The default circuit-board grid is now 10x6, one quarter of the previous editor area, with a correspondingly smaller world footprint. +- Circuit-board width and height are editable; the board world size follows the grid dimensions. +- Input/output terminal count is limited by board height, and every external terminal can be dragged independently onto the left, right, top, or bottom edge. +- Branch junctions can be moved directly; all connected straight segments follow the junction. +- NOT and rectifier bodies are visually smaller while their pins remain on grid intersections. +- Existing external wires follow terminal positions after a board resize or terminal move. +- Seesaw action reasons now use play-specific wording instead of inheriting unrelated fulfillment reasons such as bed demand. +- Save snapshot/schema: 40/49. Older saves are intentionally unsupported. + + +## v39.16.71 +- Circuit-board external terminals are now placed directly with IN/OUT terminal tools; the +/- terminal-count controls were removed. +- Disconnected external terminals are dimmed in the circuit-board editor; connected terminals retain their signal colors and cannot be deleted until their world wire is removed. +- Placement previews no longer use the generic ellipse fallback: circle items use circles, obstacle/mechanical items use their collision footprints, and special items use dedicated shapes. +- Detector, wire, insulated wire, and circuit board moved from the Physics palette section to a new Electrical section. +- Clicking a detector dual-range track outside either thumb teleports the nearer thumb to the clicked value. +- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. + + +## v39.16.72 +- Circuit-board default grid is 10x10 and both dimensions can grow to 20x20; the world footprint scales proportionally in both axes. +- AND gate body height is two grid cells. Compact one-input parts can be placed one grid row from the top/bottom edges when their rotated pins remain in bounds. +- Placement previews prefer shared occupancy/collision footprints; the dedicated seesaw preview was removed and circuit boards use their rectangular board footprint. +- Internal combinational feedback loops are explicitly detected and forced OFF; a NOT self-loop is unstable and does not provide power. +- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged. + + +## v39.16.73 +- Circuit-board editing shows placement ghosts for pending internal parts and terminals. +- Q/E rotates pending internal parts by 90 degrees before placement. +- The ON constant source is documented in the editor. +- Wire and insulated-wire palette silhouettes render correctly in the Electrical category. +- Save snapshot/schema remains 40/49. + + +## v39.16.74 +- 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. diff --git a/FILES.json b/FILES.json index cf94b15..897b5a4 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.60", + "version": "39.16.74", "entrypoints": { "app": "index.html", "runtime": "js/main.js", @@ -53,19 +53,28 @@ { "id": "scripts", "patterns": [ + "scripts/*.js", "scripts/*.py", "scripts/*.ps1" ], - "meaning": "Generators, regression checks, and token-aware AI inventory tooling." + "meaning": "Generators, regression checks, version-specific audits, and token-aware AI inventory tooling." }, { "id": "core", "patterns": [ "js/version.js", "js/event_bus.js", + "js/domain_ids.js", "js/registry_base.js", "js/disease_registry.js", "js/sound_pack.js", + "js/deterministic_helpers.js", + "js/item_tool_metadata.js", + "js/item_tool_definitions.js", + "js/item_visual_definitions.js", + "js/item_food_definitions.js", + "js/item_effect_definitions.js", + "js/item_registry.js", "js/data.js", "js/ground_types.js", "js/health.js", @@ -123,7 +132,8 @@ "js/item*.js", "js/items.js", "js/structures.js", - "js/structure_lifecycle.js" + "js/structure_lifecycle.js", + "js/circuit_board_system.js" ], "meaning": "Item/structure registry, lifecycle, dynamic behavior, rendering." }, @@ -131,7 +141,8 @@ "id": "tarinai", "patterns": [ "js/tarinai*.js", - "js/ants.js" + "js/ants.js", + "js/seesaw_system.js" ], "meaning": "Creature state/actions/needs/social life/item effects/disease/rendering plus ant actor model." }, diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md index 559f968..d0bf495 100644 --- a/GAME_FEATURES.md +++ b/GAME_FEATURES.md @@ -38,7 +38,9 @@ Fences block movement and can be rotated for layout control. Gates can be opened Rotators are editable mechanical shapes that spin and push nearby targets. Their shape and speed can create conveyors, crushers, traps, or chaotic collision fields. Reciprocators move back and forth along a line, so they can shove objects repeatedly. Poison blocks are custom hazard shapes that damage on contact. -Wires and insulated wires carry signals between compatible items. Normal energized wire can hurt living targets on contact, while insulated wire is safer for incidental contact. Detectors can sense targets or timing conditions and send signals, allowing simple automation. +## Electrical And Automation + +Detectors, wires, insulated wires, and circuit boards are grouped in the Electrical tool category. Normal energized wire can hurt living targets on contact, while insulated wire is safer for incidental contact. Detectors can sense target ranges or time windows and send signals; clicking either dual-range track moves the nearer thumb directly to the clicked value. Circuit-board external input and output terminals are placed directly on the board edge, can be moved around all four sides, and appear dim in the editor until a world wire is actually connected. ## Danger Tools @@ -73,8 +75,8 @@ Manual save and restore use browser storage. Reset returns to field selection an Display settings control effects, shadows, drawing detail, and quality choices for crowded scenes. Crowded fields are an intended part of the sandbox, so the game uses spatial lookup, scheduling, cached drawing, and incremental maintenance systems to keep large colonies and many objects playable. -## Achievement system (v39.16.60) -- Fifty-four achievements track reproduction, natural status changes, generation runs, rapid population events, special deaths, colony conditions, individual care, autonomous ecology, history usage, cleanup, rapid deletion, temperature transitions, weather shelter, medicine-category completion, combat reversals, and ant-related play styles. +## Achievement system (v39.16.74) +- Sixty-four achievements track reproduction, natural status changes, generation runs, rapid population events, special deaths, colony conditions, individual care, autonomous ecology, history usage, cleanup, rapid deletion, temperature transitions, weather shelter, medicine-category completion, combat reversals, circuit-board signaling, and ant-related play styles. - Locked rows reveal only the configured condition allowlist; all others show `???`. There are no achievement tooltips. 大いなる母, めっちゃたりない観察, 敵の敵は味方, 混沌ヲ希求スル者, and 豊穣ヲ希求スル者 include local progress percentages, and unlocked achievements are sorted first inside each category. - Each achievement row behaves as a horizontal bar chart. Its background fill visualizes the optional global completion percentage from 0–100%, and the global achiever count is displayed beside it. - `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets. @@ -83,12 +85,15 @@ Display settings control effects, shadows, drawing detail, and quality choices f - v39.16.56 adds 混沌ヲ希求スル者 and 潔癖症, raises 清掃業者 to 200, starts ミニマリスト from day 6, excludes chain/crown from おくすり手帳全埋め, and keeps shared-rate bars visible after unlock. - v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく. ベビーブーム now requires 30 births in one minute; 管理された楽園 starts tracking from play day 6 and requires one uninterrupted happy in-game day. - Local unlocks and persistent totals live in browser storage. Field-scoped direct-feeding, cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress follow current-format saves where applicable. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了. -- Achievement evaluation is primarily event-driven; periodic world scans skip completed or irrelevant conditions. The server writes compact v2 achievement aggregate JSON while continuing to read the earlier verbose state format. +- Achievement evaluation is primarily event-driven; periodic world scans skip completed or irrelevant conditions. The server writes compact v3 achievement aggregate JSON while migrating older aggregate formats on read. -### Achievement spell persistence (v39.16.60) +### Achievement spell persistence (v39.16.74) - Exported spells compactly include non-debug achievement unlocks, unlock times, and persistent progress. - Loading a spell merges achievements and keeps the greater progress instead of erasing local achievement data. - Restored unlocks are queued for the existing server recovery synchronization. Save slots remain world-only. -- Spell achievement metadata uses a sparse binary encoding: raw unlock bits, bit-packed persistent progress, minute-delta unlock timestamps, and sparse per-entity state. Legacy JSON-metadata spells remain readable. +- Spell achievement metadata uses a sparse binary encoding: raw unlock bits, bit-packed persistent progress, minute-delta unlock timestamps, and sparse per-entity state. Only the current schema-49 save/spell format and achievement payload v5 are accepted. - v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements. +- v39.16.62: Added five achievements for low FPS, uninterrupted 24-hour play, toilet-sand placement, park-ground editing, and rapid ground changes. 寿命 deaths are excluded from 安全第一 failures, empty-field clicks replace pause-button clicks for 何やってるの?, and the two long observation achievements are last in その他. + +- v39.16.63: Added アリ観察 and 命のバトン; stabilized progress-row typography; continuous-play observation no longer resets on tab changes; corrected sauna wording and enemy-ant streak reset behavior; obsolete spell formats are rejected. diff --git a/README.md b/README.md index 607a6ae..b31a919 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; - v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button. - v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field. - v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves. -- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.60`. +- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.74`. - v39.16.31 changes: all in-world tooltips use the closer offset; Hair Trigger adds a central bounce-fence ball enclosure with Tarinai and pet-food duplicators outside; physics tools use distinct icons. - v39.16.29 changes: parasol placement preview now uses the exact shade center and footprint; toilet sand absorbs rain-created water drops. Happy adds parasols, water balloons, and toilet sand beneath each duplicator; Athletic uses fence-safe single-bar rotators and water balloons; War adds rotating fans. - v39.16.19 changes: food and medicine can be given directly by placement or pinched drop, with a dedicated overlay, food heart bursts, and stronger personality effects; unused collision reasons and duplicate constraint projection dirty checks were removed. @@ -72,14 +72,14 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re - v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons. -## Achievement system (v39.16.60) -- Fifty-four local achievements cover biological events, special deaths, colony conditions, individual care, passive ecology, Undo/Redo behavior, rapid deletion, cleaner use, temperature transitions, medicine-category completion, weather shelter, combat reversals, and ant-related play styles. +## Achievement system (v39.16.74) +- Sixty-four local achievements cover biological events, special deaths, colony conditions, individual care, passive ecology, Undo/Redo behavior, rapid deletion, cleaner use, temperature transitions, medicine-category completion, weather shelter, combat reversals, circuit-board signaling, and ant-related play styles. - Locked entries show the configured pre-unlock disclosure directly in the row; all other locked entries show `???`. 大いなる母, めっちゃたりない観察, and 敵の敵は味方 show local progress percentages. Unlocked entries appear first within each category. - Each entry is a horizontal completion-rate bar whose background fill reflects the optional shared global unlock percentage; the shared achiever count appears beside the percentage. -- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく; both are event-driven and 町のお医者さん is disclosed before unlock. v39.16.56 added two achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars. v39.16.60 adds three achievements and restricts the chaos counter to fight-mochi-influenced fights. +- v39.16.64 adds 毎日たりない観察(7 calendar days) and 抵抗器じゃない(7 distinct tarinai shocked by one energized bare wire). v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく; both are event-driven and 町のお医者さん is disclosed before unlock. v39.16.56 added two achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars. v39.16.60 adds three achievements and restricts the chaos counter to fight-mochi-influenced fights. - Persistent totals include 物でいっぱい, 害虫駆除, 大いなる母(3333体), 無計画都市, やっぱなし, and やっぱあり. Field-local progress includes 餌やりじいさん, 清掃業者, 自給自足, and 敵の敵は味方; applicable field-local metadata follows current-format saves. - `自動化の第一歩` and `リンク職人` are retired and replaced by `機械化産業`, which requires both detector-driven activation and rope/rod/spring coverage. Existing players with both retired achievements migrate automatically. -- Optional same-origin PHP API aggregates anonymous player counts and global unlock percentages. Raw aggregate data is stored under `.achievement_data/state.json` in compact v2 arrays, with transparent reading/migration of the prior verbose format, and is protected by Apache rules. +- Optional same-origin PHP API aggregates anonymous player counts and global unlock percentages. Raw aggregate data is stored under `.achievement_data/state.json` in compact v3 positional arrays. Compact v2 and the prior verbose object are accepted only as migration inputs and are automatically rewritten under a separate server lock with atomic file replacement on the next API read. UUIDs are binary/base64url encoded, achievement IDs are indexed, timestamps are delta-coded, and repeated game versions use a dictionary. - On startup, shared-stat refresh, achievement-screen opening, and every five minutes, the client sends its complete non-debug unlock set with original local timestamps. Ordinary unlock bursts send only unsynchronized records after a short debounce. The server adds only missing records, so a lost `state.json` is rebuilt incrementally as players return without overwriting surviving records. ### Shared achievement percentage deployment @@ -87,6 +87,25 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re - PHP must be enabled, and `.achievement_data` must be writable by the PHP process. - On a static-only host, local unlocks still work; the global percentage is shown as unavailable. - Set `window.TARINAI_ACHIEVEMENT_API` before `achievements.js` to use a different same-origin endpoint. -- The current release tracks fifty-one achievement IDs, supports per-player reset, and accepts additive bulk synchronization for automatic recovery after aggregate-data loss. A separate backup remains necessary to recover players who never return. -- v39.16.60 changes: achievement metadata in spells is now encoded as sparse binary data instead of embedded JSON; unlock masks, progress, timestamps, per-entity counters, and placement metadata are compactly packed while legacy v39.16.57 spell data remains readable. +- The current release tracks sixty-four achievement IDs, supports per-player reset, and accepts additive bulk synchronization for automatic recovery after aggregate-data loss. A separate backup remains necessary to recover players who never return. +- `python3 scripts/achievement_server_state_audit.py` creates deterministic randomized achievement histories at runtime; it does not require or modify a production `state.json`. +- v39.16.60 changes: achievement metadata in spells is now encoded as sparse binary data instead of embedded JSON; unlock masks, progress, timestamps, per-entity counters, and placement metadata are compactly packed and spell data is accepted only from the current format. - v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements. +- v39.16.62: Added スペックがたりない, 寝ろ, 砂場, そこ公共空間だよ?, and 破格の工事費用; 寿命 deaths no longer reset 安全第一; 何やってるの? now uses empty-field clicks; and save schema 44 expands the achievement mask while decoding schemas 41–43. + +- v39.16.63: Fixed achievement progress text weight and continuous-play tab switching; added アリ観察 and 命のバトン; corrected サウナ水風呂 and 敵の敵は味方 condition mismatches; save/spell data accepts only schema 45 and achievement payload v4. +- v39.16.64: Added 毎日たりない観察 and 抵抗器じゃない; introduced calendar-day streak persistence; and migrated server aggregate JSON to compact v3 positional data automatically on read. +- v39.16.65: Hardened pre-deployment server migration with read-only summaries, POST-only mutations, corruption-safe decoding, atomic writes under a separate lock, completionist eligibility filtering, and seeded randomized/concurrent migration audits. + +- v39.16.66: Added two-user seesaw play, an editable AND/OR/NOT circuit board with positioned terminals, signal control for fan/stove/dry ice/duplicator, and wire-target highlighting. + +- v39.16.67: Replaced the circuit-board dropdown editor with direct grid placement and cursor-drawn wiring; changed colony-state evaluation to every two in-game hours and excluded growing populations from Weakened; fixed seesaw action selection, fixed its orientation, and matched the placement overlay to its shape. +- v39.16.69: Reworked circuit-board wiring into straight grid segments with branches and multi-connect terminals; removed OR and added a rectifier; made internal parts rotatable and external terminal count/positions editable; expanded board terminal spacing; changed detector numeric conditions to min/max dual sliders with number inputs; and raised seesaw riders slightly above the seats. +- v39.16.68: Fixed seesaw participation so idle/bored Tarinai can initiate play and a waiting rider strongly recruits a partner; selected data reports active play as 「シーソーをしている。」; moved seesaws behind sprite layers; and aligned all two-input circuit gate pins to the 40px wiring grid. +- v39.16.70: Reduced the default circuit-board footprint to one quarter of the previous area; made grid width/height editable; made external terminals movable on all four edges with a height-dependent count limit; made branch junctions movable; reduced NOT/rectifier body sizes; and corrected seesaw action-reason text. + +- v39.16.71: Replaced circuit-board terminal +/- controls with direct IN/OUT terminal placement and erasing, dimmed terminals without external world wires, removed generic elliptical placement previews, added an Electrical tool category for detector/wires/circuit board, and made detector range-track clicks move the nearest thumb. + +- v39.16.72: Circuit boards now default to 10x10 with a 20x20 maximum, AND uses a two-cell height, compact gates can sit closer to top/bottom edges, placement previews follow occupancy footprints more directly, and unstable internal logic loops are explicitly forced OFF. +- v39.16.73: Circuit-board editing now shows placement ghosts for internal parts and terminals, Q/E rotates the pending internal part by 90 degrees, ON constant sources are explained in the editor, and wire/insulated-wire palette silhouettes render correctly after the Electrical category move. +- v39.16.74: Removed the circuit-board OFF constant part and added the 「情報化産業」 achievement for carrying an active signal directly from one circuit board to another over wire or insulated wire. diff --git a/achievement_api.php b/achievement_api.php index d6dc995..32f9d88 100644 --- a/achievement_api.php +++ b/achievement_api.php @@ -60,9 +60,22 @@ const ACHIEVEMENT_IDS = [ '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', + 'information_industry', ]; const DATA_DIR_NAME = '.achievement_data'; const DATA_FILE_NAME = 'state.json'; +const LOCK_FILE_NAME = 'state.lock'; +const STATE_SCHEMA_VERSION = 3; +const MAX_REQUEST_BYTES = 131072; function respond(array $payload, int $status = 200): never { http_response_code($status); @@ -78,16 +91,83 @@ function empty_state(): array { return ['players' => [], 'unlocks' => []]; } +function compact_player_id(string $value): string { + $hex = str_replace('-', '', strtolower($value)); + if (strlen($hex) !== 32 || !ctype_xdigit($hex)) return ''; + $binary = hex2bin($hex); + if ($binary === false) return ''; + return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); +} + +function expand_player_id(string $value): string { + if (strlen($value) !== 22 || preg_match('/^[A-Za-z0-9_-]{22}$/', $value) !== 1) return ''; + $binary = base64_decode(strtr($value, '-_', '+/') . '==', true); + if ($binary === false || strlen($binary) !== 16) return ''; + $hex = bin2hex($binary); + return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); +} + +function detect_state_format(mixed $value): string { + if (!is_array($value)) return 'invalid'; + if ($value === []) return 'empty'; + if (array_is_list($value)) { + return (($value[0] ?? null) === STATE_SCHEMA_VERSION + && isset($value[1], $value[2], $value[3], $value[4]) + && is_array($value[2]) && is_array($value[3]) && is_array($value[4])) + ? 'v3' : 'invalid'; + } + if (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) { + return 'v2'; + } + if (array_key_exists('players', $value) || array_key_exists('unlocks', $value)) return 'verbose'; + return 'invalid'; +} + function normalize_state(mixed $value): array { if (!is_array($value)) return empty_state(); $players = []; $unlocks = []; - if (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) { + // v3 positional form: [version, baseTime, versions, players, unlocks]. + if (($value[0] ?? null) === STATE_SCHEMA_VERSION && isset($value[1], $value[2], $value[3], $value[4]) + && is_array($value[2]) && is_array($value[3]) && is_array($value[4])) { + $baseTime = max(1, (int)$value[1]); + $versions = array_map(static fn($entry): string => substr((string)$entry, 0, 32), $value[2]); + $playerIds = []; + foreach ($value[3] as $row) { + if (!is_array($row) || !isset($row[0]) || !is_string($row[0])) continue; + $id = expand_player_id($row[0]); + if (!valid_player_id($id)) continue; + $firstSeen = $baseTime + max(0, (int)($row[1] ?? 0)); + $lastSeen = $firstSeen + max(0, (int)($row[2] ?? 0)); + $versionIndex = max(0, (int)($row[3] ?? 0)); + $playerIds[] = $id; + $players[$id] = [ + 'firstSeen' => max(1, $firstSeen), + 'lastSeen' => max(1, $lastSeen), + 'gameVersion' => $versions[$versionIndex] ?? '', + ]; + } + foreach ($value[4] as $row) { + if (!is_array($row) || count($row) < 3) continue; + $achievementIndex = (int)$row[0]; + $achievementId = ACHIEVEMENT_IDS[$achievementIndex] ?? ''; + if ($achievementId === '') continue; + $records = []; + $count = count($row); + for ($i = 1; $i + 1 < $count; $i += 2) { + $playerIndex = (int)$row[$i]; + if (!isset($playerIds[$playerIndex])) continue; + $records[$playerIds[$playerIndex]] = $baseTime + max(0, (int)$row[$i + 1]); + } + if ($records) $unlocks[$achievementId] = $records; + } + // v2 is an on-read migration source and is always rewritten as v3. + } elseif (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) { $playerIds = []; foreach ($value['p'] as $row) { if (!is_array($row) || !isset($row[0]) || !is_string($row[0]) || !valid_player_id($row[0])) continue; - $id = $row[0]; + $id = strtolower($row[0]); $playerIds[] = $id; $players[$id] = [ 'firstSeen' => max(1, (int)($row[1] ?? 1)), @@ -96,7 +176,7 @@ function normalize_state(mixed $value): array { ]; } foreach ($value['u'] as $achievementId => $flat) { - if (!is_string($achievementId) || !is_array($flat)) continue; + if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($flat)) continue; $records = []; $count = count($flat); for ($i = 0; $i + 1 < $count; $i += 2) { @@ -107,8 +187,24 @@ function normalize_state(mixed $value): array { if ($records) $unlocks[$achievementId] = $records; } } else { - $players = isset($value['players']) && is_array($value['players']) ? $value['players'] : []; - $unlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : []; + $rawPlayers = isset($value['players']) && is_array($value['players']) ? $value['players'] : []; + foreach ($rawPlayers as $id => $player) { + if (!is_string($id) || !valid_player_id($id) || !is_array($player)) continue; + $players[strtolower($id)] = [ + 'firstSeen' => max(1, (int)($player['firstSeen'] ?? 1)), + 'lastSeen' => max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))), + 'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32), + ]; + } + $rawUnlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : []; + foreach ($rawUnlocks as $achievementId => $records) { + if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($records)) continue; + foreach ($records as $id => $timestamp) { + $normalizedId = strtolower((string)$id); + if (!isset($players[$normalizedId])) continue; + $unlocks[$achievementId][$normalizedId] = max(1, (int)$timestamp); + } + } } $legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first']) @@ -133,30 +229,112 @@ function compact_state(array $state): array { $playerIds = array_keys($state['players']); sort($playerIds, SORT_STRING); $playerIndex = []; + $timestamps = []; + $versionCounts = []; + foreach ($playerIds as $id) { + $player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : []; + $timestamps[] = max(1, (int)($player['firstSeen'] ?? 1)); + $timestamps[] = max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))); + $version = substr((string)($player['gameVersion'] ?? ''), 0, 32); + $versionCounts[$version] = ($versionCounts[$version] ?? 0) + 1; + } + foreach ($state['unlocks'] as $achievementId => $records) { + if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue; + foreach ($records as $timestamp) $timestamps[] = max(1, (int)$timestamp); + } + $baseTime = $timestamps ? min($timestamps) : 1; + uksort($versionCounts, static function (string $a, string $b) use ($versionCounts): int { + $countOrder = ($versionCounts[$b] ?? 0) <=> ($versionCounts[$a] ?? 0); + return $countOrder !== 0 ? $countOrder : strcmp($a, $b); + }); + $versions = array_keys($versionCounts); + if (!$versions) $versions = ['']; + $versionIndex = array_flip($versions); + $players = []; foreach ($playerIds as $index => $id) { $playerIndex[$id] = $index; $player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : []; - $players[] = [ - $id, - max(1, (int)($player['firstSeen'] ?? 1)), - max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))), - substr((string)($player['gameVersion'] ?? ''), 0, 32), - ]; + $firstSeen = max(1, (int)($player['firstSeen'] ?? 1)); + $lastSeen = max($firstSeen, (int)($player['lastSeen'] ?? $firstSeen)); + $version = substr((string)($player['gameVersion'] ?? ''), 0, 32); + $row = [compact_player_id($id), $firstSeen - $baseTime, $lastSeen - $firstSeen]; + $indexValue = (int)($versionIndex[$version] ?? 0); + if ($indexValue !== 0) $row[] = $indexValue; + $players[] = $row; } + $unlocks = []; - foreach ($state['unlocks'] as $achievementId => $records) { - if (!is_string($achievementId) || !is_array($records) || !$records) continue; - $flat = []; + foreach (ACHIEVEMENT_IDS as $achievementIndex => $achievementId) { + $records = $state['unlocks'][$achievementId] ?? null; + if (!is_array($records) || !$records) continue; + $pairs = []; foreach ($records as $id => $timestamp) { if (!isset($playerIndex[$id])) continue; - $flat[] = $playerIndex[$id]; - $flat[] = max(1, (int)$timestamp); + $pairs[] = [$playerIndex[$id], max(1, (int)$timestamp) - $baseTime]; } - if ($flat) $unlocks[$achievementId] = $flat; + usort($pairs, static fn(array $a, array $b): int => $a[0] <=> $b[0]); + if (!$pairs) continue; + $row = [$achievementIndex]; + foreach ($pairs as [$indexValue, $timestampDelta]) { + $row[] = $indexValue; + $row[] = max(0, $timestampDelta); + } + $unlocks[] = $row; + } + return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks]; +} + +function encode_compact_state(array $state): string { + try { + return json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new RuntimeException('storage_encode_failed', 0, $error); + } +} + +function decode_stored_state(string $contents): array { + if (trim($contents) === '') return [empty_state(), 'empty']; + try { + $decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new RuntimeException('storage_corrupt', 0, $error); + } + $format = detect_state_format($decoded); + if ($format === 'invalid') throw new RuntimeException('storage_corrupt'); + return [normalize_state($decoded), $format]; +} + +function atomic_write_state(string $dataPath, string $encoded): void { + $dataDir = dirname($dataPath); + $tempPath = tempnam($dataDir, DATA_FILE_NAME . '.tmp.'); + if ($tempPath === false) throw new RuntimeException('storage_unavailable'); + $handle = @fopen($tempPath, 'wb'); + if ($handle === false) { + @unlink($tempPath); + throw new RuntimeException('storage_unavailable'); + } + try { + $length = strlen($encoded); + $offset = 0; + while ($offset < $length) { + $written = fwrite($handle, substr($encoded, $offset)); + if ($written === false || $written === 0) throw new RuntimeException('storage_write_failed'); + $offset += $written; + } + if (!fflush($handle)) throw new RuntimeException('storage_write_failed'); + if (function_exists('fsync') && !fsync($handle)) throw new RuntimeException('storage_write_failed'); + } catch (Throwable $error) { + fclose($handle); + @unlink($tempPath); + throw $error; + } + fclose($handle); + @chmod($tempPath, 0664); + if (!@rename($tempPath, $dataPath)) { + @unlink($tempPath); + throw new RuntimeException('storage_write_failed'); } - ksort($unlocks, SORT_STRING); - return ['v' => 2, 'p' => $players, 'u' => $unlocks]; } function build_summary(array $state): array { @@ -180,10 +358,19 @@ function build_summary(array $state): array { } $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); +if (!in_array($method, ['GET', 'POST'], true)) respond(['ok' => false, 'error' => 'method_not_allowed'], 405); + $payload = []; if ($method === 'POST') { - $raw = file_get_contents('php://input'); - $decoded = json_decode(is_string($raw) ? $raw : '', true); + $declaredLength = max(0, (int)($_SERVER['CONTENT_LENGTH'] ?? 0)); + if ($declaredLength > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413); + $raw = file_get_contents('php://input', false, null, 0, MAX_REQUEST_BYTES + 1); + if (!is_string($raw) || strlen($raw) > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413); + try { + $decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); + } catch (JsonException) { + respond(['ok' => false, 'error' => 'invalid_json'], 400); + } if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400); $payload = $decoded; } @@ -198,9 +385,13 @@ $playerId = $method === 'POST' if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) { respond(['ok' => false, 'error' => 'invalid_action'], 400); } +if ($method === 'GET' && $action !== 'summary') { + respond(['ok' => false, 'error' => 'method_not_allowed'], 405); +} if (!valid_player_id($playerId)) { respond(['ok' => false, 'error' => 'invalid_player'], 400); } +$playerId = strtolower($playerId); $syncUnlocks = []; $completionistEligible = null; if ($action === 'unlock') { @@ -227,11 +418,12 @@ if ($action === 'unlock') { respond(['ok' => false, 'error' => 'invalid_timestamp'], 400); } $numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT); - if ($numericTimestamp === false || $numericTimestamp <= 0) { + if ($numericTimestamp === false || !is_finite((float)$numericTimestamp) || $numericTimestamp <= 0) { respond(['ok' => false, 'error' => 'invalid_timestamp'], 400); } $syncUnlocks[$achievementId] = (float)$numericTimestamp; } + if ($completionistEligible === false) unset($syncUnlocks['true_tarinai_observer']); } $dataDir = __DIR__ . DIRECTORY_SEPARATOR . DATA_DIR_NAME; @@ -239,23 +431,31 @@ if (!is_dir($dataDir) && !mkdir($dataDir, 0775, true) && !is_dir($dataDir)) { respond(['ok' => false, 'error' => 'storage_unavailable'], 503); } $dataPath = $dataDir . DIRECTORY_SEPARATOR . DATA_FILE_NAME; -$handle = fopen($dataPath, 'c+'); -if ($handle === false || !flock($handle, LOCK_EX)) { - if (is_resource($handle)) fclose($handle); +$lockPath = $dataDir . DIRECTORY_SEPARATOR . LOCK_FILE_NAME; +$lockHandle = @fopen($lockPath, 'c'); +if ($lockHandle === false || !flock($lockHandle, LOCK_EX)) { + if (is_resource($lockHandle)) fclose($lockHandle); respond(['ok' => false, 'error' => 'storage_locked'], 503); } +$summary = null; +$storageError = null; try { - rewind($handle); - $contents = stream_get_contents($handle); - $state = normalize_state(json_decode(is_string($contents) ? $contents : '', true)); + $contents = is_file($dataPath) ? file_get_contents($dataPath) : ''; + if ($contents === false) throw new RuntimeException('storage_unavailable'); + [$state, $sourceFormat] = decode_stored_state($contents); $now = time(); - $player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId]) - ? $state['players'][$playerId] - : ['firstSeen' => $now]; - $player['lastSeen'] = $now; - $player['gameVersion'] = substr((string)($payload['gameVersion'] ?? ''), 0, 32); - $state['players'][$playerId] = $player; + $mutatesPlayer = $action !== 'summary'; + + if ($mutatesPlayer) { + $player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId]) + ? $state['players'][$playerId] + : ['firstSeen' => $now]; + $player['lastSeen'] = $now; + $submittedGameVersion = substr((string)($payload['gameVersion'] ?? ''), 0, 32); + if ($submittedGameVersion !== '' || !isset($player['gameVersion'])) $player['gameVersion'] = $submittedGameVersion; + $state['players'][$playerId] = $player; + } if ($action === 'unlock') { $achievementId = (string)$payload['achievementId']; @@ -285,14 +485,18 @@ try { } } - rewind($handle); - ftruncate($handle, 0); - fwrite($handle, json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); - fflush($handle); + $encoded = encode_compact_state($state); + if (trim($contents) !== $encoded || $sourceFormat !== 'v3' || $mutatesPlayer) { + atomic_write_state($dataPath, $encoded); + } $summary = build_summary($state); +} catch (RuntimeException $error) { + $known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed']; + $storageError = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable'; } finally { - flock($handle, LOCK_UN); - fclose($handle); + flock($lockHandle, LOCK_UN); + fclose($lockHandle); } -respond($summary); +if ($storageError !== null) respond(['ok' => false, 'error' => $storageError], 503); +respond(is_array($summary) ? $summary : ['ok' => false, 'error' => 'storage_unavailable'], is_array($summary) ? 200 : 503); diff --git a/app_manifest.json b/app_manifest.json index 02cf606..c4b33a6 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.16.60", + "version": "39.16.74", "css": [ "css/base.css", "css/layout.css", @@ -36,6 +36,7 @@ "js/constraint_system.js", "js/physics_world_system.js", "js/physics_projection_system.js", + "js/circuit_board_system.js", "js/signal_system.js", "js/assets.js", "js/audio.js", @@ -88,6 +89,7 @@ "js/tarinai_consumable_behavior.js", "js/tarinai_social_action_runtime.js", "js/tarinai_building_behavior.js", + "js/seesaw_system.js", "js/tarinai_action_definitions.js", "js/tarinai_needs_items.js", "js/tarinai_food_prototype_mixin.js", @@ -147,8 +149,8 @@ "js/ui.js", "js/game_dialogs.js", "js/achievements.js", - "js/ui_log.js", "js/ui_helpers.js", + "js/ui_log.js", "js/ui_selected.js", "js/save_schema.js", "js/snapshot_system.js", diff --git a/css/base.css b/css/base.css index cb70d5f..91b9d49 100644 --- a/css/base.css +++ b/css/base.css @@ -917,3 +917,125 @@ h1 { .save-confirm-dialog { z-index: 36; } .save-confirm-panel { width: min(390px, 100%); } .save-confirm-message { margin-bottom: 8px; color: var(--ink); font-size: 14px; } + +/* Logic-board editor */ +.circuit-board-editor{position:fixed;inset:0;z-index:1200;display:grid;place-items:center;padding:18px;background:rgba(18,22,27,.58);backdrop-filter:blur(3px)} +.circuit-board-editor.hidden{display:none} +.circuit-board-panel{width:min(980px,96vw);max-height:92vh;overflow:hidden;background:rgba(250,249,244,.98);color:#2e3531;border:1px solid rgba(44,74,57,.35);border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.35);display:flex;flex-direction:column} +.circuit-board-titlebar{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;background:linear-gradient(135deg,#235b3a,#347b50);color:white} +.circuit-board-titlebar h2{margin:0;font-size:1.18rem} +.circuit-board-titlebar button{border:0;background:transparent;color:white;font-size:1.7rem;line-height:1;cursor:pointer} +.circuit-board-help{margin:12px 18px 6px;font-size:.88rem;color:#536059} +.circuit-board-layout{display:grid;grid-template-columns:minmax(280px,42%) minmax(360px,58%);gap:14px;padding:10px 18px;min-height:0;flex:1} +.circuit-board-preview{align-self:start;background:#17231c;border-radius:14px;padding:10px;min-height:250px;position:sticky;top:0} +.circuit-board-preview svg{width:100%;height:auto;display:block} +.circuit-preview-lines path{fill:none;stroke:#d7b84d;stroke-width:2.4} +.circuit-preview-gates rect{fill:#26352d;stroke:#d9e9dd;stroke-width:1.3}.circuit-preview-gates text{fill:#f4f7f2;text-anchor:middle;font:700 11px sans-serif} +.circuit-preview-pins circle{fill:#73c8ff;stroke:#f4f1d6;stroke-width:1.2}.circuit-preview-pins circle:nth-of-type(n+4){fill:#ff9a60}.circuit-preview-pins text{fill:#eef4ef;font:700 11px sans-serif} +.circuit-board-scroll{overflow:auto;padding-right:4px}.circuit-board-scroll section{margin:0 0 14px;padding:12px;border:1px solid rgba(45,83,60,.18);border-radius:12px;background:white}.circuit-board-scroll h3{margin:0 0 10px;font-size:.94rem} +.circuit-port-row,.circuit-gate-row,.circuit-output-row{display:grid;grid-template-columns:minmax(72px,1fr) auto auto auto;gap:8px;align-items:center;padding:7px 0;border-bottom:1px solid rgba(0,0,0,.07)} +.circuit-port-row:last-child,.circuit-gate-row:last-child,.circuit-output-row:last-child{border-bottom:0}.circuit-port-row label,.circuit-gate-row label{display:flex;align-items:center;gap:4px;font-size:.8rem} +.circuit-board-panel select,.circuit-board-panel button{font:inherit}.circuit-board-panel select{padding:5px 7px;border:1px solid rgba(37,77,53,.27);border-radius:7px;background:white} +.circuit-gate-actions{display:flex;gap:7px;margin-bottom:8px}.circuit-gate-actions button,.circuit-gate-row button,.circuit-board-actions button{padding:7px 12px;border:1px solid rgba(38,82,54,.26);border-radius:9px;background:#f1f5f1;cursor:pointer}.circuit-gate-row button{padding:4px 9px;color:#a13f3f} +.circuit-empty{margin:6px 0;color:#7a837d;font-size:.84rem}.circuit-output-row{grid-template-columns:50px 1fr}.circuit-board-actions{display:flex;justify-content:flex-end;gap:9px;padding:12px 18px 16px;border-top:1px solid rgba(0,0,0,.09)}.circuit-board-actions .primary{background:#2e754b;color:white;border-color:#245f3d} +@media(max-width:760px){.circuit-board-layout{grid-template-columns:1fr}.circuit-board-preview{position:relative;min-height:180px}.circuit-port-row,.circuit-gate-row{grid-template-columns:1fr 1fr}.circuit-gate-row button{justify-self:end}.circuit-board-panel{max-height:96vh}} + +/* Grid circuit editor reuses the mechanical editor shell and toolbar. */ +.circuit-grid-panel { width: min(930px, calc(100vw - 28px)); } +.circuit-grid-tools { max-height: none; overflow: visible; } +.circuit-grid-canvas-wrap { + max-width: 100%; + max-height: min(62vh, 560px); + overflow: auto; + padding: 6px; + border-radius: 12px; + background: rgba(20,35,27,0.35); +} +#circuitGridCanvas { + width: auto; + height: auto; + max-width: none; + display: block; + border-radius: 10px; + border: 1px solid rgba(84,68,48,0.20); + background: #14231b; + touch-action: none; + cursor: crosshair; +} +.circuit-grid-hint { line-height: 1.55; } +@media (max-width: 560px) { + .circuit-grid-panel { width: calc(100vw - 16px); } + .circuit-grid-tools { max-height: 20vh; overflow-y: auto; } + .circuit-grid-canvas-wrap { max-height: min(48vh, 360px); } + #circuitGridCanvas { max-width: none; } +} + +/* v39.16.69: circuit terminal count controls and detector numeric range inputs. */ +.circuit-port-count-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 0 0 10px; +} +.circuit-port-count-controls label { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 9px; + border: 1px solid rgba(84,68,48,0.14); + border-radius: 10px; + background: rgba(255,255,255,0.56); + font-size: 13px; + font-weight: 700; +} +.circuit-port-count-controls button { + width: 30px; + height: 30px; + padding: 0; + border: 1px solid rgba(38,82,54,.26); + border-radius: 8px; + background: #f1f5f1; + cursor: pointer; +} +.circuit-port-count-controls input { + width: 54px; + min-height: 30px; + padding: 3px 6px; + border: 1px solid rgba(37,77,53,.27); + border-radius: 7px; + background: white; + text-align: center; + font: inherit; + font-variant-numeric: tabular-nums; +} +.pressure-number-inputs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.pressure-number-inputs label { + display: grid; + grid-template-columns: auto minmax(72px, 1fr); + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 700; +} +.pressure-number-inputs input { + width: 100%; + min-width: 0; + padding: 6px 8px; + border: 1px solid rgba(42,121,172,0.28); + border-radius: 8px; + background: rgba(255,255,255,0.92); + color: var(--ink); + font: inherit; + font-variant-numeric: tabular-nums; +} +@media (max-width: 560px) { + .circuit-port-count-controls { display: grid; grid-template-columns: 1fr; } + .circuit-port-count-controls label { justify-content: space-between; } +} + +/* v39.16.70: resizable circuit board and movable junctions. */ +.circuit-port-count-controls label:nth-child(-n+2){background:rgba(236,246,239,.82)} diff --git a/css/components.css b/css/components.css index 88b122b..f7c70ef 100644 --- a/css/components.css +++ b/css/components.css @@ -1128,7 +1128,7 @@ margin-top: 4px; color: #526b82; font-size: 10px; - font-weight: 800; + font-weight: 500; font-variant-numeric: tabular-nums; } .achievement-entry-meta { diff --git a/index.html b/index.html index 2cab199..53b01dc 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ たりない観察 - - - - - - - - + + + + + + + +
@@ -42,7 +42,7 @@ - +
@@ -202,7 +202,7 @@
たりない観察
-

実績 0 / 51

+

実績 0 / 64

@@ -292,171 +292,173 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/achievements.js b/js/achievements.js index 17c961b..1639ed9 100644 --- a/js/achievements.js +++ b/js/achievements.js @@ -4,24 +4,26 @@ const STORAGE_KEY = "tarinai_achievements_v2"; const PLAYER_KEY = "tarinai_achievement_player_v1"; const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php"); - const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.60"); + const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.74"); const DEBUG_MODE = ["1", "true"].includes(new URLSearchParams(global.location?.search || "").get("debug")); const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const ABSOLUTE_ZERO_C = -273.15; const CONTINUOUS_PLAY_TARGET_MS = 60 * 60 * 1000; + const CONTINUOUS_PLAY_24H_TARGET_MS = 24 * 60 * 60 * 1000; const HELD_TARGET_MS = 30 * 1000; const COMPLETIONIST_ID = "true_tarinai_observer"; const GENERATION_TARGET = 10; const CHAOS_FIGHT_TARGET = 666; const SNIPER_SHOT_TARGET = 333; const FERTILITY_BIRTH_TARGET = 721; + const DAILY_PLAY_TARGET = 7; + const WIRE_SHOCK_TARGET = 7; + const DAY_MS = 24 * 60 * 60 * 1000; const ROBOT_CLEAN_TARGET = 200; const ROBOT_ALL_TARGET_MASK = 0x3f; const SYNC_DEBOUNCE_MS = 450; const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000; - const SPELL_STATE_VERSION = 3; - const PREVIOUS_SPELL_STATE_VERSION = 2; - const LEGACY_SPELL_STATE_VERSION = 1; + const SPELL_STATE_VERSION = 5; const MEDICINE_LEDGER_TYPES = Object.freeze(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]); const DIRECT_TREATMENT_TYPES = new Set(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]); const PRE_UNLOCK_DESCRIPTION_IDS = new Set([ @@ -41,6 +43,8 @@ "ants_alive_25", "great_mother_1000_births", "continuous_play_1_hour", + "low_fps_single_digit", + "continuous_play_24_hours", "robot_cleaner_100", "mercury_lifespan", "enemy_enemy_friend", @@ -48,6 +52,7 @@ "town_doctor_50", "chaos_seeker_666_fights", "fertility_seeker_721_love_births", + "daily_play_7_days", ]); const ACHIEVEMENT_CATEGORIES = Object.freeze([ Object.freeze({ @@ -56,6 +61,7 @@ ids: Object.freeze([ "first_birth", "natural_zunchi_slave", "natural_tarinai_king", "natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations", + "ant_nest_without_tarinai", "birth_50_in_60_seconds", "great_mother_1000_births", "lifespan_completed", "all_non_sleep_diseased_25", "colony_happy", "direct_feed_33", "safe_colony_25_5_minutes", "ants_alive_25", "overprotective", @@ -72,15 +78,16 @@ "ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge", "medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend", "revolution", "fuel_to_fire", "king_full_satisfaction", "slave_zero_satisfaction", - "chaos_seeker_666_fights", + "chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai", ]), }), Object.freeze({ id: "construction", title: "\u5efa\u7bc9", ids: Object.freeze([ - "placed_objects_100", "mechanized_industry", "robot_cleaner_100", + "placed_objects_100", "mechanized_industry", "information_industry", "robot_cleaner_100", "minimalist_happy", "unplanned_city_30", "clean_freak_robot_only", "megalopolis", + "sandbox_five_toilets", "park_ground_changed", "ground_change_4_in_1_second", ]), }), Object.freeze({ @@ -88,8 +95,9 @@ title: "\u305d\u306e\u4ed6", ids: Object.freeze([ "idle_observer_5_minutes", "secret_collection_9_slots", "pause_spam_4_in_1_second", - "continuous_play_1_hour", "undo_mass_revival", "held_30_seconds", - "undo_20", "redo_20", "poke_plushie_fling", "true_tarinai_observer", "sniper_333_shots", + "undo_mass_revival", "held_30_seconds", "undo_20", "redo_20", + "poke_plushie_fling", "sniper_333_shots", "low_fps_single_digit", + "daily_play_7_days", "continuous_play_24_hours", "continuous_play_1_hour", "true_tarinai_observer", ]), }), ]); @@ -111,14 +119,14 @@ Object.freeze({ id: "direct_feed_33", title: "\u990c\u3084\u308a\u3058\u3044\u3055\u3093", description: "\u624b\u304b\u3089\u76f4\u63a533\u56de\u990c\u3092\u4e0e\u3048\u308b\u3002" }), Object.freeze({ id: "ignite_during_birth_ritual", title: "\u71c3\u3048\u4e0a\u304c\u308b\u604b", description: "\u305f\u308a\u306a\u3044\u304c\u30d8\u30b3\u30d8\u30b3\u4e2d\u306b\u7740\u706b\u3059\u308b\u3002" }), Object.freeze({ id: "laxative_starvation", title: "\u5185\u81d3\u5168\u90e8\u51fa\u305f", description: "\u4e0b\u5264\u306e\u52b9\u679c\u4e2d\u306b\u9913\u6b7b\u3059\u308b\u3002" }), - Object.freeze({ id: "idle_observer_5_minutes", title: "\u76e3\u8996\u30ab\u30e1\u30e9", description: "\u30b2\u30fc\u30e0\u51855\u5206\u9593\u3001\u914d\u7f6e\u30fb\u524a\u9664\u30fb\u3064\u3064\u304f\u30fb\u3064\u307e\u3080\u30fb\u5c04\u6483\u3092\u884c\u308f\u306a\u3044\u3002" }), + Object.freeze({ id: "idle_observer_5_minutes", title: "\u76e3\u8996\u30ab\u30e1\u30e9", description: "5\u5206\u9593\u753b\u9762\u3092\u898b\u308b\u3060\u3051" }), Object.freeze({ id: "placed_objects_100", title: "\u7269\u3067\u3044\u3063\u3071\u3044", description: "\u914d\u7f6e\u7269\u3092\u7d2f\u8a08100\u500b\u7f6e\u304f\u3002" }), Object.freeze({ id: "mechanized_industry", title: "\u6a5f\u68b0\u5316\u7523\u696d", description: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3067\u63a5\u7d9a\u5148\u3092\u4f5c\u52d5\u3055\u305b\u3001\u30ed\u30fc\u30d7\u30fb\u68d2\u30fb\u3070\u306d\u3092\u305d\u308c\u305e\u308c1\u672c\u4ee5\u4e0a\u8a2d\u7f6e\u3059\u308b\u3002" }), - Object.freeze({ id: "safe_colony_25_5_minutes", title: "\u5b89\u5168\u7b2c\u4e00", description: "25\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3092\u30b2\u30fc\u30e0\u51855\u5206\u9593\u7dad\u6301\u3057\u3001\u305d\u306e\u9593\u6b7b\u4ea1\u8005\u3092\u51fa\u3055\u306a\u3044\u3002" }), + Object.freeze({ id: "safe_colony_25_5_minutes", title: "\u5b89\u5168\u7b2c\u4e00", description: "25\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3092\u30b2\u30fc\u30e0\u51855\u5206\u9593\u7dad\u6301\u3057\u3001\u305d\u306e\u9593\u5bff\u547d\u4ee5\u5916\u3067\u6b7b\u4ea1\u8005\u3092\u51fa\u3055\u306a\u3044\u3002" }), Object.freeze({ id: "ants_alive_25", title: "\u30a2\u30ea\u306e\u60d1\u661f", description: "\u751f\u5b58\u3057\u3066\u3044\u308b\u30a2\u30ea\u304c25\u4f53\u306b\u5230\u9054\u3059\u308b\u3002" }), Object.freeze({ id: "ants_killed_100", title: "\u5bb3\u866b\u99c6\u9664", description: "\u30a2\u30ea\u3092\u7d2f\u8a08100\u4f53\u5012\u3059\u3002" }), Object.freeze({ id: "secret_collection_9_slots", title: "\u79d8\u5bc6\u306e\u30b3\u30ec\u30af\u30b7\u30e7\u30f3", description: "\u30bb\u30fc\u30d6\u30b9\u30ed\u30c3\u30c89\u3064\u3092\u3059\u3079\u3066\u57cb\u3081\u308b\u3002" }), - Object.freeze({ id: "pause_spam_4_in_1_second", title: "\u4f55\u3084\u3063\u3066\u308b\u306e\uff1f", description: "\u4e00\u6642\u505c\u6b62\u30dc\u30bf\u30f3\u30921\u79d2\u9593\u306b4\u56de\u9023\u6253\u3059\u308b\u3002" }), + Object.freeze({ id: "pause_spam_4_in_1_second", title: "\u4f55\u3084\u3063\u3066\u308b\u306e\uff1f", description: "\u4f55\u3082\u306a\u3044\u90e8\u5206\u30921\u79d2\u9593\u306b4\u56de\u9023\u6253\u3059\u308b\u3002" }), Object.freeze({ id: "continuous_play_1_hour", title: "\u3081\u3063\u3061\u3083\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "1\u6642\u9593\u9023\u7d9a\u30d7\u30ec\u30a4\u3059\u308b\u3002" }), Object.freeze({ id: "below_absolute_zero_item", title: "\u7269\u7406\u6cd5\u5247\u3092\u4e0b\u5074\u306b\u8d85\u8d8a", description: "\u6e29\u5ea6\u8a08\u7b97\u3055\u308c\u308b\u30a2\u30a4\u30c6\u30e0\u30921\u3064\u7d76\u5bfe\u96f6\u5ea6\u3092\u4e0b\u56de\u308b\u6e29\u5ea6\u306b\u3059\u308b\u3002" }), Object.freeze({ id: "undo_mass_revival", title: "\u898b\u306a\u304b\u3063\u305f\u3053\u3068\u306b\u3057\u3088\u3046", description: "\u4e00\u5ea6\u306eUndo\u306710\u4f53\u4ee5\u4e0a\u306e\u6b7b\u4ea1\u3092\u306a\u304b\u3063\u305f\u3053\u3068\u306b\u3059\u308b\u3002" }), @@ -128,7 +136,7 @@ Object.freeze({ id: "overprotective", title: "\u904e\u4fdd\u8b77", description: "\u540c\u3058\u305f\u308a\u306a\u3044\u306b\u624b\u304b\u308910\u56de\u990c\u3092\u4e0e\u3048\u30013\u56de\u4ee5\u4e0a\u85ac\u3067\u6cbb\u7642\u3059\u308b\u3002" }), Object.freeze({ id: "self_sufficient", title: "\u81ea\u7d66\u81ea\u8db3", description: "\u8907\u88fd\u6a5f\u304c\u306a\u3044\u72b6\u614b\u3067\u300120\u4f53\u4ee5\u4e0a\u3092\u76f4\u63a5\u7d66\u990c\u305b\u305a10\u5206\u9593\u7dad\u6301\u3059\u308b\u3002" }), Object.freeze({ id: "unplanned_city_30", title: "\u7121\u8a08\u753b\u90fd\u5e02", description: "\u914d\u7f6e\u5f8c30\u79d2\u4ee5\u5185\u306b\u524a\u9664\u3059\u308b\u64cd\u4f5c\u3092\u7d2f\u8a0830\u56de\u884c\u3046\u3002" }), - Object.freeze({ id: "sauna_cold_plunge", title: "\u30b5\u30a6\u30ca\u6c34\u98a8\u5442", description: "\u7d76\u5bfe\u96f6\u5ea6\u3092\u4e0b\u56de\u308b\u6e29\u5ea6\u3092\u4f5c\u308b\u3002" }), + Object.freeze({ id: "sauna_cold_plunge", title: "\u30b5\u30a6\u30ca\u6c34\u98a8\u5442", description: "\u6691\u3059\u304e\u308b\u72b6\u614b\u306b\u306a\u3063\u305f\u305f\u308a\u306a\u3044\u3092\u300115\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b\u72b6\u614b\u306b\u3059\u308b\u3002" }), Object.freeze({ id: "rain_shelter_all", title: "\u96e8\u5bbf\u308a\u5b8c\u4e86", description: "\u96e8\u5929\u6642\u300120\u4f53\u4ee5\u4e0a\u306e\u5168\u54e1\u3092\u30d1\u30e9\u30bd\u30eb\u30fb\u5de3\u7bb1\u30fb\u571f\u7ba1\u3067\u96e8\u5bbf\u308a\u3055\u305b\u308b\u3002" }), Object.freeze({ id: "medicine_ledger_all", title: "\u304a\u304f\u3059\u308a\u624b\u5e33\u5168\u57cb\u3081", description: "\u5168\u7a2e\u985e\u306e\u85ac\u3092\u5c11\u306a\u304f\u3068\u30821\u56de\u4f7f\u7528\u3059\u308b\u3002" }), Object.freeze({ id: "mercury_lifespan", title: "\u4e0d\u8001\u4e0d\u6b7b\u307e\u3067\u3042\u3068\u4e00\u6b69", description: "\u6c34\u9280\u3092\u4f7f\u7528\u3057\u305f\u305f\u308a\u306a\u3044\u304c\u5929\u5bff\u3092\u5168\u3046\u3059\u308b\u3002" }), @@ -148,6 +156,16 @@ Object.freeze({ id: "sniper_333_shots", title: "\u72d9\u6483\u624b", description: "\u7d2f\u8a08333\u767a\u6483\u3064\u3002" }), Object.freeze({ id: "megalopolis", title: "\u30e1\u30ac\u30ed\u30dd\u30ea\u30b9", description: "\u8907\u88fd\u6a5f\u309210\u500b\u4ee5\u4e0a\u3001\u5e72\u8349\u5bdd\u5e8a\u30fb\u5de3\u7bb1\u30fb\u571f\u7ba1\u3092\u5408\u8a0815\u500b\u4ee5\u4e0a\u8a2d\u7f6e\u3057\u3001\u305f\u308a\u306a\u3044\u3092100\u4f53\u4ee5\u4e0a\u751f\u5b58\u3055\u305b\u308b\u3002" }), Object.freeze({ id: "fertility_seeker_721_love_births", title: "\u8c4a\u7a63\u30f2\u5e0c\u6c42\u30b9\u30eb\u8005", description: "\u3078\u3053\u9905\u306e\u5f71\u97ff\u4e0b\u3067\u7e41\u6b96\u3092721\u56de\u6210\u529f\u3055\u305b\u308b\u3002" }), + Object.freeze({ id: "low_fps_single_digit", title: "\u30b9\u30da\u30c3\u30af\u304c\u305f\u308a\u306a\u3044", description: "fps\u30921\u6841\u306b\u3059\u308b\u3002" }), + Object.freeze({ id: "continuous_play_24_hours", title: "\u5bdd\u308d", description: "24\u6642\u9593\u9023\u7d9a\u3067\u30d7\u30ec\u30a4\u3059\u308b\u3002" }), + Object.freeze({ id: "sandbox_five_toilets", title: "\u7802\u5834", description: "\u30c8\u30a4\u30ec\u306e\u7802\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u306b5\u500b\u8a2d\u7f6e\u3059\u308b\u3002" }), + Object.freeze({ id: "park_ground_changed", title: "\u305d\u3053\u516c\u5171\u7a7a\u9593\u3060\u3088\uff1f", description: "\u516c\u5712\u306e\u5730\u9762\u3092\u52dd\u624b\u306b\u5909\u66f4\u3059\u308b\u3002" }), + Object.freeze({ id: "ground_change_4_in_1_second", title: "\u7834\u683c\u306e\u5de5\u4e8b\u8cbb\u7528", description: "\u5730\u9762\u3092\u5909\u3048\u3059\u304e\u3066\u7834\u7523\u3059\u308b\u3002" }), + Object.freeze({ id: "ant_nest_without_tarinai", title: "\u30a2\u30ea\u89b3\u5bdf", description: "\u30a2\u30ea\u306e\u5de3\u304c\u3042\u308b\u304c\u3001\u305f\u308a\u306a\u3044\u304c\u3044\u306a\u3044\u3002" }), + Object.freeze({ id: "sticky_bomb_15_passes", title: "\u547d\u306e\u30d0\u30c8\u30f3", description: "\u7c98\u7740\u30dc\u30e0\u3092\u7206\u767a\u307e\u3067\u306b15\u56de\u4ee5\u4e0a\u30d1\u30b9\u3059\u308b\u3002" }), + Object.freeze({ id: "daily_play_7_days", title: "\u6bce\u65e5\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "7\u65e5\u9023\u7d9a\u3067\u30d7\u30ec\u30a4\u3059\u308b\u3002" }), + Object.freeze({ id: "wire_shock_7_tarinai", title: "\u62b5\u6297\u5668\u3058\u3083\u306a\u3044", description: "\u96fb\u6d41\u304c\u6d41\u308c\u3066\u3044\u308b1\u672c\u306e\u96fb\u7dda\u30677\u4f53\u306e\u305f\u308a\u306a\u3044\u3092\u611f\u96fb\u3055\u305b\u308b\u3002" }), + Object.freeze({ id: "information_industry", title: "\u60c5\u5831\u5316\u7523\u696d", description: "\u57fa\u76e4\u540c\u58eb\u3092\u96fb\u7dda\u985e\u3067\u3064\u306a\u304e\u3001\u4fe1\u53f7\u3092\u6d41\u3059\u3002" }), ]); const definitionById = new Map(DEFINITIONS.map(def => [def.id, def])); const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [ @@ -177,17 +195,19 @@ let requestSerial = 0; let runtimeByWorld = new WeakMap(); let dangerFightDeathsByWorld = new WeakMap(); + let wireShockTrackers = new WeakMap(); let syncQueue = Promise.resolve(); let toastQueue = []; let toastActive = false; - let pauseClickTimes = []; - let continuousPlayStartedAt = document.hidden ? 0 : Date.now(); + let emptyClickTimes = []; + let groundChangeTimes = []; + let continuousPlayStartedAt = Date.now(); let continuousPlayLastHeartbeatAt = continuousPlayStartedAt; let lastListSignature = ""; let scheduledSyncTimer = 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 } }; + 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 } }; } function loadState() { @@ -235,6 +255,8 @@ fightMochiFightCount: Math.max(0, Math.floor(Number(progressSource.fightMochiFightCount || 0) || 0)), shotCount: Math.max(0, Math.floor(Number(progressSource.shotCount || 0) || 0)), loveMochiBirthCount: Math.max(0, Math.floor(Number(progressSource.loveMochiBirthCount || 0) || 0)), + dailyPlayStreak: Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(progressSource.dailyPlayStreak || 0) || 0))), + dailyPlayLastDay: Math.max(0, Math.floor(Number(progressSource.dailyPlayLastDay || 0) || 0)), }; const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]); if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) { @@ -316,10 +338,9 @@ } function continuousPlayElapsedMs(now = Date.now()) { - if (state.unlocked.continuous_play_1_hour) return CONTINUOUS_PLAY_TARGET_MS; + if (state.unlocked.continuous_play_24_hours) return CONTINUOUS_PLAY_24H_TARGET_MS; const timestamp = Number(now); - if (document.hidden || !continuousPlayStartedAt || !Number.isFinite(timestamp)) return 0; - if (continuousPlayLastHeartbeatAt && timestamp - continuousPlayLastHeartbeatAt > 15000) return 0; + if (!continuousPlayStartedAt || !Number.isFinite(timestamp)) return 0; return Math.max(0, timestamp - continuousPlayStartedAt); } @@ -351,10 +372,18 @@ const count = state.unlocked[id] ? target : Math.min(target, Math.max(0, Math.floor(Number(state.progress?.loveMochiBirthCount || 0) || 0))); return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / target * 100).toFixed(1)}%\uff08${count} / ${target}\u56de\uff09`; } + if (id === "daily_play_7_days") { + const count = state.unlocked[id] ? DAILY_PLAY_TARGET : Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress?.dailyPlayStreak || 0) || 0))); + return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / DAILY_PLAY_TARGET * 100).toFixed(1)}%\uff08${count} / ${DAILY_PLAY_TARGET}\u65e5\uff09`; + } 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`; } + if (id === "continuous_play_24_hours") { + const elapsed = Math.min(CONTINUOUS_PLAY_24H_TARGET_MS, continuousPlayElapsedMs()); + return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_24H_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 1440:00\uff09`; + } if (id === "enemy_enemy_friend") { const world = global.world || null; const count = state.unlocked[id] ? 10 : Math.min(10, Math.max(0, Math.floor(Number(world?.achievementEnemyAntKills || 0) || 0))); @@ -375,10 +404,13 @@ const local = [ Math.min(3333, Math.max(0, Number(state.progress?.birthCount || 0) || 0)), Math.floor(continuousPlayElapsedMs() / 1000), + Math.max(0, Math.floor(Number(global.__tarinaiFps || 0) || 0)), Math.min(10, Math.max(0, Number(global.world?.achievementEnemyAntKills || 0) || 0)), Math.min(50, Math.max(0, Number(state.progress?.firstAidHeals || 0) || 0)), Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Number(state.progress?.fightMochiFightCount || 0) || 0)), Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Number(state.progress?.loveMochiBirthCount || 0) || 0)), + Math.min(DAILY_PLAY_TARGET, Math.max(0, Number(state.progress?.dailyPlayStreak || 0) || 0)), + Math.max(0, Number(state.progress?.dailyPlayLastDay || 0) || 0), ].join(":"); return `${unlocks}|${rates}|${local}`; } @@ -504,6 +536,10 @@ dom.list.replaceChildren(...groups); } + function renderIfDialogOpen() { + if (!dom.dialog?.classList.contains("hidden")) render(); + } + function resetUnlockToastVisual() { if (!dom.toast) return; dom.toast.classList.remove("absorbing"); @@ -793,6 +829,8 @@ state.progress.fightMochiFightCount = Math.max(0, Math.floor(Number(state.progress.fightMochiFightCount || 0) || 0)); state.progress.shotCount = Math.max(0, Math.floor(Number(state.progress.shotCount || 0) || 0)); state.progress.loveMochiBirthCount = Math.max(0, Math.floor(Number(state.progress.loveMochiBirthCount || 0) || 0)); + state.progress.dailyPlayStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress.dailyPlayStreak || 0) || 0))); + state.progress.dailyPlayLastDay = Math.max(0, Math.floor(Number(state.progress.dailyPlayLastDay || 0) || 0)); return state.progress; } @@ -855,35 +893,14 @@ state.unlocked.chaos_seeker_666_fights ? 0 : Math.min(CHAOS_FIGHT_TARGET, progress.fightMochiFightCount), state.unlocked.sniper_333_shots ? 0 : Math.min(SNIPER_SHOT_TARGET, progress.shotCount), state.unlocked.fertility_seeker_721_love_births ? 0 : Math.min(FERTILITY_BIRTH_TARGET, progress.loveMochiBirthCount), + state.unlocked.daily_play_7_days ? 0 : Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak), + state.unlocked.daily_play_7_days ? 0 : Math.min(131071, progress.dailyPlayLastDay), ], ]; } function parseSpellState(payload = null) { - if (!Array.isArray(payload)) return null; - const version = Number(payload[0]); - if (version === LEGACY_SPELL_STATE_VERSION) { - const maskHex = String(payload[1] || "").toLowerCase(); - const expectedBytes = Math.ceil(DEFINITIONS.length / 8); - if (!/^[0-9a-f]*$/.test(maskHex) || maskHex.length !== expectedBytes * 2) { - throw new Error("invalid achievement spell mask"); - } - const maskBytes = []; - for (let offset = 0; offset < maskHex.length; offset += 2) maskBytes.push(parseInt(maskHex.slice(offset, offset + 2), 16)); - const unlockedIndexes = []; - for (let index = 0; index < DEFINITIONS.length; index += 1) { - if (maskBytes[Math.floor(index / 8)] & (1 << (index % 8))) unlockedIndexes.push(index); - } - return { - unlockedIndexes, - baseTimestamp: Math.max(0, Math.floor(Number(payload[2] || 0) || 0)), - deltas: Array.isArray(payload[3]) ? payload[3] : [], - packed: Array.isArray(payload[4]) ? payload[4] : [], - timestampUnitMs: 1000, - progressVersion: LEGACY_SPELL_STATE_VERSION, - }; - } - if (version !== PREVIOUS_SPELL_STATE_VERSION && version !== SPELL_STATE_VERSION) return null; + if (!Array.isArray(payload) || Number(payload[0]) !== SPELL_STATE_VERSION) return null; const lowWord = Number(payload[1]); const highWord = Number(payload[2]); const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1; @@ -897,7 +914,7 @@ deltas: Array.isArray(payload[4]) ? payload[4] : [], packed: Array.isArray(payload[5]) ? payload[5] : [], timestampUnitMs: 60000, - progressVersion: version, + progressVersion: SPELL_STATE_VERSION, }; } @@ -958,6 +975,14 @@ progress.fightMochiFightCount = Math.max(progress.fightMochiFightCount, Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Math.floor(Number(packed[9] || 0) || 0)))); progress.shotCount = Math.max(progress.shotCount, Math.min(SNIPER_SHOT_TARGET, Math.max(0, Math.floor(Number(packed[10] || 0) || 0)))); progress.loveMochiBirthCount = Math.max(progress.loveMochiBirthCount, Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Math.floor(Number(packed[11] || 0) || 0)))); + const importedDailyStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(packed[12] || 0) || 0))); + const importedDailyLastDay = Math.min(131071, Math.max(0, Math.floor(Number(packed[13] || 0) || 0))); + if (importedDailyLastDay > progress.dailyPlayLastDay) { + progress.dailyPlayLastDay = importedDailyLastDay; + progress.dailyPlayStreak = importedDailyStreak; + } else if (importedDailyLastDay === progress.dailyPlayLastDay) { + progress.dailyPlayStreak = Math.max(progress.dailyPlayStreak, importedDailyStreak); + } } const progressChanged = previousProgress !== JSON.stringify(progress); changed = changed || progressChanged; @@ -990,11 +1015,12 @@ } let changed = evaluateCleanFreak(world, detail); changed = evaluateMegalopolis(world, detail) || changed; + changed = evaluateSandbox(world, detail) || changed; if (!state.unlocked.placed_objects_100) { const progress = ensureProgress(); progress.placementCount = Math.min(100, progress.placementCount + 1); saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (progress.placementCount >= 100) changed = unlock("placed_objects_100", { ...detail, world, count: progress.placementCount }) || changed; } return changed; @@ -1015,7 +1041,7 @@ if (!state.unlocked.unplanned_city_30) { progress.quickDeleteCount = Math.min(30, progress.quickDeleteCount + 1); saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (progress.quickDeleteCount >= 30) changed = unlock("unplanned_city_30", { ...detail, world, count: progress.quickDeleteCount }) || changed; } return changed; @@ -1029,7 +1055,7 @@ const achievementId = action === "undo" ? "undo_20" : "redo_20"; progress[key] = Math.min(20, progress[key] + 1); saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); let changed = false; if (action === "undo" && Math.max(0, Number(detail.deadRestored || 0) || 0) >= 10) { changed = unlock("undo_mass_revival", { ...detail, count: Math.max(0, Number(detail.deadRestored || 0) || 0) }) || changed; @@ -1054,7 +1080,7 @@ const progress = ensureProgress(); progress.linkTypes = [...new Set([...(progress.linkTypes || []), linkType])]; saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); return evaluateMechanizedIndustry({ ...detail, type: linkType }); } @@ -1064,7 +1090,7 @@ if (!progress.signalActivated) { progress.signalActivated = true; saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); } return evaluateMechanizedIndustry(detail); } @@ -1076,24 +1102,67 @@ return false; } + function evaluateAntNestWithoutTarinai(worldRef = global.world, detail = {}) { + if (!worldRef || state.unlocked.ant_nest_without_tarinai) return false; + const hasNest = (worldRef.items || []).some(item => item && !item.dead && item.type === "ant_nest"); + if (!hasNest) return false; + const aliveTarinai = (worldRef.tarinai || []).some(tarinai => tarinai && !tarinai.dead); + if (aliveTarinai) return false; + return unlock("ant_nest_without_tarinai", { ...detail, world: worldRef }); + } + + function recordStickyBombPass(item = null, detail = {}) { + if (!item || item.dead || item.type !== "sticky_bomb" || state.unlocked.sticky_bomb_15_passes) return false; + item.stickyBombPassCount = Math.max(0, Math.floor(Number(item.stickyBombPassCount || 0) || 0)) + 1; + if (item.stickyBombPassCount < 15) return false; + return unlock("sticky_bomb_15_passes", { ...detail, item, count: item.stickyBombPassCount }); + } + + function recordCircuitBoardSignalTransfer(sourceBoard = null, targetBoard = null, wire = null, detail = {}) { + if (state.unlocked.information_industry) return false; + if (!sourceBoard || !targetBoard || sourceBoard === targetBoard) return false; + if (sourceBoard.type !== "circuit_board" || targetBoard.type !== "circuit_board") return false; + if (!wire || !["wire", "insulated_wire"].includes(wire.type) || !wire.signalActive) return false; + return unlock("information_industry", { ...detail, sourceBoard, targetBoard, wire }); + } + + function recordWirePowerState(wire = null, active = false) { + if (!wire || (typeof wire !== "object" && typeof wire !== "function")) return false; + if (!active) return false; + if (!wireShockTrackers.has(wire)) wireShockTrackers.set(wire, { targets: new WeakSet(), count: 0 }); + return true; + } + + function recordWireShock(wire = null, target = null, detail = {}) { + if (state.unlocked.wire_shock_7_tarinai || !wire || !target || wire.type !== "wire" || !wire.signalActive) return false; + const world = detail.world || wire.world || target.world || global.world || null; + if (!world || !(world.tarinai || []).includes(target) || target.dead) return false; + recordWirePowerState(wire, true); + const tracker = wireShockTrackers.get(wire); + if (!tracker || tracker.targets.has(target)) return false; + tracker.targets.add(target); + tracker.count += 1; + if (tracker.count < WIRE_SHOCK_TARGET) return false; + return unlock("wire_shock_7_tarinai", { ...detail, world, wire, target, count: tracker.count }); + } + function recordAntKilled(detail = {}) { let changed = false; const progress = ensureProgress(); if (!state.unlocked.ants_killed_100) { progress.antKills = Math.min(100, progress.antKills + 1); saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (progress.antKills >= 100) changed = unlock("ants_killed_100", detail) || changed; } const world = detailWorld(detail); const ant = detail.ant || null; const now = Math.max(0, Number(world?.time || 0) || 0); const aliveCount = (world?.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0); - const lastDamageAt = Number(world?.achievementLastTarinaiDamageAt); - const undamagedForTenSeconds = !Number.isFinite(lastDamageAt) || now - lastDamageAt >= 10; - if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30 && undamagedForTenSeconds) { + if (world && aliveCount < 30) world.achievementEnemyAntKills = 0; + if (!state.unlocked.enemy_enemy_friend && world && ant?._achievementLastDamageSource?.danger && aliveCount >= 30) { world.achievementEnemyAntKills = Math.min(10, Math.max(0, Math.floor(Number(world.achievementEnemyAntKills || 0) || 0)) + 1); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (world.achievementEnemyAntKills >= 10) changed = unlock("enemy_enemy_friend", { ...detail, world, count: world.achievementEnemyAntKills }) || changed; } return changed; @@ -1118,7 +1187,10 @@ const dangerSource = dangerItemSource(source); // This achievement tracks collateral damage caused by danger-category tools, // not ordinary combat, hunger, disease, or ant attacks. - if (world && dangerSource) world.achievementLastTarinaiDamageAt = now; + if (world && dangerSource) { + world.achievementLastTarinaiDamageAt = now; + world.achievementEnemyAntKills = 0; + } tarinai._achievementLastDamageSource = dangerSource ? { danger: true, at: now, @@ -1204,36 +1276,100 @@ return false; } - function recordPauseClick(detail = {}) { + function recordEmptyClick(detail = {}) { if (state.unlocked.pause_spam_4_in_1_second) return false; const now = Number(detail.now); const timestamp = Number.isFinite(now) ? now : Date.now(); - pauseClickTimes = pauseClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value); - pauseClickTimes.push(timestamp); - if (pauseClickTimes.length >= 4) { - pauseClickTimes = []; + emptyClickTimes = emptyClickTimes.filter(value => timestamp - value <= 1000 && timestamp >= value); + emptyClickTimes.push(timestamp); + if (emptyClickTimes.length >= 4) { + emptyClickTimes = []; return unlock("pause_spam_4_in_1_second", { ...detail, count: 4, windowMs: 1000 }); } return false; } + function recordGroundChange(detail = {}) { + const world = detailWorld(detail); + const previous = String(detail.previous || ""); + const next = String(detail.next || world?.groundType || ""); + if (!world || detail.silent || !previous || !next || previous === next) return false; + let changed = false; + if (!state.unlocked.park_ground_changed && String(world.fieldType || "") === "park") { + changed = unlock("park_ground_changed", { ...detail, world, previous, next }) || changed; + } + if (!state.unlocked.ground_change_4_in_1_second) { + const now = Number(detail.now); + const timestamp = Number.isFinite(now) ? now : Date.now(); + groundChangeTimes = groundChangeTimes.filter(value => timestamp - value <= 1000 && timestamp >= value); + groundChangeTimes.push(timestamp); + if (groundChangeTimes.length >= 4) { + groundChangeTimes = []; + changed = unlock("ground_change_4_in_1_second", { ...detail, world, count: 4, windowMs: 1000 }) || changed; + } + } + return changed; + } + + function evaluateSandbox(world = global.world, detail = {}) { + if (!world || state.unlocked.sandbox_five_toilets) return false; + const count = (world.items || []).reduce((sum, item) => sum + (item && !item.dead && item.type === "toilet" ? 1 : 0), 0); + if (count < 5) return false; + return unlock("sandbox_five_toilets", { ...detail, world, count }); + } + + function recordLowFps(detail = {}) { + if (state.unlocked.low_fps_single_digit) return false; + const fps = Number.isFinite(Number(detail.fps)) ? Number(detail.fps) : Number(global.__tarinaiFps); + if (!Number.isFinite(fps) || fps <= 0 || fps >= 10) return false; + return unlock("low_fps_single_digit", { ...detail, fps }); + } + + function localDayNumber(value = Date.now()) { + const date = new Date(Number(value)); + if (!Number.isFinite(date.getTime())) return 0; + return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS); + } + + function recordDailyPlay(detail = {}) { + if (state.unlocked.daily_play_7_days) return false; + const today = localDayNumber(Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now()); + if (!today) return false; + const progress = ensureProgress(); + const previousDay = progress.dailyPlayLastDay; + if (today < previousDay || today === previousDay) return false; + progress.dailyPlayStreak = today === previousDay + 1 + ? Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak + 1) + : 1; + progress.dailyPlayLastDay = today; + saveState(); + renderIfDialogOpen(); + if (progress.dailyPlayStreak >= DAILY_PLAY_TARGET) { + return unlock("daily_play_7_days", { ...detail, day: today, streak: progress.dailyPlayStreak }); + } + return false; + } + function recordContinuousPlay(detail = {}) { - if (state.unlocked.continuous_play_1_hour) return false; + if (state.unlocked.continuous_play_1_hour && state.unlocked.continuous_play_24_hours) return false; const explicitElapsed = Number(detail.elapsedMs); let elapsedMs; if (Number.isFinite(explicitElapsed)) { elapsedMs = Math.max(0, explicitElapsed); } else { const timestamp = Number.isFinite(Number(detail.now)) ? Number(detail.now) : Date.now(); - if (document.hidden) return false; - if (!continuousPlayStartedAt || !continuousPlayLastHeartbeatAt || timestamp - continuousPlayLastHeartbeatAt > 15000 || timestamp < continuousPlayLastHeartbeatAt) { - continuousPlayStartedAt = timestamp; - } + if (!continuousPlayStartedAt || timestamp < continuousPlayStartedAt) continuousPlayStartedAt = timestamp; continuousPlayLastHeartbeatAt = timestamp; elapsedMs = Math.max(0, timestamp - continuousPlayStartedAt); } - if (elapsedMs >= CONTINUOUS_PLAY_TARGET_MS) return unlock("continuous_play_1_hour", { ...detail, elapsedMs }); - return false; + let changed = false; + if (!state.unlocked.continuous_play_1_hour && elapsedMs >= CONTINUOUS_PLAY_TARGET_MS) { + changed = unlock("continuous_play_1_hour", { ...detail, elapsedMs }) || changed; + } + if (!state.unlocked.continuous_play_24_hours && elapsedMs >= CONTINUOUS_PLAY_24H_TARGET_MS) { + changed = unlock("continuous_play_24_hours", { ...detail, elapsedMs }) || changed; + } + return changed; } function recordBelowAbsoluteZeroItem(worldRef = global.world, detail = {}) { @@ -1253,7 +1389,7 @@ const type = String(detail.type || detail.target?.type || ""); if (!world || !["zunchi", "ant_corpse", "trace", "splat"].includes(type)) return false; world.achievementRobotCleanCount = Math.min(ROBOT_CLEAN_TARGET, Math.max(0, Math.floor(Number(world.achievementRobotCleanCount || 0) || 0)) + 1); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (world.achievementRobotCleanCount >= ROBOT_CLEAN_TARGET) return unlock("robot_cleaner_100", { ...detail, world, count: world.achievementRobotCleanCount }); return false; } @@ -1304,7 +1440,7 @@ const progress = ensureProgress(); progress.firstAidHeals = Math.min(50, progress.firstAidHeals + 1); saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (progress.firstAidHeals >= 50) return unlock("town_doctor_50", { ...detail, count: progress.firstAidHeals, recovered }); return false; } @@ -1330,7 +1466,7 @@ if (!progress.medicineTypes.includes(type)) { progress.medicineTypes = [...progress.medicineTypes, type]; saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); } if (MEDICINE_LEDGER_TYPES.every(required => progress.medicineTypes.includes(required))) { changed = unlock("medicine_ledger_all", { ...detail, count: progress.medicineTypes.length }) || changed; @@ -1374,7 +1510,7 @@ changed = unlock("chaos_seeker_666_fights", { ...detail, world, count: progress.fightMochiFightCount, fightMochiInfluenced: true }) || changed; } else { saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); } } const now = Math.max(0, Number(world.time || 0) || 0); @@ -1398,7 +1534,7 @@ progress.shotCount = Math.min(SNIPER_SHOT_TARGET, progress.shotCount + 1); if (progress.shotCount >= SNIPER_SHOT_TARGET) return unlock("sniper_333_shots", { ...detail, count: progress.shotCount }); saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); return false; } @@ -1410,7 +1546,7 @@ return unlock("fertility_seeker_721_love_births", { ...detail, count: progress.loveMochiBirthCount }); } saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); return false; } @@ -1486,7 +1622,7 @@ if (!state.unlocked.great_mother_1000_births) { progress.birthCount = Math.min(3333, progress.birthCount + 1); saveState(); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (progress.birthCount >= 3333) unlock("great_mother_1000_births", { ...detail, count: progress.birthCount }); } if (state.unlocked.birth_50_in_60_seconds) return; @@ -1500,11 +1636,15 @@ function recordDeath(detail = {}) { recordFightPairDangerDeath(detail); + const reason = String(detail.reason || detail.tarinai?.deathReason || ""); + const lifespanDeath = /\u5bff\u547d|\u5929\u5bff/.test(reason); const world = detail.world || detail.tarinai?.world || null; if (world) { evaluateMegalopolis(world, detail); - const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0); - world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1; + if (!lifespanDeath) { + const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0); + world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1; + } } const now = Number(world?.time || 0) || 0; const tracker = runtimeTracker(world, now); @@ -1513,7 +1653,6 @@ tracker.deathTimes = tracker.deathTimes.filter(time => time >= now - 10.0001); if (tracker.deathTimes.length >= 50) unlock("death_50_in_10_seconds", detail); } - const reason = String(detail.reason || detail.tarinai?.deathReason || ""); const tarinai = detail.tarinai || null; if (reason.includes("\u5929\u5bff\u3092\u5168\u3046\u3057\u305f")) { unlock("lifespan_completed", detail); @@ -1555,7 +1694,7 @@ recordDirectCare({ ...detail, world, foodGift: true }); if (state.unlocked.direct_feed_33) return false; world.achievementDirectFeedCount = Math.min(33, Math.max(0, Math.floor(Number(world.achievementDirectFeedCount || 0) || 0)) + 1); - if (!dom.dialog?.classList.contains("hidden")) render(); + renderIfDialogOpen(); if (world.achievementDirectFeedCount >= 33) return unlock("direct_feed_33", { ...detail, world, count: world.achievementDirectFeedCount }); return false; } @@ -1577,7 +1716,8 @@ || !state.unlocked.rain_shelter_all || !state.unlocked.sauna_cold_plunge || !state.unlocked.idle_observer_5_minutes - || !state.unlocked.safe_colony_25_5_minutes; + || !state.unlocked.safe_colony_25_5_minutes + || !state.unlocked.ant_nest_without_tarinai; const needsAntScan = !state.unlocked.ants_alive_25; const needsTemperatureItemScan = !state.unlocked.below_absolute_zero_item; const needsCleanFreakScan = !state.unlocked.clean_freak_robot_only; @@ -1666,7 +1806,9 @@ } } + if (!state.unlocked.sandbox_five_toilets) evaluateSandbox(worldRef, { source: "world-evaluation" }); if (!state.unlocked.ants_alive_25) recordAntPopulation(worldRef); + if (!state.unlocked.ant_nest_without_tarinai) evaluateAntNestWithoutTarinai(worldRef, { source: "world-evaluation" }); if (!state.unlocked.below_absolute_zero_item) recordBelowAbsoluteZeroItem(worldRef); if (!state.unlocked.clean_freak_robot_only) evaluateCleanFreak(worldRef, { source: "world-evaluation" }); if (!state.unlocked.megalopolis && alive.length >= 100) evaluateMegalopolis(worldRef, { source: "world-evaluation" }); @@ -1686,16 +1828,19 @@ state.resetPending = true; runtimeByWorld = new WeakMap(); dangerFightDeathsByWorld = new WeakMap(); + wireShockTrackers = new WeakMap(); toastQueue = []; toastActive = false; - pauseClickTimes = []; - continuousPlayStartedAt = document.hidden ? 0 : Date.now(); + emptyClickTimes = []; + groundChangeTimes = []; + continuousPlayStartedAt = Date.now(); continuousPlayLastHeartbeatAt = continuousPlayStartedAt; if (global.world) { resetWorldProgress(global.world); global.world.achievementLastInterventionAt = Math.max(0, Number(global.world.time || 0) || 0); } saveState(); + recordDailyPlay({ source: "reset" }); render(); clearTimeout(showUnlockToast.absorbTimer); clearTimeout(showUnlockToast.hideTimer); @@ -1736,14 +1881,14 @@ dom.button?.focus?.(); } - dom.pauseButton?.addEventListener("click", () => recordPauseClick()); document.addEventListener("visibilitychange", () => { - continuousPlayStartedAt = document.hidden ? 0 : Date.now(); - continuousPlayLastHeartbeatAt = continuousPlayStartedAt; + if (!document.hidden) continuousPlayLastHeartbeatAt = Date.now(); }); if (typeof global.setInterval === "function") { global.setInterval(() => { if (!document.hidden) recordContinuousPlay(); + recordDailyPlay(); + recordLowFps(); recordHeldTarinai(global.world, Date.now()); }, 1000); if (!DEBUG_MODE) global.setInterval(() => void syncPending({ full: true }), FULL_SYNC_INTERVAL_MS); @@ -1801,10 +1946,19 @@ recordLinkPlaced, recordSignalActivation, recordAntPopulation, + evaluateAntNestWithoutTarinai, recordAntKilled, + recordStickyBombPass, + recordCircuitBoardSignalTransfer, + recordWirePowerState, + recordWireShock, recordSaveSlotsFilled, - recordPauseClick, + recordEmptyClick, + recordGroundChange, + evaluateSandbox, + recordLowFps, recordContinuousPlay, + recordDailyPlay, recordBelowAbsoluteZeroItem, recordDamageSource, recordAntDamageSource, @@ -1816,6 +1970,7 @@ recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); }, }); global.TarinaiAchievements = api; + recordDailyPlay({ source: "load" }); if (!state.unlocked[COMPLETIONIST_ID] && allOtherAchievementsUnlocked()) unlock(COMPLETIONIST_ID, { source: "load" }); render(); global.setTimeout(() => recordSaveSlotsFilled(), 0); diff --git a/js/circuit_board_system.js b/js/circuit_board_system.js new file mode 100644 index 0000000..38cdc4e --- /dev/null +++ b/js/circuit_board_system.js @@ -0,0 +1,1117 @@ +"use strict"; + +// Resizable grid-based combinational logic board. Internal wiring is a graph of +// straight segments. Junctions are explicit movable nodes and terminals may +// fan out to any number of connections. +(function (global) { + const SIDES = Object.freeze(["left", "right", "top", "bottom"]); + const GATE_TYPES = Object.freeze(["and", "not", "rectifier", "on"]); + const GRID = 40; + const DEFAULT_COLS = 10; + const DEFAULT_ROWS = 10; + const MIN_COLS = 6; + const MAX_COLS = 20; + const MIN_ROWS = 4; + const MAX_ROWS = 20; + const HALF_W = 32; + const HALF_H = 32; + const CANVAS_W = DEFAULT_COLS * GRID; + const CANVAS_H = DEFAULT_ROWS * GRID; + const MAX_NODES = 36; + const MAX_WIRES = 384; + + const clone = value => { + try { return JSON.parse(JSON.stringify(value)); } catch (_) { return null; } + }; + const clamp = global.TarinaiCoreHelpers?.clampNumber || ((v, a, b) => Math.max(a, Math.min(b, Number(v) || 0))); + const safeRotation = value => ((Math.round(Number(value) || 0) % 4) + 4) % 4; + const safeSide = value => SIDES.includes(value) ? value : "left"; + const nodeType = value => GATE_TYPES.includes(value) ? value : "and"; + const nodeInputCount = type => type === "and" ? 2 : ((type === "not" || type === "rectifier") ? 1 : 0); + const safeCols = value => Math.round(clamp(value ?? DEFAULT_COLS, MIN_COLS, MAX_COLS)); + const safeRows = value => Math.round(clamp(value ?? DEFAULT_ROWS, MIN_ROWS, MAX_ROWS)); + const canvasWidth = config => safeCols(config?.cols) * GRID; + const canvasHeight = config => safeRows(config?.rows) * GRID; + const snap = value => Math.round((Number(value) || 0) / GRID) * GRID; + const isVerticalSide = side => side === "left" || side === "right"; + const sideExtent = (config, side) => isVerticalSide(side) ? canvasHeight(config) : canvasWidth(config); + const safeOffset = (config, side, value) => { + const extent = sideExtent(config, side); + return clamp(snap(value), GRID, Math.max(GRID, extent - GRID)); + }; + const portLimit = config => Math.max(1, safeRows(config?.rows) - 1); + const pointToken = (config, x, y) => `p:${Math.round(clamp(snap(x), GRID, canvasWidth(config) - GRID))}:${Math.round(clamp(snap(y), GRID, canvasHeight(config) - GRID))}`; + + function boardHalfWidth(boardOrConfig) { + const config = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : (boardOrConfig || defaultConfig()); + return 64 * safeCols(config.cols) / 20; + } + + function boardHalfHeight(boardOrConfig) { + const config = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : (boardOrConfig || defaultConfig()); + return 64 * safeRows(config.rows) / 20; + } + + function updateBoardPhysicalSize(board) { + if (!board || board.type !== "circuit_board") return; + const hw = boardHalfWidth(board.circuitConfig || defaultConfig()); + const hh = boardHalfHeight(board.circuitConfig || defaultConfig()); + board.r = Math.max(18, Math.hypot(hw, hh)); + board.radius = board.r; + } + + function parsePointToken(config, token) { + const match = /^p:(-?\d+):(-?\d+)$/.exec(String(token || "")); + if (!match) return null; + return { + x: clamp(snap(match[1]), GRID, canvasWidth(config) - GRID), + y: clamp(snap(match[2]), GRID, canvasHeight(config) - GRID), + }; + } + + function spacedOffsets(config, side, count) { + const extent = sideExtent(config, side); + const usable = Math.max(GRID, extent - GRID * 2); + const total = Math.max(1, count); + if (total === 1) return [safeOffset(config, side, extent * 0.5)]; + return Array.from({ length: total }, (_, index) => safeOffset(config, side, GRID + usable * index / (total - 1))); + } + + function defaultPorts(config, direction) { + const count = direction === "input" ? 3 : 2; + const side = direction === "input" ? "left" : "right"; + const prefix = direction === "input" ? "I" : "O"; + return spacedOffsets(config, side, Math.min(count, portLimit(config))).map((offset, index) => ({ id: `${prefix}${index + 1}`, side, offset })); + } + + function defaultConfig() { + const config = { v: 4, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, inputs: [], outputs: [], nodes: [], wires: [] }; + config.inputs = defaultPorts(config, "input"); + config.outputs = defaultPorts(config, "output"); + return config; + } + + function normalizeNodeId(value, used, index) { + let id = /^[a-z0-9_-]{1,24}$/i.test(String(value || "")) ? String(value) : `g${index + 1}`; + if (!used.has(id)) { used.add(id); return id; } + let suffix = 2; + while (used.has(`${id}_${suffix}`)) suffix += 1; + id = `${id}_${suffix}`; + used.add(id); + return id; + } + + function normalizePorts(config, rawPorts, direction) { + const source = Array.isArray(rawPorts) ? rawPorts : []; + const maximum = portLimit(config); + const prefix = direction === "input" ? "I" : "O"; + const fallbackSide = direction === "input" ? "left" : "right"; + const used = new Set(); + const result = []; + for (let index = 0; index < source.length && result.length < maximum; index += 1) { + const raw = source[index] || {}; + let id = /^[A-Za-z][A-Za-z0-9_-]{0,11}$/.test(String(raw.id || "")) ? String(raw.id) : `${prefix}${index + 1}`; + if (used.has(id)) { + let n = 1; + while (used.has(`${prefix}${n}`)) n += 1; + id = `${prefix}${n}`; + } + used.add(id); + const side = safeSide(raw.side || fallbackSide); + result.push({ id, side, offset: safeOffset(config, side, raw.offset ?? ((index + 1) * GRID * 2)) }); + } + return result; + } + + function rotateOffset(x, y, rotation) { + const r = safeRotation(rotation); + if (r === 1) return { x: -y, y: x }; + if (r === 2) return { x: -x, y: -y }; + if (r === 3) return { x: y, y: -x }; + return { x, y }; + } + + function nodeBodySize(type) { + if (type === "not" || type === "rectifier") return { width: 44, height: 30 }; + if (type === "on") return { width: 38, height: 38 }; + return { width: 80, height: 80 }; + } + + function nodePlacementBounds(config, type, rotation = 0) { + const size = nodeBodySize(type); + const r = safeRotation(rotation); + const bodyHalfW = (r % 2) ? size.height * 0.5 : size.width * 0.5; + const bodyHalfH = (r % 2) ? size.width * 0.5 : size.height * 0.5; + const probe = { id: "__probe__", type, x: 0, y: 0, rotation: r }; + const pins = nodePins(probe); + let minPinX = 0, maxPinX = 0, minPinY = 0, maxPinY = 0; + for (const pin of pins) { + minPinX = Math.min(minPinX, pin.x); maxPinX = Math.max(maxPinX, pin.x); + minPinY = Math.min(minPinY, pin.y); maxPinY = Math.max(maxPinY, pin.y); + } + const width = canvasWidth(config), height = canvasHeight(config); + const minX = Math.max(bodyHalfW, GRID - minPinX); + const maxX = Math.min(width - bodyHalfW, width - GRID - maxPinX); + const minY = Math.max(bodyHalfH, GRID - minPinY); + const maxY = Math.min(height - bodyHalfH, height - GRID - maxPinY); + return { + minX: Math.ceil(minX / GRID) * GRID, + maxX: Math.floor(maxX / GRID) * GRID, + minY: Math.ceil(minY / GRID) * GRID, + maxY: Math.floor(maxY / GRID) * GRID, + }; + } + + function clampNodePosition(config, type, rotation, x, y) { + const bounds = nodePlacementBounds(config, type, rotation); + return { + x: clamp(snap(x), bounds.minX, Math.max(bounds.minX, bounds.maxX)), + y: clamp(snap(y), bounds.minY, Math.max(bounds.minY, bounds.maxY)), + }; + } + + function canvasPortPosition(config, port) { + const side = safeSide(port?.side); + const offset = safeOffset(config, side, port?.offset ?? sideExtent(config, side) * 0.5); + if (side === "left") return { x: GRID, y: offset }; + if (side === "right") return { x: canvasWidth(config) - GRID, y: offset }; + if (side === "top") return { x: offset, y: GRID }; + return { x: offset, y: canvasHeight(config) - GRID }; + } + + function externalPins(config) { + return [ + ...config.inputs.map(port => ({ token: `i:${port.id}`, role: "source", direction: "input", kind: "external", label: port.id, port, ...canvasPortPosition(config, port) })), + ...config.outputs.map(port => ({ token: `o:${port.id}`, role: "target", direction: "output", kind: "external", label: port.id, port, ...canvasPortPosition(config, port) })), + ]; + } + + function nodePins(node) { + const pins = []; + const count = nodeInputCount(node.type); + const pushPin = (pin, role, ox, oy) => { + const rotated = rotateOffset(ox, oy, node.rotation); + pins.push({ token: `n:${node.id}:${pin}`, role, kind: "node", node, pin, x: snap(node.x + rotated.x), y: snap(node.y + rotated.y) }); + }; + if (count === 1) pushPin("a", "target", -GRID, 0); + if (count === 2) { + pushPin("a", "target", -GRID, -GRID); + pushPin("b", "target", -GRID, GRID); + } + pushPin("o", "source", GRID, 0); + return pins; + } + + function allPins(config) { + return [...externalPins(config), ...config.nodes.flatMap(nodePins)]; + } + + function pinByToken(config, token) { + return allPins(config).find(pin => pin.token === token) || null; + } + + function endpointPosition(config, token) { + const pin = pinByToken(config, token); + if (pin) return { x: pin.x, y: pin.y, pin }; + const point = parsePointToken(config, token); + return point ? { ...point, pin: null } : null; + } + + function validEndpoint(config, token) { + return Boolean(endpointPosition(config, token)); + } + + function wireKey(a, b) { + return a < b ? `${a}|${b}` : `${b}|${a}`; + } + + function remapPointTokens(config, wires) { + return (wires || []).map(wire => { + const map = token => { + const match = /^p:(-?\d+):(-?\d+)$/.exec(String(token || "")); + return match ? pointToken(config, Number(match[1]), Number(match[2])) : String(token || ""); + }; + return { a: map(wire?.a), b: map(wire?.b) }; + }); + } + + function normalizeConfig(value) { + // Save compatibility is intentionally not provided for older board formats. + if (!value || typeof value !== "object" || Number(value.v) !== 4) return defaultConfig(); + const config = { + v: 4, + cols: safeCols(value.cols), + rows: safeRows(value.rows), + inputs: [], + outputs: [], + nodes: [], + wires: [], + }; + config.inputs = normalizePorts(config, value.inputs, "input"); + config.outputs = normalizePorts(config, value.outputs, "output"); + const used = new Set(); + config.nodes = (Array.isArray(value.nodes) ? value.nodes : []).slice(0, MAX_NODES).map((node, index) => { + const type = nodeType(node?.type); + const rotation = safeRotation(node?.rotation); + const pos = clampNodePosition(config, type, rotation, node?.x ?? (GRID * 3 + (index % 4) * GRID * 2), node?.y ?? (GRID * 2 + Math.floor(index / 4) * GRID * 2)); + return { id: normalizeNodeId(node?.id, used, index), type, x: pos.x, y: pos.y, rotation }; + }); + const seen = new Set(); + for (const wire of remapPointTokens(config, Array.isArray(value.wires) ? value.wires : []).slice(0, MAX_WIRES)) { + const a = String(wire?.a || ""); + const b = String(wire?.b || ""); + if (!validEndpoint(config, a) || !validEndpoint(config, b) || a === b) continue; + const key = wireKey(a, b); + if (seen.has(key)) continue; + seen.add(key); + config.wires.push({ a, b }); + } + return config; + } + + function ensureConfig(board) { + if (!board) return defaultConfig(); + board.circuitConfig = normalizeConfig(board.circuitConfig); + updateBoardPhysicalSize(board); + return board.circuitConfig; + } + + function serializeConfig(boardOrConfig) { + const value = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : normalizeConfig(boardOrConfig); + return clone(value) || defaultConfig(); + } + + function applySerializedConfig(board, value) { + if (!board) return false; + board.circuitConfig = normalizeConfig(value); + board._circuitTerminals = null; + board._circuitInputState = Object.create(null); + board._circuitNodeState = Object.create(null); + board._circuitOutputState = Object.create(null); + updateBoardPhysicalSize(board); + return true; + } + + function ports(board) { + const config = ensureConfig(board); + return [ + ...config.inputs.map(port => ({ ...port, direction: "input", portId: `i:${port.id}`, label: `\u5165\u529b ${port.id}` })), + ...config.outputs.map(port => ({ ...port, direction: "output", portId: `o:${port.id}`, label: `\u51fa\u529b ${port.id}` })), + ]; + } + + function localPortPosition(port, boardOrConfig = null) { + const config = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : (boardOrConfig || defaultConfig()); + const side = safeSide(port?.side); + const extent = sideExtent(config, side); + const offset = safeOffset(config, side, port?.offset ?? extent * 0.5); + const t = clamp(offset / Math.max(1, extent), 0, 1); + const halfW = boardHalfWidth(config); + const halfH = boardHalfHeight(config); + if (side === "left") return { x: -halfW, y: -halfH + t * halfH * 2 }; + if (side === "right") return { x: halfW, y: -halfH + t * halfH * 2 }; + if (side === "top") return { x: -halfW + t * halfW * 2, y: -halfH }; + return { x: -halfW + t * halfW * 2, y: halfH }; + } + + function portById(board, portId) { + return ports(board).find(port => port.portId === portId) || null; + } + + function portWorld(board, portOrId) { + const port = typeof portOrId === "string" ? portById(board, portOrId) : portOrId; + if (!board || !port) return null; + const local = localPortPosition(port, board); + const angle = Number(board.angle || 0) || 0; + const c = Math.cos(angle), s = Math.sin(angle); + return { x: board.x + local.x * c - local.y * s, y: board.y + local.x * s + local.y * c, localX: local.x, localY: local.y, port }; + } + + function nearestPort(board, x, y, maxDistance = 25) { + let best = null; + let bestD = Number(maxDistance) || 25; + for (const port of ports(board)) { + const point = portWorld(board, port); + const d = Math.hypot((Number(x) || 0) - point.x, (Number(y) || 0) - point.y); + if (d <= bestD) { bestD = d; best = { ...point, distance: d }; } + } + return best; + } + + function isPortExternallyConnected(board, portId, worldRef = board?.world || global.world) { + if (!board || !portId || !worldRef?.items) return false; + for (const link of worldRef.items) { + if (!link || link.dead || !["wire", "insulated_wire"].includes(link.type)) continue; + for (let index = 0; index < 2; index += 1) { + const endpoint = global.TarinaiPhysicsBodySystem?.endpoint?.(link, index) || link.physicsConstraint?.endpoints?.[index] || null; + if (endpoint?.id === board.id && endpoint?.portId === portId) return true; + } + } + return false; + } + + function remapAttachedWireEndpoints(board, worldRef = board?.world || global.world) { + if (!board || !worldRef?.items) return 0; + let changed = 0; + for (const link of worldRef.items) { + if (!link || link.dead || !["wire", "insulated_wire"].includes(link.type)) continue; + for (let index = 0; index < 2; index += 1) { + const endpoint = global.TarinaiPhysicsBodySystem?.endpoint?.(link, index) || link.physicsConstraint?.endpoints?.[index] || null; + if (!endpoint || endpoint.id !== board.id || !endpoint.portId) continue; + const point = portWorld(board, endpoint.portId); + if (!point) continue; + endpoint.localX = point.localX; + endpoint.localY = point.localY; + endpoint.x = point.x; + endpoint.y = point.y; + endpoint.center = false; + changed += 1; + } + } + const pending = worldRef.pendingLinkEndpoint; + if (pending?.id === board.id && pending.portId) { + const point = portWorld(board, pending.portId); + if (point) { + pending.localX = point.localX; + pending.localY = point.localY; + pending.x = point.x; + pending.y = point.y; + pending.center = false; + changed += 1; + } + } + if (changed) worldRef.markSpatialDirty?.("circuit-board-port-remap"); + return changed; + } + + function terminal(board, portId) { + if (!board) return null; + const port = portById(board, portId); + if (!port) return null; + if (!board._circuitTerminals || typeof board._circuitTerminals !== "object") board._circuitTerminals = Object.create(null); + let result = board._circuitTerminals[portId]; + if (!result) result = board._circuitTerminals[portId] = { kind: "circuit_terminal", type: "circuit_terminal", board, boardId: board.id || "", portId, direction: port.direction, signalActive: false }; + result.board = board; + result.boardId = board.id || ""; + result.direction = port.direction; + result.signalActive = port.direction === "output" ? Boolean(board._circuitOutputState?.[port.id]) : Boolean(board._circuitInputState?.[port.id]); + return result; + } + + function evaluate(board, inputValues = null) { + const config = ensureConfig(board); + const inputs = Object.create(null); + for (const port of config.inputs) inputs[port.id] = Boolean(inputValues ? inputValues[port.id] : board?._circuitInputState?.[port.id]); + + const parent = new Map(); + const rank = new Map(); + const add = token => { if (!parent.has(token)) { parent.set(token, token); rank.set(token, 0); } }; + const find = token => { + add(token); + let root = token; + while (parent.get(root) !== root) root = parent.get(root); + let current = token; + while (parent.get(current) !== current) { const next = parent.get(current); parent.set(current, root); current = next; } + return root; + }; + const union = (a, b) => { + let ra = find(a), rb = find(b); + if (ra === rb) return; + const ar = rank.get(ra) || 0, br = rank.get(rb) || 0; + if (ar < br) { const t = ra; ra = rb; rb = t; } + parent.set(rb, ra); + if (ar === br) rank.set(ra, ar + 1); + }; + for (const pin of allPins(config)) add(pin.token); + for (const wire of config.wires) { add(wire.a); add(wire.b); union(wire.a, wire.b); } + + const members = new Map(); + for (const token of parent.keys()) { + const root = find(token); + if (!members.has(root)) members.set(root, []); + members.get(root).push(token); + } + const nodeMap = new Map(config.nodes.map(node => [node.id, node])); + const dependencies = new Map(config.nodes.map(node => [node.id, new Set()])); + for (const node of config.nodes) { + for (const pinName of nodeInputCount(node.type) === 2 ? ["a", "b"] : (nodeInputCount(node.type) === 1 ? ["a"] : [])) { + const root = find(`n:${node.id}:${pinName}`); + for (const member of members.get(root) || []) { + const match = /^n:([^:]+):o$/.exec(member); + if (match && dependencies.has(match[1])) dependencies.get(node.id).add(match[1]); + } + } + } + const unstableNodes = new Set(); + { + let index = 0; + const indices = new Map(), low = new Map(), stack = [], onStack = new Set(); + const visit = id => { + indices.set(id, index); low.set(id, index); index += 1; stack.push(id); onStack.add(id); + for (const dep of dependencies.get(id) || []) { + if (!indices.has(dep)) { visit(dep); low.set(id, Math.min(low.get(id), low.get(dep))); } + else if (onStack.has(dep)) low.set(id, Math.min(low.get(id), indices.get(dep))); + } + if (low.get(id) !== indices.get(id)) return; + const component = []; + while (stack.length) { + const value = stack.pop(); onStack.delete(value); component.push(value); + if (value === id) break; + } + if (component.length > 1 || (component.length === 1 && dependencies.get(component[0])?.has(component[0]))) { + for (const value of component) unstableNodes.add(value); + } + }; + for (const node of config.nodes) if (!indices.has(node.id)) visit(node.id); + } + const nodeValues = Object.create(null); + const netValues = new Map(); + const visitingNodes = new Set(); + const visitingNets = new Set(); + + function driverValue(token) { + if (token.startsWith("i:")) return Boolean(inputs[token.slice(2)]); + const match = /^n:([^:]+):o$/.exec(token); + return match ? nodeValue(match[1]) : false; + } + + function netValue(token) { + const root = find(token); + if (netValues.has(root)) return Boolean(netValues.get(root)); + if (visitingNets.has(root)) return false; + visitingNets.add(root); + let value = false; + for (const member of members.get(root) || [token]) { + if (member.startsWith("i:") || /^n:[^:]+:o$/.test(member)) { + if (driverValue(member)) { value = true; break; } + } + } + visitingNets.delete(root); + netValues.set(root, Boolean(value)); + return Boolean(value); + } + + function nodeValue(id) { + if (Object.prototype.hasOwnProperty.call(nodeValues, id)) return Boolean(nodeValues[id]); + if (visitingNodes.has(id)) return false; + const node = nodeMap.get(id); + if (!node) return false; + if (unstableNodes.has(id)) { nodeValues[id] = false; return false; } + visitingNodes.add(id); + let value = false; + if (node.type === "on") value = true; + else { + const a = netValue(`n:${id}:a`); + if (node.type === "not") value = !a; + else if (node.type === "rectifier") value = a; + else value = a && netValue(`n:${id}:b`); + } + visitingNodes.delete(id); + nodeValues[id] = Boolean(value); + return Boolean(value); + } + + const outputs = Object.create(null); + for (const port of config.outputs) outputs[port.id] = netValue(`o:${port.id}`); + if (board) { + board._circuitInputState = inputs; + board._circuitNodeState = nodeValues; + board._circuitOutputState = outputs; + for (const port of config.outputs) { + const term = terminal(board, `o:${port.id}`); + if (term) term.signalActive = Boolean(outputs[port.id]); + } + } + return { inputs, nodes: nodeValues, outputs }; + } + + function nearestPin(config, x, y, maxDistance = 17) { + let best = null; + let bestD = maxDistance; + for (const pin of allPins(config)) { + const d = Math.hypot(x - pin.x, y - pin.y); + if (d <= bestD) { bestD = d; best = { ...pin, distance: d }; } + } + return best; + } + + function junctionTokens(config) { + const degree = new Map(); + for (const wire of config.wires) { + for (const token of [wire.a, wire.b]) if (parsePointToken(config, token)) degree.set(token, (degree.get(token) || 0) + 1); + } + return [...degree.entries()].map(([token, count]) => ({ token, count, ...parsePointToken(config, token) })); + } + + function nearestJunction(config, x, y, maxDistance = 16) { + let best = null; + let bestD = maxDistance; + for (const junction of junctionTokens(config)) { + const d = Math.hypot(x - junction.x, y - junction.y); + if (d <= bestD) { bestD = d; best = { ...junction, distance: d }; } + } + return best; + } + + function nodeAt(config, x, y) { + for (let i = config.nodes.length - 1; i >= 0; i -= 1) { + const node = config.nodes[i]; + const rotation = safeRotation(node.rotation); + const dx = x - node.x, dy = y - node.y; + const local = rotateOffset(dx, dy, (4 - rotation) % 4); + const size = nodeBodySize(node.type); + if (Math.abs(local.x) <= size.width * 0.5 && Math.abs(local.y) <= size.height * 0.5) return node; + } + return null; + } + + const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by); + + function wirePath(config, wire) { + const a = endpointPosition(config, wire.a); + const b = endpointPosition(config, wire.b); + return a && b ? [a, b] : []; + } + + function wireAt(config, x, y, maxDistance = 9) { + let best = null; + let bestD = maxDistance; + for (let i = config.wires.length - 1; i >= 0; i -= 1) { + const path = wirePath(config, config.wires[i]); + if (path.length < 2) continue; + const d = distanceToSegment(x, y, path[0].x, path[0].y, path[1].x, path[1].y); + if (d <= bestD) { bestD = d; best = { wire: config.wires[i], index: i, distance: d }; } + } + return best; + } + + function gcd(a, b) { + a = Math.abs(Math.round(a)); b = Math.abs(Math.round(b)); + while (b) { const t = a % b; a = b; b = t; } + return a || 1; + } + + function nearestGridPointOnWire(config, wire, x, y, maxDistance = 18) { + const path = wirePath(config, wire); + if (path.length < 2) return null; + const a = path[0], b = path[1]; + const ux = Math.round((b.x - a.x) / GRID), uy = Math.round((b.y - a.y) / GRID); + const steps = gcd(ux, uy); + let best = null, bestD = maxDistance; + for (let i = 0; i <= steps; i += 1) { + const px = snap(a.x + (b.x - a.x) * i / steps); + const py = snap(a.y + (b.y - a.y) * i / steps); + const d = Math.hypot(x - px, y - py); + if (d <= bestD) { bestD = d; best = { x: px, y: py, distance: d }; } + } + return best; + } + + function pointOnSegment(point, a, b) { + return distanceToSegment(point.x, point.y, a.x, a.y, b.x, b.y) < 0.6 + && point.x >= Math.min(a.x, b.x) - 0.6 && point.x <= Math.max(a.x, b.x) + 0.6 + && point.y >= Math.min(a.y, b.y) - 0.6 && point.y <= Math.max(a.y, b.y) + 0.6; + } + + function splitWiresAtPoint(config, x, y) { + const token = pointToken(config, x, y); + const point = parsePointToken(config, token); + const next = []; + let changed = false; + for (const wire of config.wires) { + const path = wirePath(config, wire); + if (path.length < 2 || !pointOnSegment(point, path[0], path[1]) || wire.a === token || wire.b === token) { + next.push(wire); continue; + } + const aPos = endpointPosition(config, wire.a), bPos = endpointPosition(config, wire.b); + if ((aPos.x === point.x && aPos.y === point.y) || (bPos.x === point.x && bPos.y === point.y)) { next.push(wire); continue; } + next.push({ a: wire.a, b: token }, { a: token, b: wire.b }); + changed = true; + } + if (changed) config.wires = next.slice(0, MAX_WIRES); + return token; + } + + function moveJunction(config, oldToken, x, y) { + const nextToken = pointToken(config, x, y); + if (!parsePointToken(config, oldToken) || nextToken === oldToken) return nextToken; + for (const wire of config.wires) { + if (wire.a === oldToken) wire.a = nextToken; + if (wire.b === oldToken) wire.b = nextToken; + } + const seen = new Set(); + config.wires = config.wires.filter(wire => { + if (wire.a === wire.b) return false; + const key = wireKey(wire.a, wire.b); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return nextToken; + } + + function anchorAt(config, x, y, allowEmpty = false) { + const pin = nearestPin(config, x, y, 18); + if (pin) return { token: pin.token, x: pin.x, y: pin.y, kind: "pin" }; + const junction = nearestJunction(config, x, y, 18); + if (junction) return { token: junction.token, x: junction.x, y: junction.y, kind: "junction" }; + let best = null; + for (const wire of config.wires) { + const candidate = nearestGridPointOnWire(config, wire, x, y, 18); + if (candidate && (!best || candidate.distance < best.distance)) best = { ...candidate, wire }; + } + if (best) return { token: pointToken(config, best.x, best.y), x: best.x, y: best.y, kind: "wire" }; + if (!allowEmpty) return null; + const px = clamp(snap(x), GRID, canvasWidth(config) - GRID), py = clamp(snap(y), GRID, canvasHeight(config) - GRID); + return { token: pointToken(config, px, py), x: px, y: py, kind: "empty" }; + } + + function canvasPoint(canvas, event) { + const rect = canvas.getBoundingClientRect(); + return { x: (event.clientX - rect.left) * canvas.width / Math.max(1, rect.width), y: (event.clientY - rect.top) * canvas.height / Math.max(1, rect.height) }; + } + + function terminalPlacement(config, x, y) { + const width = canvasWidth(config), height = canvasHeight(config); + const distances = [ + { side: "left", d: x }, + { side: "right", d: width - x }, + { side: "top", d: y }, + { side: "bottom", d: height - y }, + ].sort((a, b) => a.d - b.d); + const side = distances[0].side; + const axis = isVerticalSide(side) ? y : x; + return { side, offset: safeOffset(config, side, axis) }; + } + + function nextPortId(config, direction) { + const prefix = direction === "input" ? "I" : "O"; + const portsList = direction === "input" ? config.inputs : config.outputs; + const used = new Set(portsList.map(port => port.id)); + let index = 1; + while (used.has(`${prefix}${index}`)) index += 1; + return `${prefix}${index}`; + } + + function ensureDialog() { + if (typeof document === "undefined") return null; + let dialog = document.getElementById("circuitBoardEditor"); + if (dialog) return dialog; + dialog = document.createElement("div"); + dialog.id = "circuitBoardEditor"; + dialog.className = "rotator-editor circuit-grid-editor hidden"; + dialog.innerHTML = ``; + document.body.appendChild(dialog); + return dialog; + } + + function openEditor(board, worldRef = global.world) { + if (!board || board.type !== "circuit_board") return false; + const dialog = ensureDialog(); + const canvas = dialog?.querySelector("#circuitGridCanvas"); + if (!dialog || !canvas) return false; + const ctx = canvas.getContext("2d"); + let draft = serializeConfig(board); + let tool = "wire"; + let wireDraft = null; + let drag = null; + let placementRotation = 0; + let hoverPoint = null; + const undo = []; + const colsInput = dialog.querySelector("#circuitGridCols"); + const rowsInput = dialog.querySelector("#circuitGridRows"); + + const snapshot = () => { + undo.push(clone(draft)); + if (undo.length > 40) undo.shift(); + }; + const restoreUndo = () => { + if (!undo.length) return; + draft = normalizeConfig(undo.pop()); + wireDraft = null; + syncControls(); + resizeCanvas(); + draw(); + }; + const syncControls = () => { + if (colsInput) colsInput.value = String(draft.cols); + if (rowsInput) rowsInput.value = String(draft.rows); + }; + const resizeCanvas = () => { + const width = canvasWidth(draft), height = canvasHeight(draft); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + }; + const setTool = next => { + const changedTool = tool !== next; + tool = next; + if (changedTool) placementRotation = 0; + wireDraft = null; + drag = null; + dialog.querySelectorAll("[data-circuit-tool]").forEach(button => button.classList.toggle("active", button.dataset.circuitTool === tool)); + canvas.style.cursor = tool === "select" ? "grab" : (tool === "erase" ? "not-allowed" : "crosshair"); + draw(); + }; + const drawGrid = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#14231b"; ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = "rgba(196,225,203,0.12)"; ctx.lineWidth = 1; + for (let x = 0; x <= canvas.width; x += GRID) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); } + for (let y = 0; y <= canvas.height; y += GRID) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } + ctx.strokeStyle = "rgba(225,195,80,0.55)"; ctx.lineWidth = 2; ctx.strokeRect(1, 1, canvas.width - 2, canvas.height - 2); + }; + const drawLine = (a, b, preview = false) => { + ctx.strokeStyle = preview ? "rgba(255,236,125,0.62)" : "rgba(223,192,71,0.94)"; + ctx.lineWidth = preview ? 2 : 3; + if (preview) ctx.setLineDash([8, 6]); + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.setLineDash([]); + }; + const drawWire = wire => { + const path = wirePath(draft, wire); + if (path.length === 2) drawLine(path[0], path[1], false); + }; + const drawNode = (node, options = {}) => { + const size = nodeBodySize(node.type); + const labels = { and: "AND", not: "NOT", rectifier: ">|", on: "ON" }; + ctx.save(); + if (options.preview) ctx.globalAlpha *= 0.48; + ctx.translate(node.x, node.y); ctx.rotate(safeRotation(node.rotation) * Math.PI * 0.5); + ctx.fillStyle = node.type === "on" ? "#765f27" : "#26352d"; + ctx.strokeStyle = options.preview ? "rgba(255,236,125,0.96)" : "rgba(226,239,228,0.92)"; ctx.lineWidth = options.preview ? 3 : 2; + if (options.preview) ctx.setLineDash([7, 5]); + const x = -size.width * 0.5, y = -size.height * 0.5, r = Math.min(9, size.height * 0.3); + ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + size.width - r, y); ctx.quadraticCurveTo(x + size.width, y, x + size.width, y + r); ctx.lineTo(x + size.width, y + size.height - r); ctx.quadraticCurveTo(x + size.width, y + size.height, x + size.width - r, y + size.height); ctx.lineTo(x + r, y + size.height); ctx.quadraticCurveTo(x, y + size.height, x, y + size.height - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); ctx.fill(); ctx.stroke(); + ctx.fillStyle = "#f6fbf7"; ctx.font = `bold ${node.type === "and" ? 15 : 11}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; + ctx.fillText(labels[node.type] || "AND", 0, 0); + ctx.setLineDash([]); + ctx.restore(); + }; + const drawPlacementPreview = () => { + if (!hoverPoint) return; + if (GATE_TYPES.includes(tool)) { + const pos = clampNodePosition(draft, tool, placementRotation, hoverPoint.x, hoverPoint.y); + const ghost = { id: "__placement_preview__", type: tool, x: pos.x, y: pos.y, rotation: placementRotation }; + drawNode(ghost, { preview: true }); + ctx.save(); + ctx.globalAlpha = 0.62; + for (const pin of nodePins(ghost)) { + ctx.fillStyle = pin.role === "source" ? "#68c8ff" : "#ff9a62"; + if (tool === "on" && pin.role === "source") ctx.fillStyle = "#ffe45c"; + ctx.strokeStyle = "rgba(8,22,14,0.95)"; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(pin.x, pin.y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + ctx.restore(); + return; + } + if (tool === "input_port" || tool === "output_port") { + const direction = tool === "input_port" ? "input" : "output"; + const placement = nearestAvailablePortPlacement(direction, hoverPoint.x, hoverPoint.y); + if (!placement) return; + const pos = canvasPortPosition(draft, placement); + ctx.save(); ctx.globalAlpha = 0.58; + ctx.fillStyle = direction === "input" ? "#68c8ff" : "#ff9a62"; + ctx.strokeStyle = "rgba(255,236,125,0.96)"; ctx.lineWidth = 3; ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.arc(pos.x, pos.y, 10, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.setLineDash([]); ctx.restore(); + } + }; + + const drawPins = () => { + for (const pin of allPins(draft)) { + const external = pin.kind === "external"; + const externalConnected = external ? isPortExternallyConnected(board, pin.token, worldRef) : true; + ctx.fillStyle = pin.role === "source" ? "#68c8ff" : "#ff9a62"; + if (pin.node?.type === "on" && pin.role === "source") ctx.fillStyle = "#ffe45c"; + if (external && !externalConnected) ctx.fillStyle = "#3d4842"; + ctx.strokeStyle = external && !externalConnected ? "rgba(111,126,117,0.72)" : "rgba(8,22,14,0.95)"; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(pin.x, pin.y, external ? 9 : 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + if (external) { + ctx.fillStyle = externalConnected ? "#f5fbf5" : "#748078"; ctx.font = "bold 13px ui-rounded, sans-serif"; ctx.textBaseline = "middle"; + const side = pin.port?.side || "left"; + ctx.textAlign = side === "left" ? "left" : (side === "right" ? "right" : "center"); + const dx = side === "left" ? 14 : (side === "right" ? -14 : 0); + const dy = side === "top" ? 16 : (side === "bottom" ? -16 : 0); + ctx.fillText(pin.label, pin.x + dx, pin.y + dy); + } + } + for (const junction of junctionTokens(draft)) { + if (junction.count < 2) continue; + ctx.fillStyle = "#e8c440"; ctx.strokeStyle = "rgba(8,22,14,0.95)"; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(junction.x, junction.y, junction.count >= 3 ? 7 : 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + }; + function draw() { + resizeCanvas(); + drawGrid(); + for (const wire of draft.wires) drawWire(wire); + for (const node of draft.nodes) drawNode(node); + drawPins(); + drawPlacementPreview(); + if (wireDraft) { + const start = endpointPosition(draft, wireDraft.startToken); + if (start) drawLine(start, wireDraft.preview, true); + } + } + + function addNode(type, x, y, rotation = 0) { + if (draft.nodes.length >= MAX_NODES) { global.showToast?.("\u56de\u8def\u90e8\u54c1\u306f36\u500b\u307e\u3067\u3067\u3059\u3002"); return false; } + snapshot(); + const id = `g${Date.now().toString(36).slice(-6)}${draft.nodes.length.toString(36)}`; + const safePlacementRotation = safeRotation(rotation); + const pos = clampNodePosition(draft, type, safePlacementRotation, x, y); + draft.nodes.push({ id, type, x: pos.x, y: pos.y, rotation: safePlacementRotation }); + draft = normalizeConfig(draft); draw(); return true; + } + + function addStraightConnection(startToken, endToken) { + if (!startToken || !endToken || startToken === endToken || draft.wires.length >= MAX_WIRES) return false; + const key = wireKey(startToken, endToken); + if (draft.wires.some(wire => wireKey(wire.a, wire.b) === key)) return false; + draft.wires.push({ a: startToken, b: endToken }); + return true; + } + + function eraseAt(x, y) { + const external = nearestPin(draft, x, y, 18); + if (external?.kind === "external") return removePort(external); + const junction = nearestJunction(draft, x, y, 13); + if (junction) { + snapshot(); + draft.wires = draft.wires.filter(wire => wire.a !== junction.token && wire.b !== junction.token); + draw(); return true; + } + const node = nodeAt(draft, x, y); + if (node) { + snapshot(); + draft.nodes = draft.nodes.filter(value => value.id !== node.id); + draft.wires = draft.wires.filter(wire => !wire.a.startsWith(`n:${node.id}:`) && !wire.b.startsWith(`n:${node.id}:`)); + draw(); return true; + } + const hitWire = wireAt(draft, x, y, 10); + if (hitWire) { snapshot(); draft.wires.splice(hitWire.index, 1); draw(); return true; } + return false; + } + + function portToken(direction, id) { + return `${direction === "input" ? "i" : "o"}:${id}`; + } + + function portSlotKey(side, offset) { + return `${side}:${safeOffset(draft, side, offset)}`; + } + + function nearestAvailablePortPlacement(direction, x, y, ignoreToken = "") { + const occupied = new Set(); + for (const port of draft.inputs) if (`i:${port.id}` !== ignoreToken) occupied.add(portSlotKey(port.side, port.offset)); + for (const port of draft.outputs) if (`o:${port.id}` !== ignoreToken) occupied.add(portSlotKey(port.side, port.offset)); + const candidates = []; + for (const side of SIDES) { + for (let offset = GRID; offset <= sideExtent(draft, side) - GRID; offset += GRID) { + const key = portSlotKey(side, offset); + if (occupied.has(key)) continue; + const pos = canvasPortPosition(draft, { side, offset }); + candidates.push({ side, offset, distance: Math.hypot(pos.x - x, pos.y - y) }); + } + } + candidates.sort((a, b) => a.distance - b.distance); + return candidates[0] || null; + } + + function addPort(direction, x, y) { + const list = direction === "input" ? draft.inputs : draft.outputs; + const limit = portLimit(draft); + if (list.length >= limit) { + global.showToast?.(`\u7e26\u5e45${draft.rows}\u30de\u30b9\u3067\u306f${direction === "input" ? "\u5165\u529b" : "\u51fa\u529b"}\u7aef\u5b50\u306f${limit}\u500b\u307e\u3067\u3067\u3059\u3002`); + return false; + } + const placement = nearestAvailablePortPlacement(direction, x, y); + if (!placement) { global.showToast?.("\u7aef\u5b50\u3092\u7f6e\u3051\u308b\u7a7a\u304d\u4f4d\u7f6e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return false; } + snapshot(); + list.push({ id: nextPortId(draft, direction), side: placement.side, offset: placement.offset }); + draft = normalizeConfig(draft); + draw(); + return true; + } + + function removePort(pin) { + if (!pin?.kind || pin.kind !== "external") return false; + if (isPortExternallyConnected(board, pin.token, worldRef)) { + global.showToast?.("\u96fb\u7dda\u3092\u5916\u3057\u3066\u304b\u3089\u7aef\u5b50\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); + return false; + } + snapshot(); + const list = pin.direction === "input" ? draft.inputs : draft.outputs; + const index = list.findIndex(port => port.id === pin.port?.id); + if (index < 0) return false; + const [removed] = list.splice(index, 1); + const token = portToken(pin.direction, removed.id); + draft.wires = draft.wires.filter(wire => wire.a !== token && wire.b !== token); + draft = normalizeConfig(draft); + draw(); + return true; + } + + function resizeGrid(cols, rows) { + const nextCols = safeCols(cols); + const nextRows = safeRows(rows); + const requiredRows = Math.max(draft.inputs.length, draft.outputs.length) + 1; + if (nextRows < requiredRows) { + global.showToast?.(`\u73fe\u5728\u306e\u7aef\u5b50\u6570\u3092\u4fdd\u3064\u306b\u306f\u7e26\u5e45${requiredRows}\u30de\u30b9\u4ee5\u4e0a\u304c\u5fc5\u8981\u3067\u3059\u3002`); + syncControls(); + return; + } + if (nextCols === draft.cols && nextRows === draft.rows) return; + snapshot(); + draft.cols = nextCols; + draft.rows = nextRows; + draft = normalizeConfig(draft); + syncControls(); + resizeCanvas(); + draw(); + } + + canvas.onpointerdown = event => { + const p = canvasPoint(canvas, event); + hoverPoint = p; + if (tool === "select") { + const external = nearestPin(draft, p.x, p.y, 20); + const junction = nearestJunction(draft, p.x, p.y, 15); + const node = nodeAt(draft, p.x, p.y); + if (external?.kind === "external") { snapshot(); drag = { kind: "port", token: external.token }; canvas.style.cursor = "grabbing"; return; } + if (junction) { snapshot(); drag = { kind: "junction", token: junction.token }; canvas.style.cursor = "grabbing"; return; } + if (node) { snapshot(); drag = { kind: "node", id: node.id, dx: p.x - node.x, dy: p.y - node.y }; canvas.style.cursor = "grabbing"; return; } + return; + } + if (tool === "erase") { eraseAt(p.x, p.y); return; } + if (tool === "rotate") { + const node = nodeAt(draft, p.x, p.y); + if (node) { snapshot(); node.rotation = safeRotation(node.rotation + 1); const pos = clampNodePosition(draft, node.type, node.rotation, node.x, node.y); node.x = pos.x; node.y = pos.y; draw(); } + return; + } + if (tool === "input_port") { addPort("input", p.x, p.y); return; } + if (tool === "output_port") { addPort("output", p.x, p.y); return; } + if (GATE_TYPES.includes(tool)) { addNode(tool, p.x, p.y, placementRotation); return; } + if (tool !== "wire") return; + if (!wireDraft) { + const anchor = anchorAt(draft, p.x, p.y, false); + if (!anchor) return; + snapshot(); + if (anchor.kind === "wire") splitWiresAtPoint(draft, anchor.x, anchor.y); + wireDraft = { startToken: anchor.token, preview: { x: anchor.x, y: anchor.y } }; + draw(); + return; + } + const end = anchorAt(draft, p.x, p.y, true); + if (!end) { wireDraft = null; draw(); return; } + if (end.kind === "wire") splitWiresAtPoint(draft, end.x, end.y); + addStraightConnection(wireDraft.startToken, end.token); + wireDraft = null; + draft = normalizeConfig(draft); + draw(); + }; + + canvas.onpointermove = event => { + const p = canvasPoint(canvas, event); + hoverPoint = p; + if (drag?.kind === "node") { + const node = draft.nodes.find(value => value.id === drag.id); + if (node) { const pos = clampNodePosition(draft, node.type, node.rotation, p.x - drag.dx, p.y - drag.dy); node.x = pos.x; node.y = pos.y; draw(); } + return; + } + if (drag?.kind === "junction") { + drag.token = moveJunction(draft, drag.token, p.x, p.y); + draw(); + return; + } + if (drag?.kind === "port") { + const port = [...draft.inputs.map(value => ({ ...value, _direction: "input" })), ...draft.outputs.map(value => ({ ...value, _direction: "output" }))] + .find(value => `${value._direction === "input" ? "i" : "o"}:${value.id}` === drag.token); + const actual = port?._direction === "input" ? draft.inputs.find(value => value.id === port.id) : draft.outputs.find(value => value.id === port?.id); + if (actual) { + const next = nearestAvailablePortPlacement(port._direction, p.x, p.y, drag.token); + if (next) { actual.side = next.side; actual.offset = next.offset; draw(); } + } + return; + } + if (wireDraft) wireDraft.preview = { x: clamp(snap(p.x), GRID, canvas.width - GRID), y: clamp(snap(p.y), GRID, canvas.height - GRID) }; + draw(); + }; + + canvas.onpointerup = () => { + if (drag) { draft = normalizeConfig(draft); drag = null; canvas.style.cursor = "grab"; syncControls(); draw(); } + }; + canvas.onpointercancel = () => { drag = null; wireDraft = null; draw(); }; + canvas.onpointerleave = () => { hoverPoint = null; if (!drag) draw(); }; + canvas.oncontextmenu = event => { event.preventDefault(); if (wireDraft) { wireDraft = null; draw(); } }; + const handleEditorKeydown = event => { + const key = String(event.key || "").toLowerCase(); + if ((key !== "q" && key !== "e") || !GATE_TYPES.includes(tool)) return; + const tag = String(event.target?.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || event.target?.isContentEditable) return; + event.preventDefault(); event.stopPropagation(); + placementRotation = safeRotation(placementRotation + (key === "q" ? -1 : 1)); + draw(); + }; + document.addEventListener("keydown", handleEditorKeydown, true); + + dialog.querySelectorAll("[data-circuit-tool]").forEach(button => { button.onclick = () => setTool(button.dataset.circuitTool); }); + dialog.querySelectorAll("[data-grid-delta]").forEach(button => { + button.onclick = () => { + const [axis, deltaRaw] = String(button.dataset.gridDelta || "").split(":"); + const delta = Number(deltaRaw || 0); + resizeGrid(axis === "cols" ? draft.cols + delta : draft.cols, axis === "rows" ? draft.rows + delta : draft.rows); + }; + }); + if (colsInput) colsInput.onchange = () => resizeGrid(colsInput.value, draft.rows); + if (rowsInput) rowsInput.onchange = () => resizeGrid(draft.cols, rowsInput.value); + dialog.querySelector("[data-action=undo]").onclick = restoreUndo; + dialog.querySelector("[data-action=clear]").onclick = () => { snapshot(); draft.nodes = []; draft.wires = []; draw(); }; + dialog.querySelector("[data-action=reset]").onclick = () => { snapshot(); draft = defaultConfig(); syncControls(); resizeCanvas(); draw(); }; + const close = () => { + dialog.classList.add("hidden"); + document.removeEventListener("keydown", handleEditorKeydown, true); + canvas.onpointerdown = null; canvas.onpointermove = null; canvas.onpointerup = null; canvas.onpointercancel = null; canvas.onpointerleave = null; canvas.oncontextmenu = null; + }; + dialog.querySelector("[data-action=close]").onclick = close; + 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"); } + global.showToast?.("\u57fa\u76e4\u306e\u56de\u8def\u3092\u53cd\u6620\u3057\u307e\u3057\u305f\u3002"); + global.render?.(); close(); + }; + dialog.onclick = event => { if (event.target === dialog) close(); }; + syncControls(); resizeCanvas(); setTool("wire"); dialog.classList.remove("hidden"); draw(); + return true; + } + + global.TarinaiCircuitBoardSystem = Object.freeze({ + GATE_TYPES, HALF_W, HALF_H, CANVAS_W, CANVAS_H, GRID, DEFAULT_COLS, DEFAULT_ROWS, MIN_COLS, MAX_COLS, MIN_ROWS, MAX_ROWS, + defaultConfig, normalizeConfig, ensureConfig, serializeConfig, applySerializedConfig, + ports, portById, localPortPosition, portWorld, nearestPort, terminal, evaluate, openEditor, + allPins, pinByToken, wirePath, endpointPosition, boardHalfWidth, boardHalfHeight, portLimit, nodeBodySize, nodePlacementBounds, clampNodePosition, junctionTokens, moveJunction, remapAttachedWireEndpoints, isPortExternallyConnected, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index 3c87417..6bf87c4 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -59,6 +59,12 @@ const solid = worldRef?.solidObstacleRects?.(item) || []; if (solid.length) return solid; if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || []; + if (item?.type === "circuit_board") { + const circuit = global.TarinaiCircuitBoardSystem; + const halfW = Number(circuit?.boardHalfWidth?.(item) || 32) || 32; + const halfH = Number(circuit?.boardHalfHeight?.(item) || 32) || 32; + return [{ oriented: true, cx: num(item.x), cy: num(item.y), halfW, halfH, angle: num(item.angle), type: item.type, item }]; + } return solid; } diff --git a/js/colony_situation_system.js b/js/colony_situation_system.js index 0da8836..63937f8 100644 --- a/js/colony_situation_system.js +++ b/js/colony_situation_system.js @@ -35,10 +35,14 @@ const nearestAvg = nearestAverage(alive); const fieldId = String(worldRef?.fieldType || "garden"); const overcrowdLimit = fieldId === "cage" ? 28 : (fieldId === "park" ? 140 : 75); + const previousPopulation = Number(worldRef?.lastColonyMoodPopulation); + const populationIncreasing = Number.isFinite(previousPopulation) && n > previousPopulation; return { now, alive, n, + previousPopulation, + populationIncreasing, avgEnergy: avgOf(alive, t => t.energy || 0), avgStress: avgOf(alive, t => t.stress || 0), avgHunger: avgOf(alive, t => t.hunger || 0), @@ -70,7 +74,7 @@ add("confusion", 100, (m.recentShock && (m.panicRatio >= 0.10 || m.safetyRatio >= 0.18)) || m.panicRatio >= 0.25); 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.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34); + 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("happy", 46, m.avgStress < 28 && m.panicRatio < 0.08 && m.diseaseRatio < 0.12 && m.weakRatio < 0.18); add("overcrowded", 34, m.n >= m.overcrowdLimit || m.denseCrowd); @@ -87,7 +91,9 @@ const id = choose(worldRef, metrics); const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } }; worldRef.colonyMood = next; - worldRef.nextColonyMoodEvalAt = now + 1.25; + worldRef.lastColonyMoodPopulation = metrics.n; + const dayLength = Math.max(1, Number(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120); + worldRef.nextColonyMoodEvalAt = now + dayLength / 12; global.TarinaiAchievements?.evaluateWorld?.(worldRef, next); return next; } diff --git a/js/deterministic_helpers.js b/js/deterministic_helpers.js index 41723fc..d280a6e 100644 --- a/js/deterministic_helpers.js +++ b/js/deterministic_helpers.js @@ -5,7 +5,6 @@ // and visual effects. Previously this bootstrap lived inline in several // modules; keeping it here prevents helper drift and load-order surprises. (function ensureDeterministicHelpers(global) { - if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return; const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0)); const keyPart = value => { if (value == null) return "null"; @@ -28,10 +27,48 @@ }; const deterministicFrame = (worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6); const deterministicUnit = (worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")); + const finiteOr = (value, fallback = 0) => { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + }; + const clampNumber = (value, min, max) => Math.max(min, Math.min(max, finiteOr(value, min))); + const isPlainObject = value => Boolean(value && typeof value === "object" && !Array.isArray(value)); + const clonePlain = (value, fallback = {}) => { + if (value == null) return fallback; + try { + if (typeof structuredClone === "function") return structuredClone(value); + } catch (_) {} + return JSON.parse(JSON.stringify(value)); + }; + const birthHash32 = (seed = "") => { + const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || "")); + if (match) { + const parsed = parseInt(match[1], 36); + if (Number.isFinite(parsed)) return parsed >>> 0; + } + let h = 2166136261; + const value = String(seed || ""); + for (let i = 0; i < value.length; i++) { + h ^= value.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; + }; + const isEffectCauseText = (text = "") => /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, "")); global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0))); global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1); global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && deterministicUnit(worldRef, salt, ...parts) < p); }); global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts)); + global.TarinaiCoreHelpers = Object.freeze({ + ...(global.TarinaiCoreHelpers || {}), + finiteOr, + clampNumber, + isPlainObject, + clonePlain, + birthHash32, + isEffectCauseText, + }); + global.isTarinaiEffectCauseText = global.isTarinaiEffectCauseText || isEffectCauseText; })(typeof window !== "undefined" ? window : globalThis); // Keep the historical bare high-level helper names available to legacy scripts while the diff --git a/js/geometry_helpers.js b/js/geometry_helpers.js index 00b783d..f83103f 100644 --- a/js/geometry_helpers.js +++ b/js/geometry_helpers.js @@ -4,14 +4,11 @@ // Shared primitive geometry helpers used by placement, collision, mechanical bodies, // editor hit-testing, and world obstacle queries. (function (global) { - function num(v, fallback = 0) { + const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; - } - - function clampValue(v, min, max) { - return Math.max(min, Math.min(max, v)); - } + }); + const clampValue = global.TarinaiCoreHelpers?.clampNumber || ((v, min, max) => Math.max(min, Math.min(max, v))); function orientedRectAabb(cx, cy, halfW, halfH, angle) { const c = Math.cos(num(angle)); diff --git a/js/item_dynamic_tool_system.js b/js/item_dynamic_tool_system.js index beff6fe..80868ad 100644 --- a/js/item_dynamic_tool_system.js +++ b/js/item_dynamic_tool_system.js @@ -84,6 +84,7 @@ if (old && old !== carrier) { old.surpriseTimer = Math.max(old.surpriseTimer || 0, 0.18); old.thought = "\u7c98\u7740\u30dc\u30e0\u304c\u79fb\u3063\u305f"; + if (reason === "transfer") global.TarinaiAchievements?.recordStickyBombPass?.(item, { world: worldRef, from: old, to: carrier }); } if (worldRef?.effects && typeof Effect !== "undefined") { worldRef.effects.push(new Effect("ring", item.x, item.y, { size: Math.max(16, (item.r || 16) * 1.45), life: 0.18, color: reason === "transfer" ? "rgba(255,210,72,0.52)" : "rgba(255,128,78,0.48)" })); diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js index ef207a5..4c4482e 100644 --- a/js/item_lifecycle_support.js +++ b/js/item_lifecycle_support.js @@ -76,6 +76,7 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o function duplicatorApplyStoredPin(duplicator, worldRef) { const storedType = String(duplicator?.storedFoodType || ""); if (!duplicator || duplicator.type !== "duplicator" || !isPinType(storedType)) return false; + if (globalThis.TarinaiSignalSystem?.powerOverride?.(duplicator) === false) return false; const now = Number(worldRef?.time || 0); if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false; const dish = duplicatorLoadPoint(duplicator); diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index 119f888..1af3332 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -549,6 +549,7 @@ draw(ctx, t, lighting = null) { } else if (this.type === "duplicator") { ctx.save(); const loaded = !!this.storedFoodType; + const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false; const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9); bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6"); bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7"); @@ -566,7 +567,7 @@ draw(ctx, t, lighting = null) { ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)"; ctx.stroke(); - ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)"; + ctx.fillStyle = !powered ? "rgba(180,76,70,0.72)" : (loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)"); ctx.beginPath(); ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2); ctx.fill(); @@ -624,6 +625,76 @@ draw(ctx, t, lighting = null) { ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2); ctx.fill(); ctx.restore(); + } else if (this.type === "seesaw") { + const r = this.r || 60; + const tilt = Number(this.seesawTilt || 0) || 0; + ctx.save(); + ctx.fillStyle = styleNight ? "rgba(81,68,57,0.96)" : "rgba(130,99,69,0.98)"; + ctx.strokeStyle = styleNight ? "rgba(41,34,29,0.86)" : "rgba(78,55,37,0.82)"; + ctx.lineWidth = 2.4; + ctx.beginPath(); + ctx.moveTo(-r * 0.22, r * 0.42); + ctx.lineTo(0, -r * 0.05); + ctx.lineTo(r * 0.22, r * 0.42); + ctx.closePath(); ctx.fill(); ctx.stroke(); + ctx.save(); + ctx.rotate(tilt); + const grad = ctx.createLinearGradient(0, -r * 0.14, 0, r * 0.14); + grad.addColorStop(0, styleNight ? "#c5895c" : "#f0ad6f"); + grad.addColorStop(1, styleNight ? "#8d5d3f" : "#ba7447"); + ctx.fillStyle = grad; + ctx.strokeStyle = styleNight ? "rgba(57,37,29,0.9)" : "rgba(104,59,35,0.85)"; + roundedRect(ctx, -r * 1.08, -r * 0.13, r * 2.16, r * 0.26, r * 0.08); + ctx.fill(); ctx.stroke(); + for (const side of [-1, 1]) { + ctx.fillStyle = side < 0 ? "rgba(88,177,231,0.98)" : "rgba(239,111,116,0.98)"; + roundedRect(ctx, side * r * 0.79 - r * 0.18, -r * 0.26, r * 0.36, r * 0.22, r * 0.07); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = "rgba(70,55,45,0.75)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(side * r * 0.90, -r * 0.22); + ctx.lineTo(side * r * 0.90, -r * 0.52); + ctx.stroke(); + } + ctx.restore(); + ctx.fillStyle = "rgba(70,58,48,0.9)"; + ctx.beginPath(); ctx.arc(0, 0, r * 0.13, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } else if (this.type === "circuit_board") { + const circuit = globalThis.TarinaiCircuitBoardSystem; + const angle = Number(this.angle || 0) || 0; + const active = Object.values(this._circuitOutputState || {}).some(Boolean); + ctx.save(); + ctx.rotate(angle); + ctx.fillStyle = active ? "rgba(44,139,82,0.98)" : (styleNight ? "rgba(41,91,62,0.98)" : "rgba(47,126,76,0.98)"); + ctx.strokeStyle = styleNight ? "rgba(19,48,31,0.92)" : "rgba(26,74,42,0.90)"; + ctx.lineWidth = 2.4; + const boardHalfW = Number(circuit?.boardHalfWidth?.(this) ?? circuit?.HALF_W ?? 32) || 32; + const boardHalfH = Number(circuit?.boardHalfHeight?.(this) ?? circuit?.HALF_H ?? 32) || 32; + roundedRect(ctx, -boardHalfW, -boardHalfH, boardHalfW * 2, boardHalfH * 2, 10); ctx.fill(); ctx.stroke(); + ctx.strokeStyle = "rgba(216,190,81,0.64)"; + ctx.lineWidth = 1.4; + const traceRows = Math.max(1, Math.min(7, Math.floor(boardHalfH / 9))); + for (let i = -traceRows; i <= traceRows; i++) { + const y = i * Math.max(5, boardHalfH / Math.max(2, traceRows * 1.7)); + ctx.beginPath(); ctx.moveTo(-boardHalfW * 0.72, y); ctx.lineTo(-boardHalfW * 0.28, y); ctx.lineTo(0, y * 0.45); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(boardHalfW * 0.72, y); ctx.lineTo(boardHalfW * 0.28, y); ctx.lineTo(0, y * 0.45); ctx.stroke(); + } + ctx.fillStyle = "rgba(30,39,34,0.94)"; + roundedRect(ctx, -15, -16, 30, 32, 5); ctx.fill(); + ctx.fillStyle = "rgba(236,245,235,0.92)"; + ctx.font = "bold 11px ui-rounded, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; + ctx.fillText("LOGIC", 0, 0); + for (const port of circuit?.ports?.(this) || []) { + const local = circuit.localPortPosition(port, this); + const on = port.direction === "input" ? Boolean(this._circuitInputState?.[port.id]) : Boolean(this._circuitOutputState?.[port.id]); + ctx.fillStyle = on ? "rgba(255,228,91,1)" : (port.direction === "input" ? "rgba(92,189,255,0.96)" : "rgba(255,143,77,0.96)"); + ctx.strokeStyle = "rgba(25,35,31,0.88)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); ctx.arc(local.x, local.y, 5.4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + ctx.restore(); } else if (this.type === "signboard") { ctx.save(); const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12); @@ -685,8 +756,9 @@ draw(ctx, t, lighting = null) { } else if (this.type === "dry_ice") { const r = this.r || 21; const time = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0); + const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false; ctx.save(); - if (itemShadowsEnabled()) { + if (powered && itemShadowsEnabled()) { ctx.shadowColor = "rgba(90, 178, 255, 0.30)"; ctx.shadowBlur = 11; } @@ -737,7 +809,7 @@ draw(ctx, t, lighting = null) { roundedRect(ctx, -bodyW * 0.48, y + bodyH - footH * 0.4, bodyW * 0.96, footH, r * 0.10); ctx.fill(); - for (let i = 0; i < 4; i++) { + if (powered) for (let i = 0; i < 4; i++) { const phase = (time * (0.42 + i * 0.06) + i * 0.23 + (this.seed || 0)) % 1; const sx = -r * 0.50 + i * r * 0.33 + Math.sin(time * 1.4 + i) * r * 0.06; const sy = y - capH * 0.62 - phase * r * 0.76; @@ -750,8 +822,9 @@ draw(ctx, t, lighting = null) { ctx.restore(); } else if (this.type === "stove") { const r = this.r || 24; + const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false; ctx.save(); - if (itemShadowsEnabled()) { + if (powered && itemShadowsEnabled()) { ctx.shadowColor = "rgba(255, 120, 43, 0.34)"; ctx.shadowBlur = 9; } @@ -764,17 +837,19 @@ draw(ctx, t, lighting = null) { ctx.fillStyle = "rgba(42, 32, 28, 0.92)"; roundedRect(ctx, -r * 0.48, -r * 0.34, r * 0.96, r * 0.62, r * 0.14); ctx.fill(); - const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0)); - ctx.fillStyle = "rgba(255, 168, 57, 0.92)"; - ctx.beginPath(); - ctx.moveTo(0, r * 0.16); - ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame); - ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16); - ctx.fill(); - ctx.fillStyle = "rgba(255, 235, 120, 0.82)"; - ctx.beginPath(); - ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2); - ctx.fill(); + if (powered) { + const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0)); + ctx.fillStyle = "rgba(255, 168, 57, 0.92)"; + ctx.beginPath(); + ctx.moveTo(0, r * 0.16); + ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame); + ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16); + ctx.fill(); + ctx.fillStyle = "rgba(255, 235, 120, 0.82)"; + ctx.beginPath(); + ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2); + ctx.fill(); + } ctx.restore(); } else if (this.type === "ant_nest") { const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); @@ -873,9 +948,11 @@ draw(ctx, t, lighting = null) { const r = this.r || 26; const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this); const windAngle = itemAngleFor(this); - const spin = (this.fanSpin || 0) + (window.TarinaiSignalSystem?.powerOverride?.(this) === false ? 0 : t * 10.5); + const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false; + const spin = (this.fanSpin || 0) + (powered ? t * 10.5 : 0); ctx.save(); ctx.rotate(windAngle); + if (powered) { const windRange = 320; const windStart = r * 1.02; const windSpread = 0.62; @@ -934,6 +1011,7 @@ draw(ctx, t, lighting = null) { ctx.stroke(); } ctx.globalAlpha = fanAlpha; + } ctx.restore(); ctx.save(); ctx.rotate(bodyAngle); @@ -1235,7 +1313,7 @@ draw(ctx, t, lighting = null) { ctx.beginPath(); ctx.roundRect?.(-27,-18,54,36,8); if (!ctx.roundRect) { ctx.rect(-27,-18,54,36); } ctx.fill(); ctx.stroke(); ctx.fillStyle = active ? "#fff6c9" : "#7b838d"; ctx.beginPath(); ctx.arc(0,-3,9,0,Math.PI*2); ctx.fill(); const detectorMark = { tarinai:"\u305f", item:"\u7269", time:"\u6642", hunger_avg:"\u8179", stress_avg:"\u5727", sleep_avg:"\u7720", low_hp_count:"HP", sick_count:"\u75c5", temperature:"\u2103", zunchi_count:"\u7cde", ant_count:"\u87fb", water_count:"\u6c34" }[this.pressureTarget] || "\u305f"; - const detectorLabel = this.pressureTarget === "time" ? detectorMark : `${detectorMark}${Number(this.pressureThreshold ?? 1)}`; + const detectorLabel = this.pressureTarget === "time" ? detectorMark : `${detectorMark}${Number(this.pressureMin ?? 1)}-${Number(this.pressureMax ?? 300)}`; ctx.fillStyle = styleNight ? "#eef2f6" : "#26323e"; ctx.font="bold 10px ui-rounded, sans-serif"; ctx.textAlign="center"; ctx.fillText(detectorLabel,0,14); ctx.restore(); } else if (this.type === "rotator") { diff --git a/js/item_tool_definitions.js b/js/item_tool_definitions.js index fadbdbd..9a4850b 100644 --- a/js/item_tool_definitions.js +++ b/js/item_tool_definitions.js @@ -44,14 +44,14 @@ const TOOL_DEFINITIONS = Object.freeze({ bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u79fb\u52d5\u3067\u304d\u308b\u5730\u4e0b\u7a7a\u9593\u3002\u305f\u308a\u306a\u3044\u304c\u7720\u308b\u3053\u3068\u3082\u3042\u308b\u3002" }, - dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" }, - stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" }, + dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56f2\u306e\u6c17\u6e29\u309210\u2103\u4e0b\u3052\u308b\u3002\u52b9\u679c\u306f\u91cd\u306d\u3089\u308c\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" }, + stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56f2\u306e\u6c17\u6e29\u309210\u2103\u4e0a\u3052\u308b\u3002\u52b9\u679c\u306f\u91cd\u306d\u3089\u308c\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" }, ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, balloon: { id: "balloon", itemType: "balloon", label: "\u6c34\u98a8\u8239", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "\ud83d\udca7", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u306e\u304a\u3082\u3061\u3083\u3002\u5272\u308c\u308b\u3068\u6c34\u6ef4\u304c\u98db\u3073\u6563\u308b\u3002" }, - fan: { id: "fan", itemType: "fan", label: "\u6247\u98a8\u6a5f", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "\ud83c\udf00", collisionShape: "circle", tooltip: "\u524d\u65b9\u306b\u98a8\u3092\u51fa\u3059\u3002\u8efd\u3044\u3082\u306e\u3092\u62bc\u3057\u3001\u5c11\u3057\u6dbc\u3057\u304f\u3059\u308b\u3002Q/E\u3067\u7f6e\u304f\u5411\u304d\u3092\u5909\u3048\u308b\u3002\u914d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u958b\u304f\u3002" }, + fan: { id: "fan", itemType: "fan", label: "\u6247\u98a8\u6a5f", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "\ud83c\udf00", collisionShape: "circle", tooltip: "\u524d\u65b9\u306b\u98a8\u3092\u51fa\u3059\u3002\u8efd\u3044\u3082\u306e\u3092\u62bc\u3057\u3001\u5c11\u3057\u6dbc\u3057\u304f\u3059\u308b\u3002Q/E\u3067\u7f6e\u304f\u5411\u304d\u3092\u5909\u3048\u308b\u3002\u914d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u958b\u304f\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" }, signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" }, - duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" }, + duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f9b\u7d66\u3059\u308b\u3002" }, firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: false, scalable: false, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", simulationOnly: true }, flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\u3059\u3002" }, @@ -69,6 +69,8 @@ const TOOL_DEFINITIONS = Object.freeze({ poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 44, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" }, reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" }, pressure_switch: { id: "pressure_switch", itemType: "pressure_switch", label: "\u691c\u77e5\u5668", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "\u25a3", collisionShape: "circle", tooltip: "\u69d8\u3005\u306a\u72b6\u6cc1\u3092\u691c\u77e5\u3057\u3066\u96fb\u7dda\u306b\u4fe1\u53f7\u3092\u9001\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" }, + seesaw: { id: "seesaw", itemType: "seesaw", label: "\u30b7\u30fc\u30bd\u30fc", placeable: true, scalable: false, radius: 60, amount: 999, iconText: "\u2195", collisionShape: "circle", tooltip: "\u4e21\u7aef\u306b\u305f\u308a\u306a\u3044\u304c\u4e57\u308b\u3068\u4e8c\u5339\u3067\u904a\u3073\u3001\u4e92\u3044\u306b\u53cb\u597d\u3092\u7bc9\u304f\u3002" }, + circuit_board: { id: "circuit_board", itemType: "circuit_board", label: "\u57fa\u76e4", placeable: true, scalable: false, radius: 46, amount: 999, iconText: "\u25a6", collisionShape: "oriented_rect", tooltip: "\u96fb\u7dda\u306e\u4fe1\u53f7\u3067\u8ad6\u7406\u6f14\u7b97\u304c\u3067\u304d\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" }, water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" }, trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true }, diff --git a/js/item_tool_metadata.js b/js/item_tool_metadata.js index 3a7c722..9665b0d 100644 --- a/js/item_tool_metadata.js +++ b/js/item_tool_metadata.js @@ -3,21 +3,23 @@ // Static item/tool metadata shared by tool definitions and UI helpers. (function (global) { const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "rain_shelter", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch"]), + obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "rain_shelter", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "circuit_board"]), food_interest: new Set(["food", "sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "first_aid", "sedative", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]), hazard: new Set(["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), pin: new Set(["pushpin", "oshibyo"]), kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]), freeStackingMovable: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi", "water", "zunda_juice", "mercury", "genkotsu", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "ant_corpse"]), sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]), - draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "rain_shelter", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch"]), + draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "rain_shelter", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "seesaw", "circuit_board"]), }); const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence"])); const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"])); - const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h"]); - const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"])); + const ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board"]); + const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h", "circuit_board"]); + const PHYSICS_CATEGORY_TOOL_IDS = Object.freeze(PHYSICS_TOOL_IDS.filter(id => !ELECTRIC_TOOL_IDS.includes(id))); + const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan", "circuit_board"])); const PIN_BEHAVIORS = Object.freeze({ pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, @@ -28,8 +30,9 @@ { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] }, { id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["food", "sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] }, { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] }, - { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "toilet", "rain_shelter", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "signboard"] }, - { id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS }, + { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "toilet", "rain_shelter", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "seesaw", "signboard"] }, + { id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_CATEGORY_TOOL_IDS }, + { id: "electric", label: "\u96fb\u6c17", themeClass: "tool-category-electric", order: 46, toolIds: ELECTRIC_TOOL_IDS }, { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] }, ]); @@ -54,6 +57,8 @@ FENCE_ITEM_TYPES, MECHANICAL_ITEM_TYPES, PHYSICS_TOOL_IDS, + PHYSICS_CATEGORY_TOOL_IDS, + ELECTRIC_TOOL_IDS, ROTATABLE_ITEM_TYPES, PIN_BEHAVIORS, TOOL_CATEGORIES, diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 9d06169..703e487 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -256,10 +256,29 @@ function initializeItemTypeState(item, type, x, y) { }; } + if (type === "seesaw") { + item.amount = 999; + item.angle = 0; + item.seesawSeatIds = ["", ""]; + item.seesawPhase = 0; + item.seesawTilt = 0; + item.seesawPlayTime = 0; + item.deletable = true; + } + if (type === "circuit_board") { + item.amount = 999; + item.angle = defaultItemAngle(type); + item.circuitConfig = globalThis.TarinaiCircuitBoardSystem?.defaultConfig?.() || { v: 4, cols: 10, rows: 10, inputs: [], outputs: [], nodes: [], wires: [] }; + globalThis.TarinaiCircuitBoardSystem?.ensureConfig?.(item); + item._circuitInputState = Object.create(null); + item._circuitOutputState = Object.create(null); + item.deletable = true; + } + if (type === "pressure_switch") { item.pressureTarget = "tarinai"; - item.pressureComparator = "gte"; - item.pressureThreshold = 1; + item.pressureMin = 1; + item.pressureMax = 300; item.pressureWidth = 220; item.pressureHeight = 160; item.pressureTimeStart = 360; diff --git a/js/mechanical_system.js b/js/mechanical_system.js index 1d5e2ae..999a5d7 100644 --- a/js/mechanical_system.js +++ b/js/mechanical_system.js @@ -4,10 +4,10 @@ // Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53. (function (global) { const footprints = global.TarinaiCollisionFootprints; - function num(v, fallback = 0) { + const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; - } + }); function rectCorners(rect, padding = 0) { return footprints?.rectCorners?.(rect, padding) || []; diff --git a/js/physics_projection_system.js b/js/physics_projection_system.js index 99a6c2c..e06d852 100644 --- a/js/physics_projection_system.js +++ b/js/physics_projection_system.js @@ -4,7 +4,7 @@ // Shared tiny helpers for positional projection used by links, mechanical // bodies, and circle/circle collision code. (function (global) { - function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; } + const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; }); function markSpatialDirty(worldRef, item, reason = "projection", threshold = 0.35, minInterval = 0.065) { const world = worldRef || item?.world || null; diff --git a/js/physics_world_system.js b/js/physics_world_system.js index c8e0031..b6e2f3b 100644 --- a/js/physics_world_system.js +++ b/js/physics_world_system.js @@ -7,17 +7,15 @@ const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]); const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]); - function num(value, fallback = 0) { + const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => { const n = Number(value); return Number.isFinite(n) ? n : fallback; - } + }); function bool(value, fallback = true) { if (value == null) return !!fallback; return !!value; } - function clamp(value, min, max) { - return Math.max(min, Math.min(max, num(value, min))); - } + const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min)))); function isBodyType(typeOrItem) { const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || ""); return BODY_TYPES.has(type); @@ -271,6 +269,7 @@ x: num(endpoint.x), y: num(endpoint.y), attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null, + portId: String(endpoint.portId || ""), fuzzyResolve: endpoint.fuzzyResolve === true, supportLost: endpoint.supportLost === true, _ref: endpoint._ref || null, @@ -476,7 +475,7 @@ if (!ep) return null; const kind = ep.kind || "item"; const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1); - return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null }; + return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null, port: String(ep.portId || "") }; }; return { pf: 2, constraint: { type: c.type, kind: c.kind, endpoints: [epToSave(c.endpoints?.[0]), epToSave(c.endpoints?.[1])], length: Math.round(num(c.length, 80)), mid: [Math.round(num(c.mid?.x)), Math.round(num(c.mid?.y)), Math.round(num(c.mid?.vx) * 10), Math.round(num(c.mid?.vy) * 10)] } }; } @@ -513,7 +512,7 @@ const kind = ep.kind || "item"; const refIdx = Number(ep.refIdx); const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) : null; - return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) }); + return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), portId: String(ep.port || ""), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) }); }; const midArr = Array.isArray(p.mid) ? p.mid : []; item.physicsConstraint = normalizeConstraint({ type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : (item.type === "rod" ? "rigid-distance" : (item.type === "spring" ? "spring-distance" : "signal-wire"))), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item); diff --git a/js/placement_preview_system.js b/js/placement_preview_system.js index 0b8795b..c290186 100644 --- a/js/placement_preview_system.js +++ b/js/placement_preview_system.js @@ -5,10 +5,10 @@ // of placement blocking. Render and actual placement both delegate here so a // green preview means the same footprint the click will try to place. (function (global) { - function num(v, fallback = 0) { + const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; - } + }); function itemRadius(type, fallback = 12) { return global.itemRadiusFor(type, fallback); } @@ -181,8 +181,10 @@ } if (item.type === "rain_shelter") { const footprint = global.TarinaiParasolSystem?.parasolFootprint?.(item) || null; - if (footprint) return { shape: "ellipse", centerX: footprint.centerX, centerY: footprint.centerY, radiusX: footprint.radiusX, radiusY: footprint.radiusY }; + if (footprint) return { shape: "roundedRect", centerX: footprint.centerX, centerY: footprint.centerY, halfWidth: footprint.radiusX, halfHeight: footprint.radiusY, cornerRadius: Math.max(8, Math.min(18, footprint.radiusY * 0.32)) }; } + const def = global.toolDefinition?.(item.type) || null; + if (def?.collisionShape === "circle") return { shape: "circle", centerX: num(item.x), centerY: num(item.y), radius: Math.max(8, num(item.r, itemRadius(item.type, 12))) }; return null; } function geometryFor(worldRef, item) { diff --git a/js/render.js b/js/render.js index 27751e7..3734daa 100644 --- a/js/render.js +++ b/js/render.js @@ -175,6 +175,44 @@ function strokeWorldObstacleRect(ctx, rect) { return true; } +function drawWireConnectableHighlights(ctx, world) { + if (!world || !["wire", "insulated_wire"].includes(String(world.tool || ""))) return; + const signal = globalThis.TarinaiSignalSystem; + const circuit = globalThis.TarinaiCircuitBoardSystem; + if (!signal?.isSignalConnectable) return; + const pulse = 0.72 + Math.sin((world.time || 0) * 7.5) * 0.13; + const pointer = world.pointer || {}; + ctx.save(); + ctx.setLineDash([7, 5]); + const blockedTypes = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]); + for (const item of world.items || []) { + if (!item || item.dead || item.type === "splat" || blockedTypes.has(item.type)) continue; + const signalCapable = signal.isSignalConnectable(item); + if (item.type === "circuit_board") { + for (const port of circuit?.ports?.(item) || []) { + const point = circuit.portWorld(item, port); + if (!point) continue; + const hovered = pointer.inside && Math.hypot(pointer.x - point.x, pointer.y - point.y) <= 17; + const selected = world.pendingLinkEndpoint?.id === item.id && world.pendingLinkEndpoint?.portId === port.portId; + ctx.globalAlpha = selected ? 1 : (hovered ? 0.98 : 0.72); + ctx.fillStyle = port.direction === "input" ? "rgba(74,177,255,0.22)" : "rgba(255,132,65,0.22)"; + ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (port.direction === "input" ? "rgba(74,177,255,0.98)" : "rgba(255,132,65,0.98)"); + ctx.lineWidth = selected || hovered ? 3.8 : 2.5; + ctx.beginPath(); ctx.arc(point.x, point.y, (selected || hovered ? 12 : 9) * pulse, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + continue; + } + const selected = world.pendingLinkEndpoint?.id === item.id; + const radius = Math.max(18, Number(item.r || 22) * 1.22 + 7); + ctx.globalAlpha = selected ? 1 : (signalCapable ? 0.66 : 0.30); + ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (signalCapable ? "rgba(90,207,255,0.94)" : "rgba(224,232,238,0.72)"); + ctx.lineWidth = selected ? 4 : (signalCapable ? 2.4 : 1.6); + ctx.beginPath(); ctx.ellipse(item.x, item.y, radius, radius * 0.78, 0, 0, Math.PI * 2); ctx.stroke(); + } + ctx.setLineDash([]); + ctx.restore(); +} + function drawToolTargetHoverHighlight(ctx, world) { if (!world) return; const tool = String(world.tool || ""); @@ -495,7 +533,6 @@ function drawPlacementPreview(ctx, world) { const preview = placementPreviewFor(world); if (!preview) return; const { type, x, y, r, rect, rects, overlay, influence, blocked } = preview; - const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10; ctx.save(); ctx.globalAlpha = blocked ? 0.44 : 0.72; ctx.lineWidth = blocked ? 2.2 : 2.0; @@ -519,9 +556,9 @@ function drawPlacementPreview(ctx, world) { ctx.stroke(); } }; - if (overlay?.shape === "ellipse") { + if (overlay?.shape === "circle") { ctx.beginPath(); - ctx.ellipse(overlay.centerX, overlay.centerY, Math.max(1, overlay.radiusX), Math.max(1, overlay.radiusY), 0, 0, Math.PI * 2); + ctx.arc(overlay.centerX, overlay.centerY, Math.max(1, overlay.radius), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } else if (overlay?.shape === "roundedRect") { @@ -542,14 +579,9 @@ function drawPlacementPreview(ctx, world) { ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top); } } - } else if (type === "stone" || type === "ball" || type === "balloon" || type === "fan" || type === "pipe" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { - ctx.beginPath(); - ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); } else { ctx.beginPath(); - ctx.ellipse(x, y, Math.max(9, r * 1.55), Math.max(6, r * 0.86), 0, 0, Math.PI * 2); + ctx.arc(x, y, Math.max(8, r), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } @@ -585,8 +617,8 @@ function drawPlacementPreview(ctx, world) { ctx.fill(); ctx.restore(); } - const markerX = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerX : x; - const markerY = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerY : y; + const markerX = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerX : x; + const markerY = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerY : y; ctx.globalAlpha = blocked ? 0.52 : 0.70; ctx.beginPath(); ctx.moveTo(markerX - 8, markerY); @@ -711,7 +743,7 @@ function drawOperationToolPreview(ctx, world) { } } else { const r = Math.max(10, item.r || itemRadiusFor?.(type, 16) || 16); - ctx.beginPath(); ctx.ellipse(p.x, p.y, r * 1.45, r * 0.95, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.beginPath(); ctx.arc(item.x, item.y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ctx.setLineDash([]); const markerX = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerX : item.x; @@ -901,7 +933,7 @@ function linkIntersectsVisibleRect(it, worldRef, rect, margin = 18) { } function isBehindSpriteLayerItem(it) { - return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet")); + return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "seesaw")); } function renderLayerSortY(entity) { @@ -1764,6 +1796,7 @@ function render() { } renderPerfEnd(endAttachments); + drawWireConnectableHighlights(ctx, world); drawToolTargetHoverHighlight(ctx, world); const endEffects = renderPerfBegin("render.draw.effects"); diff --git a/js/save_codec.js b/js/save_codec.js index eee9843..17cf4de 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -635,7 +635,7 @@ return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit]; } - function birthHash32(seed = "") { + const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => { const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || "")); if (match) { const parsed = parseInt(match[1], 36); @@ -648,7 +648,7 @@ h = Math.imul(h, 16777619); } return h >>> 0; - } + }); function birthSeedFromDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) { const serial = Math.max(1, Math.floor(Number(descriptor?.[0]) || fallbackSerial || 1)); const expected = global.TarinaiSeedFactory?.childSeed?.(worldSeed, serial, []) || `b${serial.toString(36)}_0`; @@ -1095,6 +1095,7 @@ if (!indexed && (Number(endpoint.x) || 0) !== (Number(row?.[1]) || 0)) mask |= 128; if (!indexed && (Number(endpoint.y) || 0) !== (Number(row?.[2]) || 0)) mask |= 256; if (endpoint.t != null && Number.isFinite(Number(endpoint.t))) mask |= 512; + if (endpoint.port) mask |= 1024; writer.u(mask); if (mask & 1) writer.str(endpoint.id); if (mask & 2) writer.str(endpoint.type); @@ -1105,6 +1106,7 @@ if (mask & 128) writer.s((Number(endpoint.x) || 0) - (Number(row?.[1]) || 0)); if (mask & 256) writer.s((Number(endpoint.y) || 0) - (Number(row?.[2]) || 0)); if (mask & 512) writer.u(endpoint.t); + if (mask & 1024) writer.str(endpoint.port); } function readPhysicsEndpoint(reader, row = []) { const [present] = readBitFields(reader, [1]); @@ -1124,7 +1126,8 @@ const x = (Number(row?.[1]) || 0) + ((mask & 128) ? reader.s() : 0); const y = (Number(row?.[2]) || 0) + ((mask & 256) ? reader.s() : 0); const t = (mask & 512) ? reader.u() : null; - return { kind, refIdx, id, type, label, token, lx, ly, center, x, y, t }; + const port = (mask & 1024) ? reader.str() : ""; + return { kind, refIdx, id, type, label, token, lx, ly, center, x, y, t, port }; } function writePhysicsConstraintExtra(writer, extra, type, row = []) { const constraint = extra?.constraint && typeof extra.constraint === "object" ? extra.constraint : {}; @@ -1215,11 +1218,12 @@ else if (type === "zunchi") writeFixedSignedDefaults(writer, list, [0, 100, 100], 3); else if (type === "signboard") writeSaveString(writer, list[0] || ""); else if (type === "robot_cleaner") writeFixedSignedDefaults(writer, list, [3, 0], 2); + else if (type === "circuit_board") writeSaveString(writer, list[0] || "{}"); else if (type === "duplicator") writeFixedSignedDefaults(writer, list, [-1], 1); else if (type === "ball" || type === "balloon") writeFixedSignedDefaults(writer, list, [0, 0], 2); else if (type === "ant_nest") writeFixedSignedDefaults(writer, list, [0, 0], 2); else if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") writeFixedSignedDefaults(writer, list, [0], 1); - else if (type === "sticky_bomb") writeFixedSignedDefaults(writer, list, [0, -1], 2); + else if (type === "sticky_bomb") writeFixedSignedDefaults(writer, list, [0, -1, 0], 3); else if (type === "nest_box") writeFixedSignedDefaults(writer, list, [0, 0, 0, 0], 4); else if (isPinSaveType(type)) writeFixedSignedDefaults(writer, list, [0, -1, 0, 0, 0], 5); else if (type === "fan") { @@ -1242,11 +1246,12 @@ if (type === "zunchi") return readFixedSignedDefaults(reader, [0, 100, 100], 3); if (type === "signboard") return [readSaveString(reader)]; if (type === "robot_cleaner") return readFixedSignedDefaults(reader, [3, 0], 2); + if (type === "circuit_board") return [readSaveString(reader)]; if (type === "duplicator") return readFixedSignedDefaults(reader, [-1], 1); if (type === "ball" || type === "balloon") return readFixedSignedDefaults(reader, [0, 0], 2); if (type === "ant_nest") return readFixedSignedDefaults(reader, [0, 0], 2); if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") return readFixedSignedDefaults(reader, [0], 1); - if (type === "sticky_bomb") return readFixedSignedDefaults(reader, [0, -1], 2); + if (type === "sticky_bomb") return readFixedSignedDefaults(reader, [0, -1, 0], 3); if (type === "nest_box") return readFixedSignedDefaults(reader, [0, 0, 0, 0], 4); if (isPinSaveType(type)) return readFixedSignedDefaults(reader, [0, -1, 0, 0, 0], 5); if (type === "fan") { @@ -1459,16 +1464,11 @@ return items; } - const LEGACY_JSON_BINARY_SCHEMA_VERSION = 41; - const LEGACY_COMPACT_BINARY_SCHEMA_VERSION = 42; const ACHIEVEMENT_METADATA_BINARY_VERSION = 1; - const ACHIEVEMENT_SPELL_BINARY_VERSION = 3; - const PREVIOUS_ACHIEVEMENT_SPELL_BINARY_VERSION = 2; - const ACHIEVEMENT_MASK_BYTES = 7; - const LEGACY_ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10]); - const LEGACY_ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666]); - const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10]); - const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721]); + const ACHIEVEMENT_SPELL_BINARY_VERSION = 5; + const ACHIEVEMENT_MASK_BYTES = 8; + const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10, 3, 17]); + const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721, 7, 131071]); function finiteNumberOrNull(value) { const n = Number(value); @@ -1493,34 +1493,14 @@ function achievementMaskBytesFromSpell(spell = null) { const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES); if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] }; - const version = Number(spell[0]); - let baseMinute = 0; - let deltas = []; - let progress = []; - if (version === 1) { - const maskHex = String(spell[1] || "").toLowerCase(); - if (!/^[0-9a-f]{14}$/.test(maskHex)) throw new Error("invalid achievement spell mask"); - for (let i = 0; i < ACHIEVEMENT_MASK_BYTES; i++) bytes[i] = parseInt(maskHex.slice(i * 2, i * 2 + 2), 16); - const baseSecond = toSafeUInt(spell[2]); - const secondDeltas = Array.isArray(spell[3]) ? spell[3] : []; - const absoluteMinutes = secondDeltas.map(delta => Math.floor((baseSecond + toSafeUInt(delta)) / 60)); - baseMinute = absoluteMinutes.length ? Math.min(...absoluteMinutes) : 0; - deltas = absoluteMinutes.map(value => value - baseMinute); - progress = Array.isArray(spell[4]) ? spell[4] : []; - } else if (version === PREVIOUS_ACHIEVEMENT_SPELL_BINARY_VERSION || version === ACHIEVEMENT_SPELL_BINARY_VERSION) { - const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0))); - const high = Math.max(0, Math.min(0xffffff, Math.floor(Number(spell[2]) || 0))); - for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255; - for (let i = 0; i < 3; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255; - baseMinute = toSafeUInt(spell[3]); - deltas = Array.isArray(spell[4]) ? spell[4].map(toSafeUInt) : []; - const rawProgress = Array.isArray(spell[5]) ? spell[5] : []; - progress = version === ACHIEVEMENT_SPELL_BINARY_VERSION - ? rawProgress - : [...rawProgress.slice(0, 9), 0, 0, 0]; - } else { - throw new Error("unsupported achievement spell version"); - } + if (Number(spell[0]) !== ACHIEVEMENT_SPELL_BINARY_VERSION) throw new Error("unsupported achievement spell version"); + const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0))); + const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[2]) || 0))); + for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255; + for (let i = 0; i < 4; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255; + const baseMinute = toSafeUInt(spell[3]); + const deltas = Array.isArray(spell[4]) ? spell[4].map(toSafeUInt) : []; + const progress = Array.isArray(spell[5]) ? spell[5] : []; let unlockedCount = 0; for (const byte of bytes) { let value = byte; @@ -1529,11 +1509,12 @@ if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps"); return { bytes, baseMinute, deltas, progress }; } + function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = []) { let low = 0; let high = 0; for (let i = 0; i < 4; i++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8)); - for (let i = 0; i < 3; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8)); + for (let i = 0; i < 4; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8)); return [ACHIEVEMENT_SPELL_BINARY_VERSION, low >>> 0, high >>> 0, toSafeUInt(baseMinute), deltas.map(toSafeUInt), progress]; } function writeAchievementProgress(writer, values = [], bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) { @@ -1568,7 +1549,7 @@ for (const delta of normalized.deltas) writer.u(delta); writeAchievementProgress(writer, normalized.progress); } - function readAchievementSpell(reader, schema = BINARY_SCHEMA_VERSION) { + function readAchievementSpell(reader) { const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES); let unlockedCount = 0; for (const byte of bytes) { @@ -1578,11 +1559,8 @@ const baseMinute = reader.u(); const deltas = []; for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u()); - const legacy = schema === LEGACY_COMPACT_BINARY_SCHEMA_VERSION; - const progress = legacy - ? readAchievementProgress(reader, LEGACY_ACHIEVEMENT_PROGRESS_BITS, LEGACY_ACHIEVEMENT_PROGRESS_MAX) - : readAchievementProgress(reader); - return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, legacy ? [...progress.slice(0, 9), 0, 0, 0] : progress); + const progress = readAchievementProgress(reader); + return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress); } function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) { const value = metadata && typeof metadata === "object" ? metadata : {}; @@ -1749,7 +1727,7 @@ const formatHeader = reader.b(); const schema = formatHeader & 0x7f; const hasStringTable = Boolean(formatHeader & 0x80); - if (![BINARY_SCHEMA_VERSION, LEGACY_COMPACT_BINARY_SCHEMA_VERSION, LEGACY_JSON_BINARY_SCHEMA_VERSION].includes(schema)) throw new Error("unsupported binary save schema"); + if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema"); readStringTable(reader, hasStringTable); const w = readWorld(reader); const tCount = reader.u(); @@ -1760,12 +1738,7 @@ const items = readItemBlocks(reader); let g = null; try { - if (schema === LEGACY_JSON_BINARY_SCHEMA_VERSION) { - const encodedMetadata = reader.str(); - g = encodedMetadata ? JSON.parse(encodedMetadata) : null; - } else { - g = readAchievementMetadata(reader, tCount, items.length, schema); - } + g = readAchievementMetadata(reader, tCount, items.length, schema); } catch (_) { throw new Error("invalid achievement metadata in binary save"); } diff --git a/js/save_schema.js b/js/save_schema.js index 35d42cc..f3ff540 100644 --- a/js/save_schema.js +++ b/js/save_schema.js @@ -8,8 +8,8 @@ if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js"); if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js"); - const SNAPSHOT_VERSION = 35; - const BINARY_SCHEMA_VERSION = 43; + const SNAPSHOT_VERSION = 40; + const BINARY_SCHEMA_VERSION = 49; function moodValue(index, fallback = "relaxed") { return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback)); diff --git a/js/seesaw_system.js b/js/seesaw_system.js new file mode 100644 index 0000000..3f4486e --- /dev/null +++ b/js/seesaw_system.js @@ -0,0 +1,217 @@ +"use strict"; + +// Two-creature leisure equipment. Seats are reserved independently and only +// produce play/friendship while both ends are occupied. +(function (global) { + const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => Number.isFinite(Number(v)) ? Number(v) : fallback); + const clamp = global.TarinaiCoreHelpers?.clampNumber || ((v, a, b) => Math.max(a, Math.min(b, Number(v) || 0))); + + function ensure(item) { + if (!item) return null; + if (!Array.isArray(item.seesawSeatIds)) item.seesawSeatIds = ["", ""]; + if (item.seesawSeatIds.length < 2) item.seesawSeatIds = [item.seesawSeatIds[0] || "", item.seesawSeatIds[1] || ""]; + item.angle = 0; + item.seesawPhase = num(item.seesawPhase); + item.seesawTilt = num(item.seesawTilt); + return item; + } + + function seatWorld(item, index = 0) { + ensure(item); + const side = index === 1 ? 1 : -1; + const reach = Math.max(48, num(item?.r, 60) * 0.94); + return { x: num(item?.x) + reach * side, y: num(item?.y) - 2, index: index === 1 ? 1 : 0 }; + } + + function actorById(worldRef, id) { + if (!id) return null; + return worldRef?.liveTarinaiById?.(id) || (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === id) || null; + } + + function release(tarinai, item = null) { + const board = item || tarinai?.target; + if (board?.type === "seesaw") { + ensure(board); + for (let i = 0; i < 2; i += 1) if (board.seesawSeatIds[i] === tarinai?.id) board.seesawSeatIds[i] = ""; + } + if (tarinai) { + tarinai.seesawItemId = ""; + tarinai.seesawSeatIndex = -1; + tarinai.seesawWaitStartedAt = -Infinity; + tarinai.seesawPlayStartedAt = -Infinity; + } + return true; + } + + function cleanReservations(item, worldRef) { + ensure(item); + for (let i = 0; i < 2; i += 1) { + const actor = actorById(worldRef, item.seesawSeatIds[i]); + if (!actor || actor.dead || actor.seesawItemId !== item.id || actor.seesawSeatIndex !== i || actor.state !== "play_seesaw") item.seesawSeatIds[i] = ""; + } + } + + function findAvailable(worldRef, tarinai, range = 560) { + let best = null; + let bestScore = Number(range) || 560; + const list = worldRef?.itemsOfType?.("seesaw") || (worldRef?.items || []).filter(item => item?.type === "seesaw"); + for (const item of list) { + if (!item || item.dead) continue; + cleanReservations(item, worldRef); + for (let index = 0; index < 2; index += 1) { + const reserved = item.seesawSeatIds[index]; + if (reserved && reserved !== tarinai?.id) continue; + const point = seatWorld(item, index); + const d = Math.hypot(num(tarinai?.x) - point.x, num(tarinai?.y) - point.y); + const hasPartner = Boolean(item.seesawSeatIds[1 - index]); + const score = d - (hasPartner ? 90 : 0); + if (score < bestScore) { bestScore = score; best = { item, seatIndex: index, point, hasPartner }; } + } + } + return best; + } + + function shouldSeek(worldRef, tarinai, needs = null) { + const found = findAvailable(worldRef, tarinai, 620); + if (!found) return false; + const fulfill = num(needs?.fulfill ?? tarinai?.needs?.fulfill); + const social = num(needs?.social ?? tarinai?.needs?.social); + if (found.hasPartner) return Math.max(fulfill, social) >= 8; + return fulfill >= 26 || social >= 40; + } + + function actionPriority(worldRef, tarinai) { + const found = findAvailable(worldRef, tarinai, 620); + if (!found) return 0; + return found.hasPartner ? 74 : 34; + } + + function isPlaying(tarinai, worldRef = tarinai?.world) { + const reservation = validReservation(tarinai, worldRef); + if (!reservation) return false; + const partnerId = reservation.item.seesawSeatIds[1 - reservation.index]; + const partner = actorById(worldRef, partnerId); + if (!partner || partner.dead || partner.state !== "play_seesaw") return false; + const ownPoint = reservation.point; + const partnerPoint = seatWorld(reservation.item, 1 - reservation.index); + const ownArrived = Math.hypot(num(tarinai.x) - ownPoint.x, num(tarinai.y) - ownPoint.y) <= Math.max(28, num(tarinai.radius, 20) * 1.25); + const partnerArrived = Math.hypot(num(partner.x) - partnerPoint.x, num(partner.y) - partnerPoint.y) <= Math.max(28, num(partner.radius, 20) * 1.25); + return ownArrived && partnerArrived; + } + + function reserve(item, tarinai, preferredIndex = null) { + if (!item || item.dead || item.type !== "seesaw" || !tarinai || tarinai.dead) return -1; + ensure(item); + const candidates = preferredIndex === 0 || preferredIndex === 1 ? [preferredIndex, 1 - preferredIndex] : [0, 1]; + for (const index of candidates) { + if (!item.seesawSeatIds[index] || item.seesawSeatIds[index] === tarinai.id) { + item.seesawSeatIds[index] = tarinai.id; + tarinai.seesawItemId = item.id || ""; + tarinai.seesawSeatIndex = index; + tarinai.seesawWaitStartedAt = num(tarinai.world?.time, 0); + tarinai.seesawPlayStartedAt = -Infinity; + return index; + } + } + return -1; + } + + function start(tarinai, worldRef, item = null) { + const found = item ? { item, seatIndex: null } : findAvailable(worldRef, tarinai, 620); + const target = found?.item; + if (!target) return false; + cleanReservations(target, worldRef); + const index = reserve(target, tarinai, found.seatIndex); + if (index < 0) return false; + tarinai.setActionState?.("play_seesaw", { target, reason: "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b", sleeping: false, wake: true }); + tarinai.target = target; + tarinai.thought = "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b"; + return true; + } + + function validReservation(tarinai, worldRef) { + if (!tarinai || tarinai.dead || tarinai.state !== "play_seesaw") return null; + const item = worldRef?.itemById?.(tarinai.seesawItemId) || (worldRef?.items || []).find(value => value && !value.dead && value.id === tarinai.seesawItemId); + if (!item || item.type !== "seesaw") return null; + cleanReservations(item, worldRef); + const index = tarinai.seesawSeatIndex === 1 ? 1 : 0; + if (item.seesawSeatIds[index] !== tarinai.id) return null; + return { item, index, point: seatWorld(item, index) }; + } + + function tickAction(tarinai, worldRef) { + const reservation = validReservation(tarinai, worldRef); + if (!reservation) return false; + const d = Math.hypot(num(tarinai.x) - reservation.point.x, num(tarinai.y) - reservation.point.y); + tarinai.thought = d > Math.max(22, num(tarinai.radius, 20)) ? "\u30b7\u30fc\u30bd\u30fc\u3078\u5411\u304b\u3063\u3066\u3044\u308b" : (reservation.item.seesawSeatIds[1 - reservation.index] ? "\u4e00\u7dd2\u306b\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u3093\u3067\u3044\u308b" : "\u76f8\u624b\u3092\u5f85\u3063\u3066\u3044\u308b"); + const now = num(worldRef?.time); + if (Number.isFinite(tarinai.seesawPlayStartedAt) && tarinai.seesawPlayStartedAt > -Infinity) { + if (now - tarinai.seesawPlayStartedAt >= 18) return "finished"; + } else { + const dayLength = Math.max(1, num(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength, 120)); + if (now - num(tarinai.seesawWaitStartedAt, now) >= Math.max(30, dayLength * 0.5)) return "finished"; + } + return true; + } + + function updateWorld(worldRef, dt = 0.016) { + if (!worldRef?.items) return false; + const step = Math.max(0, Math.min(0.1, num(dt, 0.016))); + const seesaws = worldRef.itemsOfType?.("seesaw") || worldRef.items.filter(item => item?.type === "seesaw"); + for (const item of seesaws) { + if (!item || item.dead) continue; + ensure(item); cleanReservations(item, worldRef); + const actors = [actorById(worldRef, item.seesawSeatIds[0]), actorById(worldRef, item.seesawSeatIds[1])]; + const arrived = actors.map((actor, index) => { + if (!actor) return false; + const point = seatWorld(item, index); + return Math.hypot(num(actor.x) - point.x, num(actor.y) - point.y) <= Math.max(28, num(actor.radius, 20) * 1.25); + }); + const playing = Boolean(actors[0] && actors[1] && arrived[0] && arrived[1]); + const pairKey = playing ? `${actors[0].id}|${actors[1].id}` : ""; + if (pairKey && item.seesawPairKey !== pairKey) { + item.seesawPairKey = pairKey; + const now = num(worldRef.time); + actors[0].seesawPlayStartedAt = now; + actors[1].seesawPlayStartedAt = now; + } else if (!pairKey) item.seesawPairKey = ""; + if (playing) item.seesawPhase += step * 4.6; + const targetTilt = playing ? Math.sin(item.seesawPhase) * 0.16 : 0; + item.seesawTilt += (targetTilt - item.seesawTilt) * Math.min(1, step * 8); + for (let index = 0; index < 2; index += 1) { + const actor = actors[index]; + if (!actor || !arrived[index]) continue; + const base = seatWorld(item, index); + const side = index === 1 ? 1 : -1; + const lift = Math.sin(item.seesawPhase) * 9 * side; + actor.x = base.x; + actor.y = base.y - 12 + (playing ? lift : 0); + actor.vx = 0; actor.vy = 0; + const actionText = playing ? "\u30b7\u30fc\u30bd\u30fc\u3092\u3057\u3066\u3044\u308b\u3002" : "\u76f8\u624b\u3092\u5f85\u3063\u3066\u3044\u308b"; + actor.thought = actionText; + if (playing && typeof global.setBehaviorText === "function") { + const behavior = global.currentTarinaiBehavior?.(actor); + if (behavior?.actionId === "play_seesaw" && behavior?.reason !== actionText) { + global.setBehaviorText(actor, { need: "fulfill", subNeed: "social_play", actionId: "play_seesaw", actionLabel: actionText, reasonText: actionText, target: item, phase: "perform", source: behavior?.source || "need" }); + } + } + } + if (playing) { + const [a, b] = actors; + a.adjustRelation?.(b, step * 0.70, -step * 0.025, "seesaw_play"); + b.adjustRelation?.(a, step * 0.70, -step * 0.025, "seesaw_play"); + if (typeof global.applyNeedSatisfaction === "function") { + global.applyNeedSatisfaction(a, { fulfill: step * 1.4, social: step * 0.55 }, "seesaw"); + global.applyNeedSatisfaction(b, { fulfill: step * 1.4, social: step * 0.55 }, "seesaw"); + } else { + a.stress = clamp(num(a.stress) - step * 0.35, 0, 100); + b.stress = clamp(num(b.stress) - step * 0.35, 0, 100); + } + item.seesawPlayTime = num(item.seesawPlayTime) + step; + } + } + return true; + } + + global.TarinaiSeesawSystem = Object.freeze({ ensure, seatWorld, findAvailable, shouldSeek, actionPriority, isPlaying, reserve, release, start, tickAction, updateWorld, validReservation }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/signal_system.js b/js/signal_system.js index d249dc2..ec387fe 100644 --- a/js/signal_system.js +++ b/js/signal_system.js @@ -5,7 +5,7 @@ const WIRE_TYPES = new Set(["wire", "insulated_wire"]); const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]); const SIGNAL_SOURCE_TYPES = new Set(["pressure_switch"]); - const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence"]); + const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator"]); const DETECTOR_METRICS = Object.freeze({ tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 }, hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 }, @@ -34,11 +34,11 @@ }); - function num(value, fallback = 0) { + const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => { const n = Number(value); return Number.isFinite(n) ? n : fallback; - } - function clamp(value, min, max) { return Math.max(min, Math.min(max, num(value, min))); } + }); + const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min)))); function normalizeMinute(value, fallback = 0) { return Math.max(0, Math.min(1435, Math.round(num(value, fallback) / 5) * 5)); } @@ -91,6 +91,18 @@ return found; } + function endpointSignalNode(endpoint, object, worldRef) { + if (!endpoint || !object) return object || null; + if (object.type !== "circuit_board") return object; + const circuit = global.TarinaiCircuitBoardSystem; + const portId = endpoint.portId || circuit?.nearestPort?.(object, endpoint.x, endpoint.y, 80)?.port?.portId || ""; + return circuit?.terminal?.(object, portId) || null; + } + + function isSignalConnectable(item) { + return Boolean(item && !item.dead && (SIGNAL_SOURCE_TYPES.has(item.type) || DIRECT_SIGNAL_SINK_TYPES.has(item.type) || item.type === "circuit_board")); + } + function endpointPoint(endpoint, worldRef) { return global.TarinaiLinkRuntime?.endpointWorld?.(endpoint, worldRef) || (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null); @@ -145,9 +157,10 @@ for (const item of worldRef.itemsOfType?.("pressure_switch") || []) { if (!item || item.dead) continue; item.pressureTarget = normalizePressureTarget(item.pressureTarget); - item.pressureComparator = normalizeComparator(item.pressureComparator); const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1; - item.pressureThreshold = clamp(num(item.pressureThreshold, defaultThreshold), -999, 999); + item.pressureMin = clamp(num(item.pressureMin, defaultThreshold), -999, 999); + item.pressureMax = clamp(num(item.pressureMax, 999), -999, 999); + if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; } item.pressureWidth = clamp(item.pressureWidth || 220, 60, 840); item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840); item.pressureTimeStart = normalizeMinute(item.pressureTimeStart, 360); @@ -159,7 +172,7 @@ value = active ? 1 : 0; } else { value = detectorMetricValue(item, worldRef); - active = item.pressureComparator === "lte" ? value <= item.pressureThreshold : value >= item.pressureThreshold; + active = value >= item.pressureMin && value <= item.pressureMax; } if (active !== Boolean(item.signalActive)) worldRef.drawListDirty = true; item.pressureCount = value; @@ -192,30 +205,40 @@ function buildSignalNetwork(worldRef) { const adjacency = new Map(); - const connected = new Set(); + const connectedNodes = new Set(); const wires = []; + const circuit = global.TarinaiCircuitBoardSystem; let wireGeometryMoved = false; const addEdge = (a, b) => { if (!a || !b || a === b) return; if (!adjacency.has(a)) adjacency.set(a, new Set()); if (!adjacency.has(b)) adjacency.set(b, new Set()); adjacency.get(a).add(b); adjacency.get(b).add(a); - connected.add(a); connected.add(b); + connectedNodes.add(a); connectedNodes.add(b); }; + const boards = []; for (const item of worldRef.items || []) { if (!item || item.dead) continue; if (DIRECT_SIGNAL_SINK_TYPES.has(item.type)) { item._wireSignalDriven = false; item._wireSignalOn = false; item._directSignalDriven = false; item._directSignalOn = false; } + if (item.type === "circuit_board") { + circuit?.ensureConfig?.(item); + item._circuitInputState = Object.create(null); + if (!item._circuitOutputState || typeof item._circuitOutputState !== "object") circuit?.evaluate?.(item, item._circuitInputState); + boards.push(item); + } } for (const wire of worldRef.items || []) { if (!isWire(wire)) continue; const pb = global.TarinaiPhysicsBodySystem; const endpointA = pb?.endpoint?.(wire, 0); const endpointB = pb?.endpoint?.(wire, 1); - const a = endpointObject(endpointA, worldRef); - const b = endpointObject(endpointB, worldRef); + const objectA = endpointObject(endpointA, worldRef); + const objectB = endpointObject(endpointB, worldRef); + const nodeA = endpointSignalNode(endpointA, objectA, worldRef); + const nodeB = endpointSignalNode(endpointB, objectB, worldRef); const pointA = endpointPoint(endpointA, worldRef); const pointB = endpointPoint(endpointB, worldRef); if (pointA && pointB) { @@ -227,39 +250,81 @@ if (endpointA) { endpointA.x = pointA.x; endpointA.y = pointA.y; } if (endpointB) { endpointB.x = pointB.x; endpointB.y = pointB.y; } } - wires.push({ wire, a, b }); - if (a && b) addEdge(a, b); + wires.push({ wire, a: objectA, b: objectB, nodeA, nodeB }); + if (nodeA && nodeB) addEdge(nodeA, nodeB); } + const components = []; + const componentOf = new Map(); const visited = new Set(); - for (const start of connected) { - if (visited.has(start)) continue; - const stack = [start]; - const component = []; - let active = false; + for (const startNode of connectedNodes) { + if (visited.has(startNode)) continue; + const stack = [startNode], nodes = []; while (stack.length) { const node = stack.pop(); if (!node || visited.has(node)) continue; - visited.add(node); component.push(node); - if (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) active = true; + visited.add(node); nodes.push(node); for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next); } - const hasSource = component.some(node => SIGNAL_SOURCE_TYPES.has(node.type)); - const componentSet = new Set(component); - for (const node of component) { + const component = { nodes, active: false, hasSource: nodes.some(node => SIGNAL_SOURCE_TYPES.has(node.type) || (node.type === "circuit_terminal" && node.direction === "output")) }; + components.push(component); + nodes.forEach(node => componentOf.set(node, component)); + } + + // Board outputs can depend on other boards, so resolve the graph to a + // fixed point. Unstable internal combinational cycles are forced OFF by the + // board evaluator; external feedback is still bounded to avoid hanging the simulation. + const maxCircuitPasses = Math.min(256, Math.max(16, boards.length * 2 + 4)); + for (let pass = 0; pass < maxCircuitPasses; pass += 1) { + let changed = false; + for (const component of components) { + const active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive)); + if (active !== component.active) { component.active = active; changed = true; } + } + for (const board of boards) { + const inputs = Object.create(null); + for (const port of circuit?.ports?.(board) || []) { + if (port.direction !== "input") continue; + const terminal = circuit.terminal(board, port.portId); + inputs[port.id] = Boolean(componentOf.get(terminal)?.active); + } + const before = JSON.stringify(board._circuitOutputState || {}); + circuit?.evaluate?.(board, inputs); + if (before !== JSON.stringify(board._circuitOutputState || {})) changed = true; + } + if (!changed) break; + } + for (const component of components) { + component.active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive)); + for (const node of component.nodes) { if (!DIRECT_SIGNAL_SINK_TYPES.has(node.type)) continue; - node._wireSignalDriven = hasSource; - node._wireSignalOn = hasSource && active; - setEffectivePower(node, hasSource, active, worldRef); + node._wireSignalDriven = component.hasSource; + node._wireSignalOn = component.hasSource && component.active; + setEffectivePower(node, component.hasSource, component.active, worldRef); } - for (const entry of wires) { - if ((entry.a && componentSet.has(entry.a)) || (entry.b && componentSet.has(entry.b))) entry.wire.signalActive = hasSource && active; + } + for (const entry of wires) { + const component = componentOf.get(entry.nodeA) || componentOf.get(entry.nodeB); + entry.wire.signalActive = Boolean(component?.hasSource && component?.active); + if (!entry.wire.signalActive || entry.a === entry.b) continue; + let sourceNode = null, targetNode = null, sourceBoard = null, targetBoard = null; + if (entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "output" + && entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "input") { + sourceNode = entry.nodeA; targetNode = entry.nodeB; sourceBoard = entry.a; targetBoard = entry.b; + } else if (entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "output" + && entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "input") { + sourceNode = entry.nodeB; targetNode = entry.nodeA; sourceBoard = entry.b; targetBoard = entry.a; } + if (!sourceNode || !targetNode || sourceBoard === targetBoard) continue; + const sourcePortId = String(sourceNode.portId || "").replace(/^o:/, ""); + const targetPortId = String(targetNode.portId || "").replace(/^i:/, ""); + if (!sourceBoard?._circuitOutputState?.[sourcePortId] || !targetBoard?._circuitInputState?.[targetPortId]) continue; + global.TarinaiAchievements?.recordCircuitBoardSignalTransfer?.(sourceBoard, targetBoard, entry.wire, { world: worldRef }); } for (const item of worldRef.items || []) { if (!item || item.dead) continue; - if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !connected.has(item)) { + if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !componentOf.has(item)) { item._wireSignalDriven = false; item._wireSignalOn = false; setEffectivePower(item, false, false, worldRef); } @@ -272,12 +337,7 @@ return wires; } - function distanceToSegment(px, py, ax, ay, bx, by) { - const vx = bx - ax, vy = by - ay; - const len2 = vx * vx + vy * vy || 1; - const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1); - return Math.hypot(px - (ax + vx * t), py - (ay + vy * t)); - } + const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by); function shockTarget(worldRef, wire, target, now, { connected = false } = {}) { if (!target || target.dead) return false; @@ -306,12 +366,14 @@ life: 0.28, color: "rgba(118,224,255,0.96)", }); + global.TarinaiAchievements?.recordWireShock?.(wire, target, { world: worldRef, connected }); return true; } function damageFromWires(worldRef, wires) { const now = num(worldRef.time); for (const { wire, a, b } of wires) { + global.TarinaiAchievements?.recordWirePowerState?.(wire, Boolean(wire.signalActive)); if (!wire.signalActive) continue; // Living endpoints are directly energized even through insulated wire. if (a && (a.radius || a.kind === "worker" || a.kind === "queen")) shockTarget(worldRef, wire, a, now, { connected: true }); @@ -358,6 +420,9 @@ isWire, isMinuteInRange, currentMinuteOfDay, + isSignalConnectable, + DIRECT_SIGNAL_SINK_TYPES, + SIGNAL_SOURCE_TYPES, SENSOR_METRICS: DETECTOR_METRICS, DETECTOR_METRICS, PRESSURE_TARGETS, diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 67f1d67..9f185f5 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -33,7 +33,7 @@ return Number.isFinite(n) ? n / scale : fallback; } - function birthHash32(seed = "") { + const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => { const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || "")); if (match) { const parsed = parseInt(match[1], 36); @@ -46,7 +46,7 @@ h = Math.imul(h, 16777619); } return h >>> 0; - } + }); function compactBirthDescriptor(t = null, fallbackSerial = 1) { const seed = String(t?.birthSeed || t?.familyKey || t?.id || ""); const parsed = global.TarinaiSeedFactory?.serialFromBirthSeed?.(seed) || 0; @@ -76,7 +76,7 @@ const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } - function clonePlain(value, depth = 0) { + function cloneSnapshotPlain(value, depth = 0) { if (value == null) return value; const type = typeof value; if (type === "number") return Number.isFinite(value) ? value : 0; @@ -86,7 +86,7 @@ if (Array.isArray(value)) { const out = []; for (const v of value) { - const c = clonePlain(v, depth + 1); + const c = cloneSnapshotPlain(v, depth + 1); if (c !== undefined) out.push(c); } return out; @@ -95,7 +95,7 @@ const out = {}; for (const [key, v] of Object.entries(value)) { if (DEFAULT_SKIP.has(key)) continue; - const c = clonePlain(v, depth + 1); + const c = cloneSnapshotPlain(v, depth + 1); if (c !== undefined) out[key] = c; } return out; @@ -417,6 +417,7 @@ if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)]; if (type === "signboard") return [item.text || ""]; if (type === "robot_cleaner") return [Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0]; + if (type === "circuit_board") return [JSON.stringify(global.TarinaiCircuitBoardSystem?.serializeConfig?.(item) || item.circuitConfig || {})]; if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) { const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex); if (packed) return packed; @@ -427,7 +428,7 @@ if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)]; if (type === "ant_corpse") return [q(item.decayTimer, 10)]; if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)]; - if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1]; + if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1, q(item.stickyBombPassCount, 1)]; if (type === "fire") return [q(item.flameLife, 10)]; if (type === "nest_box") return [q(item.burnTimer, 10), q(item.nestFireEvacuationTimer, 10), item.burning ? 1 : 0, item.nestFireEvacuated ? 1 : 0]; if (global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)]; @@ -444,8 +445,8 @@ const targets = ["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"]; return [ Math.max(0, targets.indexOf(item.pressureTarget || "tarinai")), - item.pressureComparator === "lte" ? 1 : 0, - q(item.pressureThreshold ?? 1, 10), + q(item.pressureMin ?? 1, 10), + q(item.pressureMax ?? 300, 10), Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0)), Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0)), Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0)), @@ -483,8 +484,8 @@ const def = global.StructureRegistry?.get?.(type); if (def) { item.label = def.label; - item.roles = clonePlain(def.roles) || item.roles || {}; - item.needEffects = clonePlain(def.needEffects) || item.needEffects || {}; + item.roles = cloneSnapshotPlain(def.roles) || item.roles || {}; + item.needEffects = cloneSnapshotPlain(def.needEffects) || item.needEffects || {}; } return; } @@ -516,7 +517,7 @@ } else if (type === "firecracker" || type === "flame_firecracker") { item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0); } else if (type === "sticky_bomb") { - item.fuseTimer = u(extra[0], 10, item.fuseTimer || 12); item.fuseMax = Math.max(item.fuseMax || 12, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || ""; + item.fuseTimer = u(extra[0], 10, item.fuseTimer || 12); item.fuseMax = Math.max(item.fuseMax || 12, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || ""; item.stickyBombPassCount = Math.max(0, u(extra[2], 1, 0)); } else if (type === "fire") { item.flameLife = u(extra[0], 10, item.flameLife || 6); } else if (type === "nest_box") { @@ -530,6 +531,11 @@ } } else if (global.isPinType(type)) { item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10); + } else if (type === "circuit_board") { + let config = {}; + try { config = JSON.parse(String(extra[0] || "{}")); } catch (_) { config = {}; } + global.TarinaiCircuitBoardSystem?.applySerializedConfig?.(item, config); + global.TarinaiCircuitBoardSystem?.evaluate?.(item); } else if (type === "fan") { const fallback = global.defaultItemAngle(type); item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback); @@ -542,8 +548,9 @@ } else if (type === "pressure_switch") { const targets = ["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"]; item.pressureTarget = targets[Number(extra[0]) | 0] || "tarinai"; - item.pressureComparator = Number(extra[1]) === 1 ? "lte" : "gte"; - item.pressureThreshold = u(extra[2], 10, 1); + item.pressureMin = u(extra[1], 10, 1); + item.pressureMax = u(extra[2], 10, 300); + if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; } item.pressureWidth = Math.max(60, Math.min(840, Number(extra[3] || 220) | 0)); item.pressureHeight = Math.max(60, Math.min(840, Number(extra[4] || 160) | 0)); item.pressureTimeStart = Math.max(0, Math.min(1435, Number(extra[5] ?? 360) | 0)); diff --git a/js/structures.js b/js/structures.js index 86db6c9..bbae507 100644 --- a/js/structures.js +++ b/js/structures.js @@ -5,9 +5,7 @@ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; } - function clonePlain(value) { - return JSON.parse(JSON.stringify(value || {})); - } + const clonePlain = global.TarinaiCoreHelpers?.clonePlain || (value => JSON.parse(JSON.stringify(value || {}))); class TarinaiStructure { constructor(definition, owner, x, y, world) { diff --git a/js/system_order.js b/js/system_order.js index d70699f..30dfe45 100644 --- a/js/system_order.js +++ b/js/system_order.js @@ -16,6 +16,7 @@ { id: "items_and_ants", run(worldRef, ctx) { ctx.mobile = phase("updateItemsAndAnts")?.(worldRef, ctx.dt) || null; } }, { id: "effects", run(worldRef, ctx) { phase("updateEffects")?.(worldRef, ctx.dt); } }, { id: "creatures", run(worldRef, ctx) { phase("updateCreatures")?.(worldRef, ctx.dt); } }, + { id: "seesaws", run(worldRef, ctx) { global.TarinaiSeesawSystem?.updateWorld?.(worldRef, ctx.dt); } }, { id: "maintenance", run(worldRef, ctx) { phase("runMaintenance")?.(worldRef, ctx.dt, ctx.mobile?.itemCountBefore ?? (worldRef.items || []).length); } }, { id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } }, { id: "ambient_tarinai", run(worldRef, ctx) { phase("spawnAmbientTarinai")?.(worldRef, ctx.dt); } }, diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index 114af64..e18d912 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -535,6 +535,22 @@ const TARINAI_ACTION_DEFINITIONS = [ createApproachMateActionSpec(), createFightRivalActionSpec(), createIntimidateEnemyActionSpec(), + { + id: "play_seesaw", + need: "fulfill", + subNeed: "social_play", + state: "play_seesaw", + label: "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b", + phrase: "\u4e00\u7dd2\u306b\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u3093\u3060", + weight: 31, + timerBacked: true, + selectTarget(t, world) { return globalThis.TarinaiSeesawSystem?.findAvailable?.(world, t, 620)?.item || null; }, + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start(t, world, ctx = {}) { return Boolean(globalThis.TarinaiSeesawSystem?.start?.(t, world, ctx.target ?? this.selectTarget(t, world, ctx))); }, + tick(t, world) { return globalThis.TarinaiSeesawSystem?.tickAction?.(t, world) ?? false; }, + finish(t) { globalThis.TarinaiSeesawSystem?.release?.(t); return true; }, + fail(t) { globalThis.TarinaiSeesawSystem?.release?.(t); return false; }, + }, { id: "play", need: "fulfill", diff --git a/js/tarinai_action_spec.js b/js/tarinai_action_spec.js index 02622ac..9ba1576 100644 --- a/js/tarinai_action_spec.js +++ b/js/tarinai_action_spec.js @@ -2,15 +2,11 @@ (function (global) { const registry = new Map(); - - function isObject(value) { - return Boolean(value && typeof value === "object"); - } - - function finiteOr(value, fallback) { + const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object")); + const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => { const n = Number(value); return Number.isFinite(n) ? n : fallback; - } + }); function hasOwnFn(obj, key) { diff --git a/js/tarinai_behavior_state.js b/js/tarinai_behavior_state.js index ba3edd8..c60bf69 100644 --- a/js/tarinai_behavior_state.js +++ b/js/tarinai_behavior_state.js @@ -3,15 +3,11 @@ (function (global) { const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js"); - - function isObject(value) { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); - } - - function finiteOr(value, fallback) { + const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object" && !Array.isArray(value))); + const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => { const n = Number(value); return Number.isFinite(n) ? n : fallback; - } + }); function nowFor(tarinai = null) { return finiteOr(tarinai?.world?.time, 0); @@ -82,9 +78,7 @@ return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || ""); } - function isEffectCauseText(text = "") { - return /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, "")); - } + const isEffectCauseText = global.TarinaiCoreHelpers?.isEffectCauseText || (text => /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""))); function normalizeBehaviorValue(input, tarinai = null, previous = null) { if (!isObject(input)) return null; diff --git a/js/tarinai_behavior_text.js b/js/tarinai_behavior_text.js index 0dee3cc..7464c2b 100644 --- a/js/tarinai_behavior_text.js +++ b/js/tarinai_behavior_text.js @@ -8,6 +8,7 @@ function fulfillPrimaryReason(tarinai, action = null) { if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044"; if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b"; if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044"; + if (actionId === "play_seesaw") return "\u904a\u3073\u305f\u3044"; if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b"; if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b"; @@ -50,9 +51,8 @@ function actionTextForReason(action = null, tarinai = null) { } function isEffectCauseText(text = "") { - return typeof globalThis.isTarinaiEffectCauseText === "function" - ? globalThis.isTarinaiEffectCauseText(text) - : /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, "")); + return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text) + ?? globalThis.isTarinaiEffectCauseText?.(text)); } function forcedCausePhrase(source = "", fallback = "") { diff --git a/js/tarinai_food_interaction_system.js b/js/tarinai_food_interaction_system.js index d15a874..724f04a 100644 --- a/js/tarinai_food_interaction_system.js +++ b/js/tarinai_food_interaction_system.js @@ -5,6 +5,7 @@ (function (global) { function updateCandidate(tarinai, ctx, it, dt = 0) { if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false; + if (it.type === "duplicator" && global.TarinaiSignalSystem?.powerOverride?.(it) === false) return false; const { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx; const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, { role: "food" }); diff --git a/js/tarinai_food_prototype_mixin.js b/js/tarinai_food_prototype_mixin.js index 23deb7f..e5b691e 100644 --- a/js/tarinai_food_prototype_mixin.js +++ b/js/tarinai_food_prototype_mixin.js @@ -8,12 +8,12 @@ Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ isServingFoodItem(it) { - if (it?.type === "duplicator" && it.storedFoodType) return true; + if (it?.type === "duplicator" && it.storedFoodType) return global.TarinaiSignalSystem?.powerOverride?.(it) !== false; return Boolean(it && isServingFoodType(it.type)); }, foodItemHasServingLeft(it) { if (!it || it.dead) return false; - if (it.type === "duplicator") return Boolean(it.storedFoodType); + if (it.type === "duplicator") return global.TarinaiSignalSystem?.powerOverride?.(it) !== false && Boolean(it.storedFoodType); if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2; this.ensureFoodServings(it); return (it.foodServingsRemaining || 0) > 0; @@ -34,7 +34,7 @@ }, consumeFoodServing(it, nutrition) { if (it?.type === "duplicator") { - if (!it.storedFoodType) return 0; + if (!it.storedFoodType || global.TarinaiSignalSystem?.powerOverride?.(it) === false) return 0; const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42); it.usedCount = (it.usedCount || 0) + 1; return value; diff --git a/js/tarinai_item_targeting.js b/js/tarinai_item_targeting.js index 2fa3f28..9f65be6 100644 --- a/js/tarinai_item_targeting.js +++ b/js/tarinai_item_targeting.js @@ -32,7 +32,7 @@ function effectiveFoodRoleMatches(item, role) { function roleMatches(item, role) { if (!item || item.dead) return false; if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false; - if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role); + if (item.type === "duplicator") return globalThis.TarinaiSignalSystem?.powerOverride?.(item) !== false && effectiveFoodRoleMatches(item, role); if (item.roles?.[role]) return true; if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role); if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed"; diff --git a/js/tarinai_needs_core.js b/js/tarinai_needs_core.js index 1f58f35..6e27ede 100644 --- a/js/tarinai_needs_core.js +++ b/js/tarinai_needs_core.js @@ -84,6 +84,7 @@ const TARINAI_ACTION_PRIORITY = { build_plushie: 28, use_plushie: 24, return_owned_structure: 22, + play_seesaw: 27, play: 20, wander_lightly: 10, }; diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 91a8457..dca841d 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -268,7 +268,7 @@ function actionSelectionBonus(action, need, tarinai, options = {}) { const p = tarinai?.fulfillReasonParts || {}; if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0); else if (action.id === "build_plushie") bonus += Number(p.plushie || 0) * 1.8 + Number(p.material || 0); - else if (action.id === "play" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2; + else if (action.id === "play" || action.id === "play_seesaw" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2; else if (options.includeComfortActions && (action.id === "return_owned_structure" || action.id === "use_plushie")) { bonus += Math.max(0, -Number(p.bedComfort || 0) - Number(p.plushieComfort || 0)) * 0.5; } @@ -311,6 +311,9 @@ function shouldStartActionBySubNeed(tarinai, action, needs) { const status = tarinai?.temperatureStatus || tarinai?.world?.temperatureStatusFor?.(tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15), tarinai); return Boolean(status && (status.discomfort || 0) >= 2.5); } + if (action?.id === "play_seesaw") { + return Boolean(globalThis.TarinaiSeesawSystem?.shouldSeek?.(tarinai?.world, tarinai, needs)); + } if (need !== "social" || !action?.subNeed) return shouldStartNeedAction(tarinai, need, needs); const parts = tarinai?.socialReasonParts || {}; const subNeed = action.subNeed; @@ -376,6 +379,9 @@ function choosePriorityNeedAction(needs, tarinai, world) { const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null; priority = (status?.harmful || (status?.harmExcess || 0) > 0) ? 92 : 53; } + if (action.id === "play_seesaw") { + priority = Math.max(priority, Number(globalThis.TarinaiSeesawSystem?.actionPriority?.(world, tarinai)) || 0); + } if (action.id === "eat_food") { const hunger = Number(tarinai?.hunger || 0) || 0; if (hunger >= 98 || needValue >= 88) priority = Math.max(priority, 120); @@ -495,6 +501,7 @@ function continueCurrentBehavior(tarinai, world, needs, dt) { || (action.id === "panic_escape" && (tarinai.fearTimer || 0) > 0.04 && tarinai.state === "panic") || (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04) || (action.id === "sunbath" && (tarinai.sunbathTimer || 0) > 0.04) + || (action.id === "play_seesaw" && Boolean(globalThis.TarinaiSeesawSystem?.validReservation?.(tarinai, world))) || ((action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04 && elapsed < 1.15); const foodSuppressed = need === "food" && !isCurrentBehaviorForced(tarinai) && (tarinai.mealCooldownUntil || 0) > now && (tarinai.hunger || 0) < 82 && value < 78 && elapsed >= Math.min(minDuration, 1.15); if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || isCurrentBehaviorForced(tarinai))) { diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index e247eb8..4dc0243 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -473,8 +473,8 @@ } if (this.target && !this.target.dead) { - const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target; - const routeStates = new Set(["seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "ant_attack", "seek_temperature"]); + const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : (this.state === "play_seesaw" ? (globalThis.TarinaiSeesawSystem?.seatWorld?.(this.target, this.seesawSeatIndex) || this.target) : this.target); + const routeStates = new Set(["seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature"]); const routeTargetItem = this.target?.shelterItem || this.target?.hostItem || this.target; const pathPadding = Math.max(18, (this.radius || 20) * 0.82); const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type; @@ -555,6 +555,9 @@ } else if (this.state === "play_ball") { strength = d < this.radius + 18 ? 1.75 : 1.28; sign = 1; + } else if (this.state === "play_seesaw") { + strength = d < this.radius + 12 ? 0.42 : 1.18; + sign = 1; } else if (this.state === "fight") { const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37); if (d < 18) { diff --git a/js/tarinai_update_policy.js b/js/tarinai_update_policy.js index 317e90f..be927e4 100644 --- a/js/tarinai_update_policy.js +++ b/js/tarinai_update_policy.js @@ -112,7 +112,7 @@ if (/birth_ritual/i.test(id) || state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) { lane = "birth"; interval = isVisible ? 0.12 : 0.24; - } else if (/approach_parent_or_child|follow_parent|approach_mate|seek_bed|play_ball|seek_material|build/i.test(id) || /follow_parent|seek_bed|play_ball/.test(state)) { + } else if (/approach_parent_or_child|follow_parent|approach_mate|seek_bed|play_ball|play_seesaw|seek_material|build/i.test(id) || /follow_parent|seek_bed|play_ball|play_seesaw/.test(state)) { lane = "moving"; interval = isVisible ? 0.10 : (isNear ? 0.22 : 0.55); } else if (/eat_food|eat/i.test(id) || state === "eat" || (tarinai.eatTimer || 0) > 0.04) { diff --git a/js/ui_charts.js b/js/ui_charts.js index 374f888..bb135e9 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -1,7 +1,7 @@ "use strict"; function colonyMoodTip() { - const mood = world?.evaluateColonyMood?.(true) || world?.colonyMood || null; + const mood = world?.evaluateColonyMood?.() || world?.colonyMood || null; return String(mood?.description || "\u5927\u304d\u306a\u554f\u984c\u304c\u5c11\u306a\u304f\u3001\u843d\u3061\u7740\u3044\u305f\u72b6\u614b\u3067\u3059\u3002"); } diff --git a/js/ui_family_layout.js b/js/ui_family_layout.js index ae1290d..d182e81 100644 --- a/js/ui_family_layout.js +++ b/js/ui_family_layout.js @@ -1,5 +1,7 @@ "use strict"; +function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } + function lineageNodeSort(a, b) { return (a.generation || 1) - (b.generation || 1) || (a.birthTime || 0) - (b.birthTime || 0) || diff --git a/js/ui_family_render.js b/js/ui_family_render.js index 5a79a3d..6b54f19 100644 --- a/js/ui_family_render.js +++ b/js/ui_family_render.js @@ -1,5 +1,7 @@ "use strict"; +function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } + function lineageSplitByActualEdges(component) { const family = lineageEdgeFamily(world.family || {}); const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values()); diff --git a/js/ui_layout_dialogs.js b/js/ui_layout_dialogs.js index fef2eeb..4cea4dc 100644 --- a/js/ui_layout_dialogs.js +++ b/js/ui_layout_dialogs.js @@ -1,5 +1,7 @@ "use strict"; +function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } + function resizeCanvas() { const rect = canvas.getBoundingClientRect(); const perfScale = window.TarinaiPerf.dprScale(); diff --git a/js/ui_log.js b/js/ui_log.js index 23fbe7e..38f6184 100644 --- a/js/ui_log.js +++ b/js/ui_log.js @@ -1,5 +1,7 @@ "use strict"; +function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } + function loadLogPushSettings() { try { const raw = localStorage.getItem("tarinai_log_push_settings_v2"); diff --git a/js/ui_selected.js b/js/ui_selected.js index b5616d0..456166e 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -1,6 +1,6 @@ "use strict"; -const escapeHtml = window.TarinaiUIHelpers.htmlEscape; +function escapeHtml(value) { return window.TarinaiUIHelpers.htmlEscape(value); } function quoteNameInRecordText(text, name) { const raw = String(text || ""); @@ -334,6 +334,7 @@ function isIdleDisplayText(value = "") { function reasonLabel(t) { if (!t) return "\u306a\u3057"; if (Number(t.electricShockUntil || 0) > Number(t.world?.time || world?.time || 0)) return "\u611f\u96fb\u3057\u3066\u3044\u308b\u3002"; + if (t.state === "play_seesaw" && globalThis.TarinaiSeesawSystem?.isPlaying?.(t, t.world || world)) return "\u30b7\u30fc\u30bd\u30fc\u3092\u3057\u3066\u3044\u308b\u3002"; const behavior = behaviorText(t); if (behavior) { const normalized = window.TEXT_CATALOG?.cleanBehaviorText?.(behavior, t.state || "") || behavior; diff --git a/js/ui_tools.js b/js/ui_tools.js index 381608f..110f59a 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -320,7 +320,7 @@ function renderToolPalette() { if (!def || def.simulationOnly || toolId === "observe") continue; const btn = document.createElement("button"); btn.className = "tool"; - const hasPhysicsIcon = category.id === "physics" && attachPhysicsToolIcon(btn, toolId); + const hasPhysicsIcon = attachPhysicsToolIcon(btn, toolId); const hasTextIcon = !hasPhysicsIcon && applyToolTextIcon(btn, def, toolId); if (!hasPhysicsIcon && !hasTextIcon) applyToolImageIcon(btn, def); btn.dataset.tool = toolId; diff --git a/js/version.js b/js/version.js index 6a0caee..7c9c287 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.16.60"; - const APP_BUILD = "tarinai-ui-v39-16-60"; + const APP_VERSION = "39.16.74"; + const APP_BUILD = "tarinai-ui-v39-16-74"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world.js b/js/world.js index 07422b6..96631f3 100644 --- a/js/world.js +++ b/js/world.js @@ -105,6 +105,8 @@ class World { constructor() { this.liveIdSerial = 0; this.liveTarinai = new Map(); this.lastBirthAt = -999; + this.lastColonyMoodPopulation = NaN; + this.nextColonyMoodEvalAt = 0; this.maxGeneration = 1; this.family = {}; this.familyVersion = 0; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index b20cda7..4eeb024 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -20,6 +20,15 @@ return dialog; } + function bindEditorClose(dialog, close, selectors = []) { + if (!dialog || typeof close !== "function") return; + for (const selector of selectors) { + const button = dialog.querySelector(selector); + if (button) button.onclick = close; + } + dialog.onclick = (event) => { if (event.target === dialog) close(); }; + } + function ensureSignboardEditor() { return ensureEditorDialog("signboardEditor", "signboard-editor hidden", `