p
This commit is contained in:
parent
d14550dad6
commit
63e10af865
86 changed files with 5400 additions and 1209 deletions
0
.achievement_data/.gitkeep
Normal file
0
.achievement_data/.gitkeep
Normal file
6
.achievement_data/.htaccess
Normal file
6
.achievement_data/.htaccess
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule mod_access_compat.c>
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
|
@ -8,8 +8,14 @@
|
|||
ExpiresByType image/webp "access plus 30 days"
|
||||
ExpiresByType audio/mpeg "access plus 30 days"
|
||||
ExpiresByType audio/wav "access plus 30 days"
|
||||
|
||||
</IfModule>
|
||||
<IfModule mod_headers.c>
|
||||
<FilesMatch "achievement_api\.php$">
|
||||
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.html?$">
|
||||
Header set Cache-Control "no-cache, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
|
|
|
|||
|
|
@ -94,3 +94,12 @@ This section records current boundaries only. Version-by-version cleanup history
|
|||
## v39.16.31 routing notes
|
||||
|
||||
- Hair Trigger preset: `js/world_reset_presets.js`; physics tool icon renderer: `js/ui_tools.js`; world tooltip offset: `js/render.js`.
|
||||
|
||||
|
||||
## Achievement system (v39.16.56)
|
||||
- `js/achievements.js`: 51 definitions, local unlock state, persistent placement/ant-kill/reproduction/fight/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; the current save schema intentionally has no backward compatibility.
|
||||
- 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.
|
||||
|
|
@ -6,7 +6,7 @@ tarinai_compact_save_stage4
|
|||
- Deflate state is folded into the low bit of the frame-length varuint, eliminating the separate compression byte.
|
||||
- 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map.
|
||||
- Previous binary schemas are intentionally unsupported.
|
||||
- Version: 39.16.31
|
||||
- Version: 39.16.56
|
||||
- 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.
|
||||
|
|
@ -30,13 +30,13 @@ tarinai_compact_save_stage4
|
|||
|
||||
- v39.16.19: added direct food/medicine giving by placement or pinched drop, with a give overlay, stronger food-gift personality change, and heart bursts; removed unused collision reasons and duplicate constraint projection dirty checks.
|
||||
|
||||
- v39.16.20: localized the direct-feeding personality reason, normalized electrocution deaths to 「感電」, added detector-controlled gate fences, made chart legend labels fully clickable, and escaped non-ASCII runtime source.
|
||||
- v39.16.20: localized the direct-feeding personality reason, normalized electrocution deaths to the Japanese electrocution label, added detector-controlled gate fences, made chart legend labels fully clickable, and escaped non-ASCII runtime source.
|
||||
|
||||
- v39.16.21: hid pinch overlays for rope/rod/spring/wire connection items and added detector time schedules with a dual-thumb range control, overnight ranges, and persistent copy/save state.
|
||||
|
||||
- v39.16.22: shortened detector copy, removed the time-range explanatory hint, cascaded connection-item removal when any endpoint disappears, and repaired detached plushies so they return to a living owner or disappear with a missing owner.
|
||||
|
||||
- v39.16.23: excluded transient splat effects from all connection endpoint selection and renamed the user-facing feeding need from 「摂餌」 to 「食欲」.
|
||||
- v39.16.23: excluded transient splat effects from all connection endpoint selection and renamed the user-facing feeding need from the formal feeding term to the appetite label.
|
||||
- v39.16.25: added toilet-seeking before normal defecation; renamed rain shelter to parasol; added sunny +3.5C and unsheltered rain -3.5C felt-temperature corrections; parasols neutralize both and block sunbathing. Pet food, first aid, and sedative remain included.
|
||||
|
||||
- v39.16.29: replaced toilet-seeking with walkable toilet sand that immediately removes poop spawned on its surface; added a parasol ground shadow and explicit hot-sun/cold-rain temperature-seeking targets; fixed null candidate temperatures being interpreted as 0C.
|
||||
|
|
@ -46,3 +46,42 @@ tarinai_compact_save_stage4
|
|||
- v39.16.30: added a few bounce fences to War, reduced ordinary-fence restitution, restored ball collisions for visible sleeping Tarinai, moved nest-box tooltips closer, allowed movable-item placement overlap, and fixed delete-tool target lookup.
|
||||
|
||||
- v39.16.31: unified in-world tooltip spacing, added the Hair Trigger preset, and replaced physics-category line-like tool icons with distinct semantic silhouettes.
|
||||
|
||||
- v39.16.35: moved snap-mode switching to a dedicated operation control with Free/20px/40px/60px cycling, removed F/G from size badges, allowed valid placement against the field top edge, and standardized preset descriptions with Japanese full stops.
|
||||
|
||||
- v39.16.37: unified biological/environmental conditions into the detector, removed the standalone sensor and relay types with no save migration, folded temperature/limits, moved secondary controls into the header menu, preserved tool mode while browsing tarinai data, and added five-second mobile selection tips.
|
||||
|
||||
- v39.16.38: detector numeric sliders match the time-control presentation; colony settings are unified; display settings are inline; meter colors, snap control, range-delete icon, and ant chart defaults are revised.
|
||||
|
||||
- v39.16.39: restored the compact sticky operation palette, added exact-match panel search, reorganized colony settings/stats, and refreshed range-delete and mechanism icons.
|
||||
- v39.16.40: moved partial panel search to the header with previous/next navigation and tooltip text indexing; localized snap mode labels; simplified bounce-fence and reciprocator icons; matched expanded colony settings to the chart background.
|
||||
|
||||
39.16.41: Added local achievements and optional self-hosted shared unlock percentage API.
|
||||
|
||||
39.16.42: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking.
|
||||
|
||||
39.16.44: Expanded achievements to fifteen conditions, changed rapid deaths to 50 in 10 seconds, moved conditions into game tooltips, added toast-to-button absorption, and added colony disease/happiness, cumulative hand feeding, ritual ignition, and laxative-starvation achievements.
|
||||
|
||||
39.16.44: Audited all achievements; fixed impossible slave generation tracking, persisted generation runs, queued simultaneous unlock toasts, hardened launched-zunchi stop detection, and preserved laxative-at-death timing.
|
||||
|
||||
39.16.45: Added seven play-style achievements for passive observation, fifty world placements, detector automation, rope/rod/spring coverage, a five-minute death-free 25-Tarinai colony, 25 live ants, and 30 cumulative ant kills; persisted world-scoped timers and placement progress.
|
||||
|
||||
39.16.46: Revised achievement naming and disclosure UI, replaced achievement tooltips with inline post-unlock descriptions and horizontal shared-rate bars, merged detector automation plus link coverage into 機械化産業, changed placement and ant-kill goals to persistent totals of 100, and made direct-feeding progress reset with the field.
|
||||
|
||||
39.16.47: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button.
|
||||
|
||||
39.16.48: Corrected the zunchi-slave fight-record threshold text to 30%; fixed wire placement totals, danger-kill attribution for 喧嘩両成敗, partial legacy link migration, strict player-ID validation, post-unlock storage writes, and debug-only unlock isolation from shared statistics.
|
||||
|
||||
39.16.49: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss.
|
||||
|
||||
39.16.51: Expanded achievements from 27 to 43 with history, cleanup, care, ecology, temperature, weather, medicine, combat, and ant-experiment conditions; persisted the required reset-scoped metadata in a new incompatible save schema; made nest-box and pipe occupants weather-protected; and added false-positive guards for time-zero markers and stale ant damage attribution.
|
||||
|
||||
39.16.52: Reorganized achievements into four collapsible categories, removed achievement tooltips, surfaced pre-unlock disclosures in the achievement UI, and replaced native browser confirmations with a shared in-game dialog.
|
||||
|
||||
|
||||
39.16.53: Renamed the four achievement categories to 生態/実験/建築/その他, moved unlocked achievements to the top of their own categories, disclosed four additional conditions before unlock, raised 大いなる母 to 3333 births, and moved achievement unlock toasts to the upper-left.
|
||||
|
||||
39.16.54: Added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足; disclosed 悠久の歴史 before unlock; reduced achievement overhead by skipping closed-dialog list rebuilds, caching category mappings, debouncing pending sync, and retaining periodic full recovery sync.
|
||||
|
||||
39.16.55: Added 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one uninterrupted happy in-game day from play day 6; repaired shared つつく targeting, damage, plushie fling, and hover overlay; corrected manual rotator/reciprocator push direction; tightened tool/limit typography; reduced periodic achievement scans; and changed server aggregate writes to compact v2 JSON with legacy-state migration.
|
||||
39.16.56: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards.
|
||||
|
|
|
|||
|
|
@ -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.31",
|
||||
"version": "39.16.56",
|
||||
"entrypoints": {
|
||||
"app": "index.html",
|
||||
"runtime": "js/main.js",
|
||||
|
|
@ -37,7 +37,9 @@
|
|||
"AI_MAP.md",
|
||||
"GAME_FEATURES.md",
|
||||
"FILES.json",
|
||||
"service-worker.js"
|
||||
"service-worker.js",
|
||||
"achievement_api.php",
|
||||
".achievement_data/.htaccess"
|
||||
],
|
||||
"meaning": "Repo/app shell metadata, server/static cache config, docs, manifest, entrypoint."
|
||||
},
|
||||
|
|
@ -149,7 +151,8 @@
|
|||
"js/ui*.js",
|
||||
"js/text_catalog.js",
|
||||
"js/command_dispatcher.js",
|
||||
"js/family_graph.js"
|
||||
"js/family_graph.js",
|
||||
"js/achievements.js"
|
||||
],
|
||||
"meaning": "DOM UI, input, tools, selected panel, logs, charts, family tree, text labels, command routing."
|
||||
},
|
||||
|
|
|
|||
348
GAME_FEATURES.md
348
GAME_FEATURES.md
|
|
@ -1,340 +1,86 @@
|
|||
## v39.16.17 mechanical one-way fence runtime fix
|
||||
|
||||
- Mechanical objects now query their canonical physics-body velocity when resolving one-way-fence contacts.
|
||||
- This removes the `bodyFor is not defined` runtime failure while preserving the existing directional fence behavior.
|
||||
|
||||
## v39.16.16 directional fences, chart controls, and living wire endpoints
|
||||
|
||||
- Tarinai can physically cross one-way fences in the arrow direction, including swept/high-speed movement.
|
||||
- Colony chart color dots toggle individual series.
|
||||
- Wire and insulated wire can attach to Tarinai and ants. Energized living endpoints take damage and panic regardless of insulation; only bare wire damages incidental contact.
|
||||
- Selected Tarinai data shows `感電している。` while shocked.
|
||||
|
||||
|
||||
## v39.16.16 colony lineage and directional routing
|
||||
|
||||
- Population history overlays interval birth and death counts.
|
||||
- Rotators preserve negative motor speed and rotate in reverse.
|
||||
- Tarinai Champion boosts to aggression, openness, and sociability pass to descendants with generational damping.
|
||||
- Champion-pair mating preference is slightly reduced.
|
||||
- One-way fence placement previews show the allowed direction.
|
||||
- Pathfinding treats one-way fences as passable only along their arrow direction.
|
||||
|
||||
## v39.16.16 colony graph, reverse rotator, and champion lineage
|
||||
|
||||
- コロニー統計の個体数グラフへ、2時間区間ごとの誕生数と死亡数を追加した。
|
||||
- 回転体のモーター速度は負値を保持し、負の角度方向へ逆回転できる。
|
||||
- たりない王の攻撃性・開放性・社交性補正は子孫の誕生時性格へ減衰継承される。
|
||||
- たりない王同士の繁殖優先倍率を5.0から4.0へ下げ、繁殖確率をやや抑えた。
|
||||
- 保存形式は変更しない。
|
||||
|
||||
# tarinai_ Game Features
|
||||
|
||||
## v39.16.16 link, bounce-fence, and spring fixes
|
||||
`tarinai_` is an observation sandbox about small creatures called tarinai. The player shapes a field with food, medicine, furniture, hazards, machines, weather, and ground choices, then watches how the colony reacts. The game is about cause and effect: small changes in the environment can alter movement, stress, disease, relationships, reproduction, accidents, cleanup, and survival.
|
||||
|
||||
- ヒモ、棒、バネ、電線、絶縁電線は、接続線が画面内を横切る場合、中点の空間セルが画面外でも描画される。
|
||||
- バウンス柵はたりないを外力インパルスで確実に吹き飛ばし、柵自身が作る急激な速度変化では衝撃ダメージを与えない。
|
||||
- バネは受動式のまま剛性を高め、減衰と強制位置補正を下げ、より大きく長く往復する。
|
||||
## Core Play
|
||||
|
||||
## v39.16.13 detector and fence changes
|
||||
The player starts by choosing a field and ground, then places objects or uses direct tools on the colony. Tarinai continue acting on their own while the player observes, edits the environment, removes problems, or creates new ones. The colony panel, event log, selected-tarinai data, and family tree help explain what is happening without taking control away from the simulation.
|
||||
|
||||
- 感圧スイッチを「検知器」へ改名し、道具UI上で通常の柵と位置を入れ替えた。
|
||||
- 検知器は軸平行の矩形範囲で対象を数えるほか、時刻も検知できる。専用UIから対象・必要数・範囲、または二つまみスライダーによる作動時間帯を設定する。
|
||||
- 柵の配置境界判定を実際の細長い形状に合わせ、フィールド上下端近くへ配置できるようにした。
|
||||
- バウンス柵の反発係数、最低射出速度、最大速度を引き上げ、衝突直後に強く跳ねるようにした。
|
||||
- ワープゲートを道具、描画、更新、信号、保存形式から削除した。
|
||||
- 一方通行柵を追加した。矢印方向には通過でき、逆方向のみ衝突する。
|
||||
- バイナリスキーマ36、スナップショット30。旧形式との読み込み互換は持たない。
|
||||
The game supports quiet observation and chaotic experiments. A player can build a comfortable habitat with beds, food, grass, and cleaning tools, or create a dangerous machine field with rotators, fences, stones, pins, explosions, ants, and poison.
|
||||
|
||||
## v39.16.11 mechanism fixes and passive spring
|
||||
## Tarinai
|
||||
|
||||
- 検知器は矩形範囲内の「たりない」または「アイテム」を数えるか、設定した時刻帯に入ると電線へ信号を送る。開始時刻が終了時刻より後なら日付をまたぎ、同時刻なら終日作動する。
|
||||
- 一方通行柵は矢印方向だけ通過でき、逆方向のたりない・アイテム・機構を遮る。
|
||||
- バウンス柵は衝突対象へ強い即時反発を与える。
|
||||
- バネは棒と同じ2点接続方式で設置する。自動伸縮はせず、外力による伸び縮みに対して受動的に反発し、弾性運動する。
|
||||
- 電線と絶縁電線はアイテム同士を接続し、複数本を経由して信号を伝える。通常電線は通電中に接触したたりないへダメージを与え、絶縁電線は与えない。
|
||||
- ヒモ、棒、バネ、電線、絶縁電線は、いずれかの接続先が消滅すると連鎖的に消滅する。
|
||||
- 所有者が生存しているぬいぐるみは、投擲中やプレイヤー所持中を除き頭上へ復帰し、地面に放置されない。
|
||||
- バイナリスキーマ34、スナップショット28。v39.16.10と保存形式は同じ。
|
||||
Tarinai are autonomous creatures with HP, needs, stress, personality, age, family links, diseases, item effects, and behavior states. They choose actions from their current condition and surroundings instead of following a fixed script. Hunger can make them seek grass or food, sleepiness can make them find a bed, fear can make them flee, and social state can make them follow, fight, avoid, or approach another tarinai.
|
||||
|
||||
## v39.16.9 load UI and item behavior fixes
|
||||
Each tarinai has individual tendencies. Aggressive tarinai fight more easily, social tarinai seek others more often, nervous tarinai react strongly to danger, and inherited traits can make children resemble their parents. Selecting a tarinai shows health, needs, stress, effects, relations, inventory, family information, and the reason for its current behavior.
|
||||
|
||||
- Replaced browser-native load confirmations with an in-game confirmation window.
|
||||
- Kept fans in the realtime scheduler lane so wind applies continuously instead of as periodic gusts.
|
||||
- Made the zunchi tool preview use the fresh, fully opaque stage.
|
||||
- Rendered carried plushies at the same depth position as their owner.
|
||||
- Disabled grass spawning, growth, placement capacity, and existing grass on the fluffy blanket ground.
|
||||
Tarinai can be damaged by hazards, pushed by physics objects, carried by ants, attached to links, blocked by fences, affected by medicine, frightened by accidents, comforted by habitat items, or preserved when outside the active field. Some objects can become emotionally important, so losing them may cause panic.
|
||||
|
||||
## v39.16.8 compact amount, relationship, seed, and framing save
|
||||
## Food And Consumables
|
||||
|
||||
- アイテム種類の標準amountは保存せず、標準値と異なる共通値または個別差分だけを保存する。共通値候補は実バイト数で選択する。
|
||||
- 個体関係の対象インデックスを昇順に並べ、前の対象との差分として保存する。
|
||||
- worldSeedを固定16進64bitへ統一し、バイナリ上では8バイト固定で保存する。
|
||||
- バイナリ先頭はスキーマ1バイトへ統合し、上位bitで文字列テーブルの有無を示す。空の文字列テーブルと重複したスナップショット版番号は保存しない。
|
||||
- 圧縮フラグをフレーム長の下位bitへ統合し、独立した1バイトを削除する。
|
||||
- 15bit文字化の32768文字テーブルと逆引きMapを廃止し、Unicodeコードポイントの算術変換へ変更する。
|
||||
- バイナリスキーマ33、スナップショット27。旧形式との読み込み互換は持たない。
|
||||
|
||||
## v39.16.7 column-oriented creature save and packed item flags
|
||||
Grass is both food and habitat material. Tarinai can eat it, feel comfort near it, gather it for simple beds, and interact with it through ground and weather systems. Water can be drunk, can appear from rain, and can be generated or copied by tools.
|
||||
|
||||
- 個体の固定情報を項目別の列指向ビットプレーンへ変更し、全個体で0の列は保存しない。
|
||||
- 睡眠状態、遺伝差分、戦績差分の存在は各ID・差分マスクから直接判定し、重複していた3bit/個体を削除する。
|
||||
- アイテムの共通追加状態、amount差分、追加状態差分のフラグを全ブロック共通の連続ビット列へ格納する。
|
||||
- 共通追加状態は候補ごとに専用バイナリを試算し、全件個別保存より実際に小さい場合だけ採用する。
|
||||
- バイナリスキーマ32、スナップショット26。旧形式との読み込み互換は持たない。
|
||||
|
||||
## v39.16.4 compact identity and normal-item binary save
|
||||
|
||||
- 個体のbirthSeed文字列を保存せず、出生番号と32bitハッシュへ分解する。`worldSeed + birthSerial`で再現できる個体はハッシュも省略する。
|
||||
- 新規出生のbirthSeedは親文字列を含めず、親子関係と遺伝情報は既存の専用フィールドで保持する。
|
||||
- 通常アイテムの追加情報を型別専用バイナリへ移行し、JSONテキストを保存しない。未知の追加配列も数値・文字列の型付きバイナリで保存する。
|
||||
- 同じ種類のアイテムはamountの最頻値をブロックに1回だけ保存し、同一追加状態が複数ある場合も共有する。個別アイテムには差分だけを保存する。
|
||||
- バイナリスキーマ30、スナップショット24。旧形式との読み込み互換は持たない。
|
||||
|
||||
## v39.16.3 physics binary save and reciprocator cargo contacts
|
||||
|
||||
- 回転体・毒ブロック・往復体・ロープ・ロッドの追加情報を、JSON文字列ではなく専用バイナリで保存する。型名、キー名、デフォルト値、重複座標、標準輪郭、インデックス参照済み接続先の文字列情報を省略する。
|
||||
- バイナリスキーマは29。v39.16.2のスキーマ28は読み込み互換を維持する。
|
||||
- 往復体はボール、石、画鋲などの可動物理アイテムを押し出すが、それらとの接触では方向、レール位相、モーター速度を変更しない。
|
||||
|
||||
## v39.16.2 reciprocator collision and placement alignment
|
||||
|
||||
- 往復体は、進行方向にある機構、静的障害物、物理アイテムへ衝突した時点で方向転換する。接触分離によって重なりが解消されても反転判定を失わない。
|
||||
- 静的障害物との判定から往復体を除外しない。高速移動時は障害物付近でサブステップ判定を増やし、すり抜けを抑える。
|
||||
- 往復体の設置前オーバーレイと実配置は同じ角度初期化処理を使用し、物理ボディー角度とレール軸を同じ順序で確定する。
|
||||
- セーブ形式は変更しない。
|
||||
|
||||
## v39.16.1 debug save-size breakdown
|
||||
|
||||
- `?debug=1` でセーブまたは呪文出力を実行すると、デバッグオーバーレイにバイナリ内訳、文字数、エンコード時間が表示される。
|
||||
- 内訳は形式情報、文字列テーブル、ワールド、個体基本情報、個体シード、個体関係、アイテム索引、通常アイテム、物理アイテムに分けて実占有バイトを集計する。
|
||||
- 通常アイテム・物理アイテムの追加情報について、バイナリ内の占有量とJSON UTF-8元サイズを併記する。ロード後は直近のデコード時間も表示する。
|
||||
|
||||
This document describes the playable features of `tarinai_`: what the player sees, what tools do, and how tarinai behave. It is intentionally player/design-facing, not an implementation map.
|
||||
|
||||
## v39.16.0 mechanical interaction and limit audit
|
||||
|
||||
- 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。
|
||||
- 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。
|
||||
- コロニー上限は中央生成APIで常時適用され、通常実行時に上限を迂回するオプションは持たない。既存要素やセーブ復元要素は削除せず、その後の新規生成のみを抑止する。
|
||||
|
||||
## v39.15.165 behavior and colony controls
|
||||
|
||||
- たりない王は、繁殖可能な別のたりない王を広い範囲から優先して探し、通常より強い繁殖欲求と短い再繁殖待機時間を持つ。
|
||||
- 往復体の新規配置時の標準方向は90度。
|
||||
- 毒ブロックの新規配置時の標準輪郭は小型(半径44、輪郭104×40、太さ11)。
|
||||
- コロニーパネルで、たりない個体数と生存オブジェクト総数の上限を設定できる。空欄または0は上限なし。既存要素は削除せず、新規追加だけを抑止する。
|
||||
- 上限値はスナップショットとバイナリセーブに保存される。
|
||||
|
||||
## Game Overview
|
||||
|
||||
`tarinai_` is an observation sandbox about small creatures called tarinai. The player places food, furniture, hazards, machines, and environment tools, then watches how individuals live, move, eat, sleep, panic, fight, reproduce, get sick, recover, and leave family records.
|
||||
|
||||
The core loop is:
|
||||
|
||||
1. Choose a field and observe the colony.
|
||||
2. Place tools or objects that change the environment.
|
||||
3. Select individual tarinai to inspect health, needs, personality, relations, diseases, records, and current behavior.
|
||||
4. Watch the event log, colony graphs, and family tree to understand long-term outcomes.
|
||||
|
||||
## Main UI
|
||||
|
||||
- **Canvas / field**: the main simulation area. Tarinai, items, effects, ants, weather, and terrain are drawn here.
|
||||
- **Top buttons**: pause/resume, speed control, ecology guide, sound settings, credits, save, display settings, and reset.
|
||||
- **Display settings**: manually adjust effects, shadows, and drawing detail. `High` keeps quality-preserving optimizations active, while lower tiers cut heavier visuals for crowded scenes without lowering object limits; simulation cadence remains quality-preserving.
|
||||
- **Mobile mode controls**: auto, camera, and tool modes for touch screens.
|
||||
- **Right panel tabs**: jump between main game, selected tarinai data, tools, colony, events, and family tree.
|
||||
- **Tarinai data panel**: appears when a tarinai is selected. Shows health, needs, personality, active effects, relations, inventory, behavior reason, and records.
|
||||
- **Tool palette**: grouped buttons for operation, food, medicine, habitat, physics, and danger tools.
|
||||
- **Colony panel**: shows population, deaths, colony mood, ground type, and charts for population, needs, and objects.
|
||||
- **Event panel**: logs births, deaths, accidents, fights, food events, weather, grass, and relation changes. Optional push-style notifications can be enabled by event type.
|
||||
- **Family tree**: shows parent-child generations, inherited stats, personality tags, family links, and archived lineage records.
|
||||
- **Ecology dialog**: in-game guide explaining tarinai needs, stress, food, sleep, personality, family, disease, accidents, tools, and colony reading.
|
||||
- **Sound menu**: master sound plus voice, notification, and operation categories.
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### Operation
|
||||
|
||||
- **解除 / clear**: cancels the selected tool and returns to normal observe mode.
|
||||
- **たりないデータ / observe**: select a tarinai or inspect compatible objects such as signboards.
|
||||
- **追加 / new**: calls a new tarinai into the field.
|
||||
- **つつく / poke**: pokes tarinai and can push balls.
|
||||
- **つまむ / pinch**: drags tarinai and some placed objects.
|
||||
- **コピー / copy**: copies a placed item and pastes a new instance with its settings.
|
||||
- **戻す / undo** and **進む / redo**: reverse or reapply placement/edit actions.
|
||||
- **削除 / delete**: removes placed tools, objects, and stains.
|
||||
- **範囲削除 / area delete**: removes many nearby objects or stains around the cursor.
|
||||
- **洗浄 / water hose**: drag to clean dirt, zunchi, corpses, and stains.
|
||||
|
||||
### Food
|
||||
|
||||
- **ずんだ餅 / sweet**: favored food; can cure some illness.
|
||||
- **へこ餅 / love mochi**: makes reproduction behavior more likely for a while.
|
||||
- **けんか餅 / fight mochi**: makes fighting behavior more likely for a while.
|
||||
- **草 / grass**: food and comfort source; can grow, be used for simple beds, and sometimes dampen nearby fire instead of immediately catching.
|
||||
- **水滴 / water**: drinkable water, also appears during rain and can be set in duplicators.
|
||||
- **ずんだ汁 / zunda juice**: clears item effects from a tarinai.
|
||||
- **複製機 / duplicator**: can be loaded with compatible food, medicine, grass, water, pushpins, or oshibyo and then produces or applies that content.
|
||||
Sweet food helps satisfy hunger and can support recovery in some cases. Love mochi makes reproduction behavior more likely for a while, while fight mochi pushes the colony toward conflict. Zunda juice clears item effects, making it useful after medicine or special consumables have changed a tarinai too much.
|
||||
|
||||
### Medicine
|
||||
Medicine changes the simulation more aggressively than normal food. Sleep drugs push tarinai toward sleep or sleep disease, laxatives create repeated zunchi and reduce food benefit, protein makes a stronger permanent mode with lower disease risk, and niteropu makes a weaker permanent mode with higher disease risk. Ammo creates temporary speed and impact danger. Mystery drugs choose from eligible effects unpredictably. Mercury changes lifespan, while giant and dwarf drugs reshape size, movement, power, and ongoing damage risk.
|
||||
|
||||
- **ねむり薬 / sleep drug**: causes sleepiness or sleep disease; damage can cure the disease.
|
||||
- **下剤 / laxative**: causes repeated zunchi and reduces food benefit.
|
||||
- **プロテイン / protein**: permanent power mode; more damage dealt, lower disease chance.
|
||||
- **ンイテロプ / niteropu**: permanent weak mode; less damage dealt, higher disease chance.
|
||||
- **弾薬 / ammo**: temporary high-speed movement and strong collision impact.
|
||||
- **怪しい薬 / mystery drug**: randomly applies an eligible item effect or disease.
|
||||
- **水銀 / mercury**: increases lifespan multiplier on each use.
|
||||
- **巨大薬 / giant drug**: greatly increases size, attack, and speed, but deals ongoing damage.
|
||||
- **矮小薬 / dwarf drug**: greatly decreases size, attack, and speed, and also deals ongoing damage.
|
||||
## Habitat And Cleanup
|
||||
|
||||
### Habitat
|
||||
Zunchi is waste, but it is also part of the ecology. It affects hygiene and disease risk and can fertilize grass. Beds and nest boxes give tarinai safer or better sleep options, reduce stress around resting areas, and help recovery. Balls give tarinai something to play with and can also become part of physics experiments.
|
||||
|
||||
- **ずんち / zunchi**: tarinai waste; affects disease and can fertilize grass.
|
||||
- **ロボ掃除機 / robot_cleaner**: moves through the field at fixed normal speed 60, or 120 in high-speed mode. It checks the nearest matching cleanup target every 2 seconds, keeps pursuing it between scans, and cleans every matching object it touches. The 死骸 target also covers blood traces; clicking the item opens its target settings.
|
||||
- **干草寝床 / bed**: placeable sleeping spot; helps HP recovery.
|
||||
- **巣箱 / nest box**: reduces stress nearby; improves sleep quality when used.
|
||||
- **ボール / ball**: toy that tarinai can play with and the player can poke.
|
||||
- **看板 / signboard**: editable text object; inspect with observe after placing.
|
||||
Robot cleaners are mobile cleanup items. They scan for selected target types, pursue matching waste or corpses, and remove them on contact. The water hose gives the player direct cleanup control by washing dirt, zunchi, corpses, stains, and similar field problems. Signboards are editable objects that can be inspected after placement, useful for labeling areas or experiments.
|
||||
|
||||
### Physics
|
||||
|
||||
- **ヒモ / rope**: connect two targets with a flexible, inextensible link that may go slack but does not lengthen.
|
||||
- **棒 / rod**: connect two targets with a rigid link.
|
||||
- **柵 / fence**: blocks tarinai movement; Q/E rotate by 45 degrees, Shift+Q/E by 5 degrees.
|
||||
- **バウンス柵 / bounce fence**: high-restitution fence that strongly launches things that hit it.
|
||||
- **ゲート柵 / gate fence**: click to open or close.
|
||||
- **回転体 / rotator**: custom mechanical shape that rotates; click to edit shape and speed.
|
||||
- **毒ブロック / poison block**: custom hazard block; touching it damages skin; click to edit shape.
|
||||
- **往復体 / reciprocator**: custom mechanical shape that moves back and forth; click to edit shape and speed.
|
||||
## Physics And Machines
|
||||
|
||||
### Danger
|
||||
Physics items turn the field into a mechanical sandbox. Ropes connect two targets with a flexible link that can go slack but should not stretch freely. Rods create a rigid connection. Springs connect targets with elastic motion, allowing linked objects to pull back and oscillate.
|
||||
|
||||
- **石 / stone**: heavy object; dangerous when dropped or moved into others.
|
||||
- **げんこつ / genkotsu**: large fist impact on the ground.
|
||||
- **爆竹 / firecracker**: creates an explosion; flame variants scatter fire while respecting active-fire limits.
|
||||
- **画鋲 / pushpin**: rolls when dropped; can lodge in tarinai and cause panic plus continuous damage.
|
||||
- **おしり鋲 / oshibyo**: blocks zunchi when lodged; pulling it out releases stored zunchi.
|
||||
- **射撃 / shoot**: attacks with a gun effect.
|
||||
- **アリの巣 / ant nest**: spawns ants that search for tarinai and carry them to the nest.
|
||||
|
||||
## Tarinai Needs And Stats
|
||||
|
||||
Tarinai behavior is shaped by health, energy, needs, stress, personality, family, disease, and nearby objects.
|
||||
|
||||
Key needs include:
|
||||
|
||||
- **Food / hunger**: hungry tarinai seek grass, sweets, food, water-related options, or other edible targets.
|
||||
- **Sleep**: sleepy tarinai seek beds, nest boxes, grass beds, or eventually sleep anywhere.
|
||||
- **Safety**: danger, pain, hazards, ants, fights, and panic increase defensive behavior.
|
||||
- **Social / relations**: tarinai can seek friends, follow parents, avoid enemies, fight rivals, or approach mates.
|
||||
- **Fulfillment / comfort**: toys, ground, sun, shelter, and environment affect mood and stress.
|
||||
- **Health**: damage, disease, stress, and recovery decide whether tarinai live, weaken, or die.
|
||||
|
||||
Stress changes with fear, crowding, dirt, disease, fights, ground type, and environment. High stress makes fleeing, intimidation, sickness, and poor behavior more likely.
|
||||
|
||||
Personality and genetics affect tendencies such as aggression, openness, sociability, nervousness, body size, lifespan, damage, and speed. Some traits are inherited by children and shown in the family tree.
|
||||
|
||||
## Movement And Behavior
|
||||
|
||||
Tarinai are autonomous. Their movement is usually a result of current state, target choice, and local environment.
|
||||
|
||||
Common behavior states:
|
||||
|
||||
- **Idle / wander**: waits, watches, or moves lightly around the field.
|
||||
- **Seek food**: moves toward edible items when hungry or affected by disease.
|
||||
- **Eat / drink**: consumes food, grass, water, medicine, or special items.
|
||||
- **Seek bed / sleep**: finds a bed, nest box, grass bed, or sleeps in place.
|
||||
- **Play ball**: approaches and interacts with balls.
|
||||
- **Seek material / build**: gathers material and creates simple structures such as grass beds.
|
||||
- **Seek friend / follow parent**: moves toward social targets or parents.
|
||||
- **Fight / seek enemy**: approaches and attacks rivals, especially under fight effects or aggression.
|
||||
- **Panic / flee**: runs from danger, pain, destroyed owned objects, hazards, or fear triggers.
|
||||
- **Intimidate**: threatens another tarinai or ants instead of directly fleeing or attacking.
|
||||
- **Birth ritual**: pre-birth/reproduction interaction with a partner.
|
||||
- **Sunbath**: rests in good sun to reduce stress.
|
||||
- **Disease states**: zunchi sickness, sleep disease, explosion disease, and injury sickness can override normal priorities.
|
||||
- **Frozen**: outside-field preservation state.
|
||||
|
||||
Tarinai can be pushed by physics objects, carried or affected by ants, dragged by the player, attached with ropes/rods, blocked by fences, and damaged by hazards. Some objects become owned or emotionally important, so losing them can trigger panic.
|
||||
|
||||
## Environment
|
||||
Fences block movement and can be rotated for layout control. Gates can be opened or closed after placement. One-way fences allow passage from one direction while blocking the opposite direction. Bounce fences launch colliding targets more strongly than normal fences, which makes them useful for traps or kinetic experiments.
|
||||
|
||||
- **Field types**: park, garden, and cage provide different field sizes.
|
||||
- **Ground types**: sand, dirt, concrete, blanket, foot massage, ice, and laboratory-style ground affect stress, grass, and movement.
|
||||
- **Weather**: rain and other weather influence water, mood, and environment effects.
|
||||
- **Grass ecology**: grass grows, feeds tarinai, comforts them, can become bed material, and has a small chance to reduce fire before burning.
|
||||
- **Dirt and corpses**: zunchi, splats, corpses, and ant corpses affect hygiene, disease, and observation results.
|
||||
- **Day/light conditions**: lighting and sun influence rendering and sunbath behavior.
|
||||
- **Season temperature**: automatic summer climate is roughly 5℃ hotter than the previous baseline; manual temperature control remains exact to the slider value.
|
||||
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.
|
||||
|
||||
## Crowded Scene Performance
|
||||
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.
|
||||
|
||||
Crowded fields are an intended sandbox outcome. Performance work should prefer cached, typed, spatial, dirty, or incremental lookups over lowering object limits. High display quality should preserve visible fidelity and simulation rules while avoiding repeated hidden scans in render, tooltip, family tree, collision, effect, fire, and maintenance paths.
|
||||
## Danger Tools
|
||||
|
||||
## Disease And Recovery
|
||||
Danger tools create immediate or ongoing pressure. Stones are heavy objects that become dangerous when dropped, thrown, or moved by machines. Genkotsu creates a large impact on the ground. Firecrackers cause explosions, with flame variants adding fire while respecting active-fire limits.
|
||||
|
||||
Diseases and item effects can strongly redirect behavior.
|
||||
Pushpins can roll, lodge in tarinai, cause panic, and deal continuous damage. Oshibyo blocks zunchi while lodged and releases stored zunchi when removed. Shooting is a direct attack tool that can hit tarinai, ants, items, pins, balloons, or placed objects depending on the target under the aim point.
|
||||
|
||||
- **Zunchi disease**: changes food/water priorities and makes the tarinai feel unwell.
|
||||
- **Sleep disease**: causes sleep-focused behavior.
|
||||
- **Explosion disease**: creates dangerous explosive outcomes and water-seeking pressure.
|
||||
- **Fight disease / injury sickness**: causes unstable movement or fighting-related behavior.
|
||||
- **Item effects**: mochi, medicine, size drugs, mercury, ammo, and power modes create temporary or permanent modifiers.
|
||||
Ant nests introduce an enemy system. Ants search for tarinai, carry captured targets back to the nest, and can be shot or killed. Worker ants and queen ants have separate HP values, and ant counts are shown through colony statistics.
|
||||
|
||||
Recovery depends on the disease or effect. Water, rest, food, medicine, zunda juice, damage, environment cleanup, or time can all matter depending on the case.
|
||||
## Operation Tools
|
||||
|
||||
## Family, Records, And Colony Observation
|
||||
The observe tool selects tarinai or compatible objects and opens their data. The add tool introduces new tarinai. Poke applies a small interaction or push, while pinch lets the player drag tarinai and some objects. Copy duplicates placed items with their settings so setups can be repeated quickly. The operation palette also provides a dedicated snap control shared by placement and pinch movement; it cycles through Free, 20px, 40px, and 60px.
|
||||
|
||||
- Tarinai can form parent-child records and partner links.
|
||||
- Children inherit parts of their parents' tendencies and are grouped by generation.
|
||||
- Family tree nodes show portraits, inherited stats, personality tags, and relationship structure.
|
||||
- Event logs record important simulation events such as birth, death, accidents, fights, weather, grass, and relation changes.
|
||||
- Colony charts summarize population, needs, and object counts over time.
|
||||
- Selected tarinai records help explain why an individual is acting a certain way.
|
||||
Undo and redo reverse or reapply placement and edit actions. Delete removes a specific object or stain, while area delete removes many nearby targets at once. These tools let the player repair a field after experiments without resetting the whole colony.
|
||||
|
||||
## Save, Reset, And Persistence
|
||||
## Grounds And Environment
|
||||
|
||||
- The app supports manual save and restore through browser storage.
|
||||
- Current saves are allowed to use compact/hash-style text. Old save text does not need to remain loadable when the save format is intentionally shortened or replaced, so load failure is acceptable after intentional save-format changes as long as the current save/load path stays internally consistent.
|
||||
- Reset opens field selection and starts a new field state.
|
||||
- Service worker caching allows the static app shell to remain available after load, depending on browser support.
|
||||
Fields define the playable space and boundary style. Grounds change the character of the colony: sand, dirt, concrete, blanket, foot massage, ice, and laboratory-style surfaces can affect movement, stress, comfort, and grass behavior. Ground choice matters because tarinai react not only to objects, but also to the surface they live on.
|
||||
|
||||
## v39.16.25 additions
|
||||
Weather changes the field over time. Rain can create water and affect comfort, shade can protect from weather or sun, and temperature changes how tarinai experience the environment. Grass, zunchi, splats, corpses, ant corpses, rain drops, and stains all feed into hygiene, disease, cleanup, rendering, and observation.
|
||||
|
||||
- 雨天時は未遮蔽のたりないの体感気温が3.5℃低下し、晴天時は3.5℃上昇する。
|
||||
- 雨よけをパラソルへ変更。範囲内では雨と日差しを防ぎ、天候による体感気温補正を無効化する。
|
||||
- パラソル内では日光浴を開始・継続しない。
|
||||
- ペットフード、救急薬、鎮静薬の追加内容は維持する。
|
||||
## Colony Statistics
|
||||
|
||||
The colony panel summarizes population, sick tarinai, juveniles, elders, deaths, mood, temperature, population limits, and object limits. Graph tabs show population, needs, and environment. Total ants are included in the environment statistics so active ant pressure can be read alongside other field signals.
|
||||
|
||||
## v39.16.28 additions
|
||||
The event panel records important simulation events such as birth, death, fights, accidents, weather, grass changes, and relation changes. The family tree shows parent-child structure, generations, inherited stats, personality tags, and family links.
|
||||
|
||||
- トイレの砂を従来より大きな角丸矩形へ変更し、干草寝床と同じ背面レイヤーで描画する。
|
||||
- トイレの砂の外観とずんち吸収判定を共通の矩形フットプリントから生成し、見た目と有効範囲を一致させる。
|
||||
- パラソルをやや大型化し、影の中心を支柱の根元へ合わせる。
|
||||
- パラソルの影の楕円を、そのまま雨・日差し・天候由来の体感気温補正を防ぐ範囲として使用する。
|
||||
- 晴天の暑さ、または雨天の寒さを避ける際、パラソルの影の内側を気温調整行動の候補にする。
|
||||
- 扇風機の体感気温低下が風の描画・物体押し出しより短い範囲で途切れていた問題を修正し、320pxの風領域全体で最大5℃の冷却を減衰適用する。
|
||||
## v39.16.29 additions
|
||||
## Save, Display, And Performance
|
||||
|
||||
- Parasol placement previews use the same ellipse and center as the rendered shade and weather-protection footprint.
|
||||
- Toilet sand absorbs ambient rain-water drops before they become ground water items.
|
||||
- Happy preset adds parasols and water balloons, and places one toilet-sand item at every duplicator position.
|
||||
- Athletic preset adds water balloons and replaces complex rotators with single-bar rotators positioned using their full rotation envelope so they do not strike fences.
|
||||
- War preset adds fans with active rotating head-swing settings.
|
||||
Manual save and restore use browser storage. Reset returns to field selection and starts a new state. Service worker caching can keep the static app shell available after the first successful load when the browser supports it.
|
||||
|
||||
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.
|
||||
|
||||
## v39.16.30 additions
|
||||
|
||||
- 戦争プリセットに少数のバウンス柵を追加する。
|
||||
- 通常の柵は、ボールなどに対する反発を控えめにする。
|
||||
- 巣箱の外で眠っているたりないも、ボールや水風船と物理衝突する。
|
||||
- 巣箱ツールチップを巣箱へやや近づける。
|
||||
- ボール、画鋲、ずんちなどの移動可能アイテム同士は、同じ設置位置へ重ねて配置できる。
|
||||
- 削除ツールの対象探索で発生していた例外を修正する。
|
||||
|
||||
## v39.16.31 additions
|
||||
|
||||
- 一触即発プリセット、ツールチップ距離の統一、物理カテゴリ専用アイコンを追加。
|
||||
## Achievement system (v39.16.56)
|
||||
- Fifty-one 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.
|
||||
- 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.
|
||||
- `餌やりじいさん` requires 33 direct feedings in one field; resetting the field resets this progress.
|
||||
- `機械化産業` replaces the former detector-automation and link-craftsman achievements and unlocks only when both requirements are met.
|
||||
- v39.16.56 adds 混沌ヲ希求スル者 and 潔癖症, raises 清掃業者 to 200, starts ミニマリスト from day 6, excludes chain/crown from おくすり手帳全埋め, and keeps shared-rate bars visible after unlock.
|
||||
- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく. ベビーブーム now requires 30 births in one minute; 管理された楽園 starts tracking from play day 6 and requires one uninterrupted happy in-game day.
|
||||
- Local unlocks and persistent totals live in browser storage. Field-scoped direct-feeding, cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress follow current-format saves where applicable. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了.
|
||||
- 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.
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -13,7 +13,16 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
|
|||
|
||||
## Runtime Shape
|
||||
|
||||
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.31`.
|
||||
- v39.16.37 changes: biological and environmental conditions are unified into the detector; the standalone sensor and relay are removed; temperature and limits are folded; ecology, sound, display settings, and credits are in the header menu; reset remains outside in red; tarinai-data navigation preserves the active tool; mobile tool descriptions appear at the top for five seconds. Snap mode remains Free/20px/40px/60px, top-edge placement is fixed, and preset descriptions end with punctuation.
|
||||
- v39.16.38 changes: detector numeric controls use value-labelled sliders like the time control; colony temperature and limits share one Colony settings fold; display settings use an inline menu matching sound settings; HP/stress meter colors are fixed; the snap control follows Clear with a faint grid icon; area-delete has a new icon; the Ant chart series is renamed and hidden initially.
|
||||
- v39.16.55 changes: expanded the achievement set to 49 with 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one happy in-game day from play day 6; restored shared poke targeting/highlights and plushie fling; corrected manual rotator/reciprocator push direction; tightened tool/limit UI; reduced periodic achievement scans; and introduced compact achievement aggregate storage.
|
||||
- v39.16.52 changes: reorganized achievements into collapsible categories, removed achievement tooltips, and moved confirmation flows into the game UI. Previous v39.16.51 changes: expanded the achievement set to 43 with sixteen play-style and special-action conditions; added persistent Undo/Redo and quick-delete counters, field-local cleaner/automation-free ecology progress, individual care/temperature/fight tracking, complete medicine-category collection, protected rain shelter in nest boxes and pipes, and stricter ant-kill attribution.
|
||||
- v39.16.49 changes: added additive full-state synchronization so shared achievement aggregates can be rebuilt as players return after server data loss.
|
||||
- v39.16.48 changes: corrected the zunchi-slave threshold text to 30%; repaired wire placement counting, danger-kill attribution, partial legacy link migration, UUID validation, and debug-only achievement isolation.
|
||||
- v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button.
|
||||
- v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field.
|
||||
- v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves.
|
||||
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.56`.
|
||||
- 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.
|
||||
|
|
@ -59,3 +68,23 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
|
|||
### v39.16.16
|
||||
Colony birth/death chart lines, reverse rotators, champion-trait inheritance, reduced champion-pair mating, and directional one-way-fence routing.
|
||||
|
||||
|
||||
- v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons.
|
||||
|
||||
|
||||
## Achievement system (v39.16.56)
|
||||
- Fifty-one 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.
|
||||
- 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 adds two more achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars.
|
||||
- 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.
|
||||
- On startup, shared-stat refresh, achievement-screen opening, and every five minutes, the client sends its complete non-debug unlock set with original local timestamps. Ordinary unlock bursts send only unsynchronized records after a short debounce. The server adds only missing records, so a lost `state.json` is rebuilt incrementally as players return without overwriting surviving records.
|
||||
|
||||
### Shared achievement percentage deployment
|
||||
- Upload `achievement_api.php` and the hidden `.achievement_data` directory with the game.
|
||||
- PHP must be enabled, and `.achievement_data` must be writable by the PHP process.
|
||||
- On a static-only host, local unlocks still work; the global percentage is shown as unavailable.
|
||||
- Set `window.TARINAI_ACHIEVEMENT_API` before `achievements.js` to use a different same-origin endpoint.
|
||||
- The current release tracks 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.
|
||||
285
achievement_api.php
Normal file
285
achievement_api.php
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
const ACHIEVEMENT_IDS = [
|
||||
'first_birth',
|
||||
'natural_zunchi_slave',
|
||||
'natural_tarinai_king',
|
||||
'self_zunchi_death',
|
||||
'natural_zunchi_slave_5_generations',
|
||||
'natural_tarinai_king_3_generations',
|
||||
'death_50_in_10_seconds',
|
||||
'birth_50_in_60_seconds',
|
||||
'great_mother_1000_births',
|
||||
'lifespan_completed',
|
||||
'soccer_ball_death',
|
||||
'fight_pair_danger_kill',
|
||||
'all_non_sleep_diseased_25',
|
||||
'colony_happy',
|
||||
'direct_feed_33',
|
||||
'ignite_during_birth_ritual',
|
||||
'laxative_starvation',
|
||||
'idle_observer_5_minutes',
|
||||
'placed_objects_100',
|
||||
'mechanized_industry',
|
||||
'safe_colony_25_5_minutes',
|
||||
'ants_alive_25',
|
||||
'ants_killed_100',
|
||||
'secret_collection_9_slots',
|
||||
'pause_spam_4_in_1_second',
|
||||
'continuous_play_1_hour',
|
||||
'below_absolute_zero_item',
|
||||
'undo_mass_revival',
|
||||
'robot_cleaner_100',
|
||||
'held_30_seconds',
|
||||
'minimalist_happy',
|
||||
'overprotective',
|
||||
'self_sufficient',
|
||||
'unplanned_city_30',
|
||||
'sauna_cold_plunge',
|
||||
'rain_shelter_all',
|
||||
'medicine_ledger_all',
|
||||
'mercury_lifespan',
|
||||
'enemy_enemy_friend',
|
||||
'revolution',
|
||||
'fuel_to_fire',
|
||||
'undo_20',
|
||||
'redo_20',
|
||||
'eternal_history_generation_10',
|
||||
'king_full_satisfaction',
|
||||
'slave_zero_satisfaction',
|
||||
'town_doctor_50',
|
||||
'poke_plushie_fling',
|
||||
'chaos_seeker_666_fights',
|
||||
'clean_freak_robot_only',
|
||||
'true_tarinai_observer',
|
||||
];
|
||||
const DATA_DIR_NAME = '.achievement_data';
|
||||
const DATA_FILE_NAME = 'state.json';
|
||||
|
||||
function respond(array $payload, int $status = 200): never {
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function valid_player_id(string $value): bool {
|
||||
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value) === 1;
|
||||
}
|
||||
|
||||
function empty_state(): array {
|
||||
return ['players' => [], 'unlocks' => []];
|
||||
}
|
||||
|
||||
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'])) {
|
||||
$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];
|
||||
$playerIds[] = $id;
|
||||
$players[$id] = [
|
||||
'firstSeen' => max(1, (int)($row[1] ?? 1)),
|
||||
'lastSeen' => max(1, (int)($row[2] ?? ($row[1] ?? 1))),
|
||||
'gameVersion' => substr((string)($row[3] ?? ''), 0, 32),
|
||||
];
|
||||
}
|
||||
foreach ($value['u'] as $achievementId => $flat) {
|
||||
if (!is_string($achievementId) || !is_array($flat)) continue;
|
||||
$records = [];
|
||||
$count = count($flat);
|
||||
for ($i = 0; $i + 1 < $count; $i += 2) {
|
||||
$playerIndex = (int)$flat[$i];
|
||||
if (!isset($playerIds[$playerIndex])) continue;
|
||||
$records[$playerIds[$playerIndex]] = max(1, (int)$flat[$i + 1]);
|
||||
}
|
||||
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'] : [];
|
||||
}
|
||||
|
||||
$legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first'])
|
||||
? $unlocks['signal_automation_first'] : [];
|
||||
$legacyLinks = isset($unlocks['link_craftsman']) && is_array($unlocks['link_craftsman'])
|
||||
? $unlocks['link_craftsman'] : [];
|
||||
if ($legacySignal && $legacyLinks) {
|
||||
if (!isset($unlocks['mechanized_industry']) || !is_array($unlocks['mechanized_industry'])) {
|
||||
$unlocks['mechanized_industry'] = [];
|
||||
}
|
||||
foreach ($legacySignal as $playerId => $signalTime) {
|
||||
if (!isset($legacyLinks[$playerId])) continue;
|
||||
$unlocks['mechanized_industry'][$playerId] = max((int)$signalTime, (int)$legacyLinks[$playerId]);
|
||||
}
|
||||
}
|
||||
unset($unlocks['signal_automation_first'], $unlocks['link_craftsman']);
|
||||
|
||||
return ['players' => $players, 'unlocks' => $unlocks];
|
||||
}
|
||||
|
||||
function compact_state(array $state): array {
|
||||
$playerIds = array_keys($state['players']);
|
||||
sort($playerIds, SORT_STRING);
|
||||
$playerIndex = [];
|
||||
$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),
|
||||
];
|
||||
}
|
||||
$unlocks = [];
|
||||
foreach ($state['unlocks'] as $achievementId => $records) {
|
||||
if (!is_string($achievementId) || !is_array($records) || !$records) continue;
|
||||
$flat = [];
|
||||
foreach ($records as $id => $timestamp) {
|
||||
if (!isset($playerIndex[$id])) continue;
|
||||
$flat[] = $playerIndex[$id];
|
||||
$flat[] = max(1, (int)$timestamp);
|
||||
}
|
||||
if ($flat) $unlocks[$achievementId] = $flat;
|
||||
}
|
||||
ksort($unlocks, SORT_STRING);
|
||||
return ['v' => 2, 'p' => $players, 'u' => $unlocks];
|
||||
}
|
||||
|
||||
function build_summary(array $state): array {
|
||||
$total = count($state['players']);
|
||||
$achievements = [];
|
||||
foreach (ACHIEVEMENT_IDS as $id) {
|
||||
$unlocked = isset($state['unlocks'][$id]) && is_array($state['unlocks'][$id])
|
||||
? count($state['unlocks'][$id])
|
||||
: 0;
|
||||
$percentage = $total > 0 ? round(($unlocked / $total) * 100, 1) : 0.0;
|
||||
$achievements[$id] = [
|
||||
'unlockedPlayers' => $unlocked,
|
||||
'percentage' => $percentage,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
'totalPlayers' => $total,
|
||||
'achievements' => $achievements,
|
||||
];
|
||||
}
|
||||
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$payload = [];
|
||||
if ($method === 'POST') {
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode(is_string($raw) ? $raw : '', true);
|
||||
if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
$payload = $decoded;
|
||||
}
|
||||
|
||||
$action = $method === 'POST'
|
||||
? (string)($payload['action'] ?? '')
|
||||
: (string)($_GET['action'] ?? 'summary');
|
||||
$playerId = $method === 'POST'
|
||||
? (string)($payload['playerId'] ?? '')
|
||||
: (string)($_GET['playerId'] ?? '');
|
||||
|
||||
if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_action'], 400);
|
||||
}
|
||||
if (!valid_player_id($playerId)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_player'], 400);
|
||||
}
|
||||
$syncUnlocks = [];
|
||||
if ($action === 'unlock') {
|
||||
$achievementId = (string)($payload['achievementId'] ?? '');
|
||||
if (!in_array($achievementId, ACHIEVEMENT_IDS, true)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
|
||||
}
|
||||
} elseif ($action === 'sync') {
|
||||
$submittedUnlocks = $payload['unlocked'] ?? null;
|
||||
if (!is_array($submittedUnlocks) || count($submittedUnlocks) > count(ACHIEVEMENT_IDS)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_unlocks'], 400);
|
||||
}
|
||||
foreach ($submittedUnlocks as $achievementId => $timestamp) {
|
||||
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
|
||||
}
|
||||
if (!is_int($timestamp) && !is_float($timestamp) && !is_string($timestamp)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
||||
}
|
||||
$numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT);
|
||||
if ($numericTimestamp === false || $numericTimestamp <= 0) {
|
||||
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
||||
}
|
||||
$syncUnlocks[$achievementId] = (float)$numericTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . DATA_DIR_NAME;
|
||||
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);
|
||||
respond(['ok' => false, 'error' => 'storage_locked'], 503);
|
||||
}
|
||||
|
||||
try {
|
||||
rewind($handle);
|
||||
$contents = stream_get_contents($handle);
|
||||
$state = normalize_state(json_decode(is_string($contents) ? $contents : '', true));
|
||||
$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;
|
||||
|
||||
if ($action === 'unlock') {
|
||||
$achievementId = (string)$payload['achievementId'];
|
||||
if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) {
|
||||
$state['unlocks'][$achievementId] = [];
|
||||
}
|
||||
if (!isset($state['unlocks'][$achievementId][$playerId])) {
|
||||
$state['unlocks'][$achievementId][$playerId] = $now;
|
||||
}
|
||||
} elseif ($action === 'sync') {
|
||||
foreach ($syncUnlocks as $achievementId => $submittedTimestamp) {
|
||||
if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) {
|
||||
$state['unlocks'][$achievementId] = [];
|
||||
}
|
||||
if (isset($state['unlocks'][$achievementId][$playerId])) continue;
|
||||
$timestampSeconds = $submittedTimestamp >= 100000000000
|
||||
? (int)floor($submittedTimestamp / 1000)
|
||||
: (int)floor($submittedTimestamp);
|
||||
$state['unlocks'][$achievementId][$playerId] = max(1, min($now, $timestampSeconds));
|
||||
}
|
||||
} elseif ($action === 'reset') {
|
||||
foreach ($state['unlocks'] as $achievementId => $records) {
|
||||
if (is_array($records)) unset($state['unlocks'][$achievementId][$playerId]);
|
||||
}
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
ftruncate($handle, 0);
|
||||
fwrite($handle, json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||
fflush($handle);
|
||||
$summary = build_summary($state);
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
respond($summary);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "39.16.31",
|
||||
"version": "39.16.56",
|
||||
"css": [
|
||||
"css/base.css",
|
||||
"css/layout.css",
|
||||
|
|
@ -145,6 +145,8 @@
|
|||
"js/command_dispatcher.js",
|
||||
"js/text_catalog.js",
|
||||
"js/ui.js",
|
||||
"js/game_dialogs.js",
|
||||
"js/achievements.js",
|
||||
"js/ui_log.js",
|
||||
"js/ui_helpers.js",
|
||||
"js/ui_selected.js",
|
||||
|
|
|
|||
78
css/base.css
78
css/base.css
|
|
@ -325,13 +325,13 @@ h1 {
|
|||
--tool-watermark-size: 0px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 29px minmax(0,1fr);
|
||||
grid-template-columns: 31px minmax(0,1fr);
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
gap: 3px;
|
||||
padding: 4px 4px 4px 2px;
|
||||
gap: 2px;
|
||||
padding: 3px 3px 3px 2px;
|
||||
min-height: 47px;
|
||||
font-size: 10.2px;
|
||||
font-size: 11.4px;
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.015em;
|
||||
text-align: left;
|
||||
|
|
@ -374,16 +374,16 @@ h1 {
|
|||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 10.2px;
|
||||
line-height: 1.10;
|
||||
font-size: 11.4px;
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
max-width: 5.05em;
|
||||
max-width: 5.35em;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.62);
|
||||
}
|
||||
|
||||
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-columns: 29px minmax(0,1fr); min-height: 47px; padding-top: 4px; }
|
||||
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-columns: 31px minmax(0,1fr); min-height: 47px; padding-top: 4px; }
|
||||
|
||||
.tool.tool-has-canvas-icon::before,
|
||||
.tool.tool-has-canvas-icon::after { background: none !important; }
|
||||
|
|
@ -621,6 +621,68 @@ h1 {
|
|||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
|
||||
.pressure-numeric-control[hidden] { display: none; }
|
||||
.pressure-numeric-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 10px 8px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(84,68,48,0.13);
|
||||
background: rgba(255,255,255,0.58);
|
||||
}
|
||||
.pressure-value-slider {
|
||||
width: calc(100% - 14px);
|
||||
height: 34px;
|
||||
margin: 0 7px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
.pressure-value-slider::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(67,158,218,0.78);
|
||||
box-shadow: inset 0 1px 2px rgba(43,55,66,0.16);
|
||||
}
|
||||
.pressure-value-slider::-webkit-slider-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -7px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(42,121,172,0.88);
|
||||
background: #fafdff;
|
||||
box-shadow: 0 2px 6px rgba(42,78,104,0.24);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.pressure-value-slider::-moz-range-track {
|
||||
height: 6px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(67,158,218,0.78);
|
||||
box-shadow: inset 0 1px 2px rgba(43,55,66,0.16);
|
||||
}
|
||||
.pressure-value-slider::-moz-range-thumb {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(42,121,172,0.88);
|
||||
background: #fafdff;
|
||||
box-shadow: 0 2px 6px rgba(42,78,104,0.24);
|
||||
cursor: grab;
|
||||
}
|
||||
.pressure-value-slider:focus-visible::-webkit-slider-thumb {
|
||||
outline: 3px solid rgba(73,157,216,0.30);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pressure-value-slider:focus-visible::-moz-range-thumb {
|
||||
outline: 3px solid rgba(73,157,216,0.30);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pressure-time-control[hidden] { display: none; }
|
||||
.pressure-spatial-control[hidden] { display: none; }
|
||||
.pressure-time-control {
|
||||
|
|
|
|||
|
|
@ -643,129 +643,145 @@
|
|||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.visual-settings-dialog-panel {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
/* v39.16.40: compact header menu and folded colony settings. */
|
||||
.top-menu { position: relative; display: inline-block; }
|
||||
.top-menu-btn {
|
||||
min-width: 40px;
|
||||
padding-inline: 11px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.visual-setting-row {
|
||||
.top-menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 14000;
|
||||
width: min(250px, calc(100vw - 20px));
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(84,68,48,.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,252,246,.98);
|
||||
box-shadow: 0 16px 42px rgba(44,34,25,.20);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid rgba(115, 91, 62, 0.16);
|
||||
}
|
||||
.visual-setting-row strong,
|
||||
.visual-setting-row span {
|
||||
display: block;
|
||||
}
|
||||
.visual-setting-row strong {
|
||||
color: #4f3928;
|
||||
font-size: 15px;
|
||||
}
|
||||
.visual-setting-row span {
|
||||
margin-top: 3px;
|
||||
color: #6d5a46;
|
||||
font-size: 12px;
|
||||
}
|
||||
.visual-setting-options {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(115, 91, 62, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.50);
|
||||
}
|
||||
.visual-setting-options button {
|
||||
min-width: 46px;
|
||||
min-height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
.top-menu-panel.hidden { display: none; }
|
||||
.top-menu-item {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
color: #5d4938;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.visual-setting-options button.active,
|
||||
.visual-setting-options button[aria-pressed="true"] {
|
||||
border-color: rgba(127, 94, 54, 0.28);
|
||||
background: #fff4d8;
|
||||
color: #3f3023;
|
||||
box-shadow: 0 2px 8px rgba(96, 70, 40, 0.12);
|
||||
}
|
||||
.visual-setting-options button:focus-visible {
|
||||
outline: 2px solid rgba(184, 128, 54, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.save-slot-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.save-slot-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.visual-setting-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.visual-setting-options {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.visual-setting-options button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.top-menu-item:hover,
|
||||
.top-menu-item:focus-visible { background: rgba(238,245,225,.88); outline: none; }
|
||||
.top-menu-sound { display: block; width: 100%; }
|
||||
.top-menu-sound .sound-menu-btn { display: block; border: 0; box-shadow: none; }
|
||||
.top-menu-sound .sound-panel {
|
||||
position: static;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 3px 3px 5px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
background: rgba(245,240,229,.78);
|
||||
}
|
||||
|
||||
.ground-stat {
|
||||
padding: 7px 8px;
|
||||
.top-menu-visual { display: block; width: 100%; }
|
||||
.sound-panel-subtitle {
|
||||
padding: 2px 0 1px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
.ground-type-btn {
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
border: 1px solid rgba(92, 142, 74, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.58);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
|
||||
}
|
||||
.ground-type-btn:hover {
|
||||
background: rgba(255,255,255,0.82);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.ground-type-btn strong {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.ground-stat[data-ground-toggle] {
|
||||
.top-menu-visual .sound-panel label { min-height: 26px; }
|
||||
|
||||
.reset-top-btn {
|
||||
color: #8e2424 !important;
|
||||
border-color: rgba(182,69,69,.48) !important;
|
||||
background: linear-gradient(180deg, rgba(255,245,245,.96), rgba(255,224,224,.92)) !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
.reset-top-btn:hover { background: linear-gradient(180deg, #fff4f4, #ffd6d6) !important; }
|
||||
|
||||
|
||||
.colony-settings-body { display: grid; gap: 0; }
|
||||
.colony-setting-section { padding: 10px; }
|
||||
.colony-setting-section + .colony-setting-section { border-top: 1px solid rgba(118,92,61,.12); }
|
||||
.colony-setting-section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 7px; color: var(--ink); font-size: 12px; font-weight: 800; }
|
||||
.colony-setting-section-head strong { font-size: 15px; }
|
||||
.colony-setting-section-head small { margin-left: auto; color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
|
||||
.colony-setting-fold {
|
||||
margin: 8px 0 4px;
|
||||
border: 1px solid rgba(118,92,61,.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,251,244,.56);
|
||||
overflow: hidden;
|
||||
}
|
||||
.colony-setting-fold > summary {
|
||||
min-height: 38px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 11px;
|
||||
color: rgba(68,55,41,.84);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.ground-stat[data-ground-toggle]:focus-visible,
|
||||
.ground-type-btn:focus-visible {
|
||||
outline: 2px solid rgba(103,178,44,0.58);
|
||||
outline-offset: 2px;
|
||||
.colony-setting-fold > summary::-webkit-details-marker { display: none; }
|
||||
.colony-setting-fold > summary::before { content: "\25b8"; color: var(--muted); width: 12px; }
|
||||
.colony-setting-fold[open] > summary::before { content: "\25be"; }
|
||||
.colony-setting-fold > summary strong { margin-left: auto; font-size: 15px; }
|
||||
.colony-setting-fold > summary small { margin-left: auto; color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
.colony-setting-fold[open] > summary { border-bottom: 1px solid rgba(118,92,61,.12); }
|
||||
.colony-setting-fold .temperature-control,
|
||||
.colony-setting-fold .colony-limit-control {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
/* v36: pin only the operation tool category under the quick-scroll tabs. */
|
||||
.colony-setting-fold .temperature-control { display: grid; gap: 5px; }
|
||||
.colony-setting-fold .temperature-auto { margin-left: 0; justify-self: end; }
|
||||
|
||||
/* v39.16.40: one unified colony settings fold. */
|
||||
.colony-settings-fold .temperature-control,
|
||||
.colony-settings-fold .colony-limit-control { padding: 0; border: 0; border-radius: 0; background: transparent; }
|
||||
|
||||
|
||||
/* v39.16.40: restore the compact sticky operation palette from the earlier UI. */
|
||||
.tool-category[data-tool-category="operate"]:not(.collapsed) {
|
||||
position: sticky;
|
||||
top: 58px;
|
||||
z-index: 11;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(221, 237, 255, 0.98));
|
||||
border: 3px solid rgba(52,112,188,0.72);
|
||||
box-shadow: 0 9px 22px rgba(58, 82, 112, 0.18), inset 0 0 0 1px rgba(255,255,255,0.86);
|
||||
background: linear-gradient(180deg, rgba(238,247,255,.98), rgba(221,237,255,.98));
|
||||
border: 3px solid rgba(52,112,188,.72);
|
||||
box-shadow: 0 9px 22px rgba(58,82,112,.18), inset 0 0 0 1px rgba(255,255,255,.86);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-toggle {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98));
|
||||
border-bottom-color: rgba(82,128,190,0.30);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(231,243,255,.98));
|
||||
border-bottom-color: rgba(82,128,190,.30);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-body {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0,1fr));
|
||||
gap: 5px;
|
||||
background: rgba(238, 247, 255, 0.98);
|
||||
background: rgba(238,247,255,.98);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -774,112 +790,442 @@
|
|||
justify-items: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.96);
|
||||
border-color: rgba(82,128,190,0.28);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-label {
|
||||
display: none;
|
||||
background: rgba(255,255,255,.96);
|
||||
border-color: rgba(82,128,190,.28);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-label { display: none; }
|
||||
.tool-category[data-tool-category="operate"] .tool::before {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) scale(var(--tool-icon-zoom, 1));
|
||||
left: 50%; top: 50%; transform: translate(-50%,-50%) scale(var(--tool-icon-zoom,1));
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-sprite-preview {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
left: 50%; top: 50%; transform: translate(-50%,-50%);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-shortcut {
|
||||
position: absolute; right: 3px; bottom: 2px; min-width: 13px; height: 13px;
|
||||
box-sizing: border-box; padding: 0 3px; border: 1px solid rgba(82,128,190,.26);
|
||||
border-radius: 5px; background: rgba(255,255,255,.88); color: rgba(48,74,112,.86);
|
||||
font: 700 9px/12px system-ui,sans-serif; text-align: center; pointer-events: none;
|
||||
}
|
||||
|
||||
/* A trash can inside a selection square for range delete. */
|
||||
.tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 2.5px solid #a8444d;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,.40);
|
||||
filter: none;
|
||||
}
|
||||
.tool[data-tool="area_delete"].tool-emoji-icon::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 2px;
|
||||
min-width: 13px;
|
||||
left: 50%;
|
||||
top: 52%;
|
||||
width: 11px;
|
||||
height: 13px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 3px;
|
||||
border: 1px solid rgba(82,128,190,0.26);
|
||||
border-radius: 5px;
|
||||
background: rgba(255,255,255,0.88);
|
||||
color: rgba(48,74,112,0.86);
|
||||
font: 700 9px/12px system-ui, sans-serif;
|
||||
text-align: center;
|
||||
transform: translate(-50%,-50%);
|
||||
border: 2px solid #a8444d;
|
||||
border-top-width: 3px;
|
||||
border-radius: 1px 1px 3px 3px;
|
||||
background: repeating-linear-gradient(90deg, transparent 0 2px, rgba(168,68,77,.75) 2px 3px, transparent 3px 5px);
|
||||
box-shadow: 0 -5px 0 -3px #a8444d;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Collapsed event card must shrink to its header.
|
||||
layout.css gives .log-card a min-height, which left a large blank panel when folded. */
|
||||
.log-card.collapsed { min-height: 0 !important; height: auto !important; flex: 0 0 auto !important; flex-basis: auto !important; }
|
||||
.log-card.collapsed .panel-card-body { display: none !important; }
|
||||
|
||||
|
||||
.temperature-control {
|
||||
margin: 10px 0 2px;
|
||||
padding: 10px 11px 11px;
|
||||
border: 1px solid rgba(118, 92, 61, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 244, 0.56);
|
||||
.tool-category[data-tool-category="operate"] .tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
top: 50% !important;
|
||||
transform: translate(-50%,-50%) !important;
|
||||
}
|
||||
.temperature-control-head {
|
||||
|
||||
/* Colony settings use the colony card's own button palette. */
|
||||
.colony-card .colony-setting-fold {
|
||||
border-color: var(--ui-theme-mid);
|
||||
background: var(--ui-theme-soft);
|
||||
}
|
||||
.colony-card .colony-setting-fold > summary {
|
||||
border-color: var(--ui-theme-mid);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.78), var(--ui-theme-soft-2));
|
||||
color: var(--ink);
|
||||
}
|
||||
.colony-card .colony-setting-fold[open] > summary { border-bottom-color: var(--ui-theme-mid); }
|
||||
.colony-card .colony-setting-section + .colony-setting-section { border-top-color: rgba(var(--ui-theme-rgb),.22); }
|
||||
.colony-stats { grid-template-columns: 1fr; }
|
||||
.ground-type-btn.colony-setting-action {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(68, 55, 41, 0.84);
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--ui-theme-mid);
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.76), var(--ui-theme-soft-2));
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.temperature-control-head strong {
|
||||
margin-left: auto;
|
||||
font-size: 15px;
|
||||
.ground-type-btn.colony-setting-action:hover { background: rgba(var(--ui-theme-rgb),.16); }
|
||||
.ground-type-btn.colony-setting-action strong { font-size: 15px; }
|
||||
.ground-type-btn.colony-setting-action span { color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
|
||||
/* Shared panel-search primitives. */
|
||||
.panel-search-control {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0,1fr) auto 32px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 2px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(82,128,190,.20);
|
||||
}
|
||||
.temperature-auto {
|
||||
display: inline-flex;
|
||||
.panel-search-input {
|
||||
width: 100%; min-width: 0; height: 32px; box-sizing: border-box;
|
||||
padding: 6px 8px; border: 1px solid rgba(82,128,190,.34); border-radius: 9px;
|
||||
background: rgba(255,255,255,.94); color: var(--ink); font: 12px/1.2 var(--font,sans-serif);
|
||||
}
|
||||
.panel-search-input:focus { outline: 2px solid rgba(40,112,180,.28); border-color: rgba(40,112,180,.58); }
|
||||
.panel-search-status { min-width: 23px; color: rgba(48,74,112,.86); font: 700 10px/1 system-ui,sans-serif; text-align: center; }
|
||||
.panel-search-next {
|
||||
width: 32px; height: 32px; padding: 0; border: 1px solid rgba(82,128,190,.34); border-radius: 9px;
|
||||
background: rgba(255,255,255,.94); color: #315f91; font: 800 17px/1 system-ui,sans-serif; cursor: pointer;
|
||||
}
|
||||
.panel-search-next:disabled { opacity: .36; cursor: default; }
|
||||
.panel-search-match {
|
||||
position: relative;
|
||||
outline: 3px solid rgba(255,190,38,.72) !important;
|
||||
outline-offset: 1px;
|
||||
background-color: rgba(255,235,135,.62) !important;
|
||||
scroll-margin-block: 150px 80px;
|
||||
}
|
||||
.panel-search-current {
|
||||
outline-color: rgba(225,91,38,.92) !important;
|
||||
box-shadow: 0 0 0 4px rgba(255,160,70,.22) !important;
|
||||
}
|
||||
|
||||
/* v39.16.40: header search uses partial matching and bidirectional navigation. */
|
||||
.top-panel-search {
|
||||
flex: 0 1 270px;
|
||||
width: clamp(190px, 21vw, 270px);
|
||||
min-width: 190px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 1fr) auto 30px 30px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.top-panel-search .panel-search-input { height: 36px; }
|
||||
.panel-search-prev,
|
||||
.panel-search-next {
|
||||
width: 30px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(82,128,190,.34);
|
||||
border-radius: 9px;
|
||||
background: rgba(255,255,255,.94);
|
||||
color: #315f91;
|
||||
font: 800 16px/1 system-ui,sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-search-prev:disabled,
|
||||
.panel-search-next:disabled { opacity: .36; cursor: default; }
|
||||
|
||||
/* Match the expanded colony settings surface to the colony graph surface. */
|
||||
.colony-card .colony-setting-fold[open] {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54));
|
||||
}
|
||||
|
||||
|
||||
/* Shared in-game confirmations */
|
||||
.game-confirm-dialog { z-index: 16000; }
|
||||
.game-confirm-panel { width: min(460px, 100%); }
|
||||
.game-confirm-message { white-space: pre-line; }
|
||||
.game-confirm-actions { justify-content: flex-end; }
|
||||
.game-confirm-actions .btn { width: auto; }
|
||||
|
||||
/* Achievements */
|
||||
.achievement-top-btn {
|
||||
border-color: rgba(177, 132, 38, .38);
|
||||
color: #72521a;
|
||||
background: linear-gradient(180deg, rgba(255, 251, 224, .96), rgba(247, 229, 153, .88));
|
||||
font-weight: 800;
|
||||
}
|
||||
.achievement-top-btn.complete {
|
||||
border-color: rgba(105, 158, 63, .44);
|
||||
color: #456729;
|
||||
background: linear-gradient(180deg, rgba(245, 255, 229, .98), rgba(213, 239, 173, .92));
|
||||
}
|
||||
.achievement-dialog-panel { width: min(720px, 100%); }
|
||||
.achievement-dialog-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
.achievement-dialog-head h2 { margin-bottom: 5px; }
|
||||
.achievement-dialog-kicker {
|
||||
margin-bottom: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.achievement-close-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(84, 68, 48, .16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .76);
|
||||
color: var(--ink);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.achievement-shared-status {
|
||||
margin: 10px 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(104, 86, 61, .12);
|
||||
border-radius: 10px;
|
||||
background: rgba(241, 235, 222, .54);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.achievement-list { display: grid; gap: 10px; }
|
||||
.achievement-group {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(105, 87, 60, .15);
|
||||
border-radius: 13px;
|
||||
background: rgba(247, 243, 234, .72);
|
||||
}
|
||||
.achievement-group-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
color: #554b3e;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.temperature-slider {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
.achievement-group-summary::-webkit-details-marker { display: none; }
|
||||
.achievement-group-summary::before {
|
||||
content: "\25b6";
|
||||
flex: 0 0 auto;
|
||||
color: #8b7657;
|
||||
font-size: 10px;
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
.temperature-slider:disabled {
|
||||
opacity: 0.52;
|
||||
.achievement-group[open] > .achievement-group-summary::before { transform: rotate(90deg); }
|
||||
.achievement-group-summary strong { margin-right: auto; font-size: 13px; }
|
||||
.achievement-group-count {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.colony-limit-control {
|
||||
margin: 8px 0 4px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid rgba(118, 92, 61, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 244, 0.56);
|
||||
.achievement-group-body {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 0 9px 9px;
|
||||
}
|
||||
.colony-limit-head {
|
||||
.achievement-group-empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, .58);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.achievement-entry {
|
||||
--achievement-rate: 0%;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 62px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(105, 87, 60, .15);
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .88), rgba(244, 238, 226, .78));
|
||||
cursor: default;
|
||||
}
|
||||
.achievement-entry:focus-visible {
|
||||
outline: 2px solid rgba(85, 119, 149, .58);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.achievement-entry::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0 auto 0 0;
|
||||
pointer-events: none;
|
||||
width: var(--achievement-rate);
|
||||
background: linear-gradient(90deg, rgba(129, 158, 188, .27), rgba(156, 184, 207, .17));
|
||||
border-right: 1px solid rgba(85, 119, 149, .20);
|
||||
transition: width .32s ease;
|
||||
}
|
||||
.achievement-entry > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.achievement-entry.unlocked {
|
||||
border-color: rgba(173, 129, 35, .38);
|
||||
background: linear-gradient(180deg, rgba(255, 253, 235, .96), rgba(248, 231, 163, .70));
|
||||
}
|
||||
.achievement-entry.unlocked::before {
|
||||
background: linear-gradient(90deg, rgba(229, 179, 51, .34), rgba(244, 211, 102, .22));
|
||||
border-right-color: rgba(173, 129, 35, .28);
|
||||
}
|
||||
.achievement-entry-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: rgba(106, 91, 68, .09);
|
||||
color: #8b7657;
|
||||
font-size: 22px;
|
||||
}
|
||||
.achievement-entry.unlocked .achievement-entry-icon {
|
||||
background: rgba(242, 196, 74, .22);
|
||||
color: #b67b13;
|
||||
}
|
||||
.achievement-entry-copy { min-width: 0; }
|
||||
.achievement-entry-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.achievement-entry-copy strong {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.achievement-entry-description {
|
||||
min-width: 0;
|
||||
color: #665d50;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.achievement-entry.locked .achievement-entry-description {
|
||||
color: #8b8378;
|
||||
font-style: italic;
|
||||
}
|
||||
.achievement-entry-progress {
|
||||
margin-top: 4px;
|
||||
color: #526b82;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.achievement-entry-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: rgba(68, 55, 41, 0.84);
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.colony-limit-head small { color: var(--muted); font-size: 10px; }
|
||||
.colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 8px; }
|
||||
.colony-limit-row { display: grid; grid-template-columns: minmax(0, 1fr) 54px; gap: 5px 7px; align-items: center; min-width: 0; font-size: 11px; color: var(--muted); }
|
||||
.colony-limit-row span { grid-column: 1 / -1; min-width: 0; }
|
||||
.colony-limit-slider { width: 100%; min-width: 0; accent-color: rgba(84, 138, 72, 0.92); }
|
||||
.colony-limit-number { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid rgba(100, 78, 52, 0.22); border-radius: 8px; padding: 5px 6px; background: rgba(255,255,255,0.78); color: var(--ink); font: inherit; text-align: right; }
|
||||
.colony-limit-status { margin-top: 7px; color: rgba(68, 55, 41, 0.78); font-size: 10px; line-height: 1.35; }
|
||||
.achievement-entry-percentage {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: #5d6570;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.achievement-entry-heading { display: block; }
|
||||
.achievement-entry-description { display: block; margin-top: 2px; }
|
||||
}
|
||||
.achievement-dialog-actions { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.achievement-dialog-actions .btn { width: auto; }
|
||||
.achievement-dialog-actions .achievement-reset-btn { margin-right: auto; }
|
||||
.achievement-toast {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
right: auto;
|
||||
z-index: 14000;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid rgba(184, 137, 37, .42);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 249, 220, .97);
|
||||
color: #57401d;
|
||||
box-shadow: 0 16px 44px rgba(45, 34, 20, .24);
|
||||
transform-origin: center;
|
||||
transition: opacity .18s ease, transform .18s ease, filter .18s ease, visibility .18s ease;
|
||||
}
|
||||
.achievement-toast.absorbing {
|
||||
opacity: 0;
|
||||
transform: translate(var(--achievement-toast-dx, 0px), var(--achievement-toast-dy, 0px)) scale(.08);
|
||||
filter: blur(2px) saturate(1.4);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
transition: opacity .72s ease-in, transform .72s cubic-bezier(.55, .02, .86, .3), filter .72s ease-in, border-radius .72s ease-in;
|
||||
}
|
||||
.achievement-top-btn.achievement-absorb-target {
|
||||
animation: achievement-target-pulse .22s ease-out .54s both;
|
||||
}
|
||||
@keyframes achievement-target-pulse {
|
||||
0% { transform: scale(1); }
|
||||
45% { transform: scale(1.09); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.achievement-toast.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.achievement-toast-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: #efc34c;
|
||||
color: #fff8d8;
|
||||
font-size: 24px;
|
||||
}
|
||||
.achievement-toast-copy { min-width: 0; }
|
||||
.achievement-toast-copy small { display: block; margin-bottom: 2px; color: #8a6b2c; font-size: 10px; font-weight: 800; }
|
||||
.achievement-toast-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.achievement-toast {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 10px);
|
||||
left: 12px;
|
||||
right: auto;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
}
|
||||
.achievement-dialog-actions { flex-direction: column-reverse; }
|
||||
.achievement-dialog-actions .btn { width: 100%; }
|
||||
}
|
||||
|
||||
/* v39.15.131: keep the folded tool card usable when other right-side cards are expanded. */
|
||||
.tool-card.collapsed {
|
||||
min-height: 64px !important;
|
||||
height: auto !important;
|
||||
flex: 0 0 auto !important;
|
||||
flex-basis: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.tool-card.collapsed .panel-card-toggle {
|
||||
min-height: 46px;
|
||||
}
|
||||
.tool-card.collapsed .panel-card-body {
|
||||
display: none !important;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.achievement-toast.absorbing { transition-duration: .12s; }
|
||||
.achievement-top-btn.achievement-absorb-target { animation: none; }
|
||||
}
|
||||
|
||||
/* v39.16.56: keep the upper-limit heading on one line in the compact panel. */
|
||||
.colony-limit-section .colony-setting-section-head > span { font-size: 10.8px; white-space: nowrap; }
|
||||
.colony-limit-section .colony-setting-section-head > small { font-size: 9px; white-space: nowrap; }
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@
|
|||
.tool[data-tool="undo"].tool-emoji-icon::before,
|
||||
.tool[data-tool="redo"].tool-emoji-icon::before { font-size: 29px; font-family: var(--font, sans-serif); }
|
||||
.tool[data-tool="delete"] { --tool-icon-zoom: 1.42; }
|
||||
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.20; }
|
||||
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1; }
|
||||
.tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; }
|
||||
.tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
font-size: 31px;
|
||||
line-height: 1;
|
||||
color: #5d7fb9;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.65), 0 2px 5px rgba(146,54,62,0.16);
|
||||
color: #b14f58;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.75), 0 2px 5px rgba(146,54,62,0.14);
|
||||
filter: none;
|
||||
transform: translateY(-54%) scale(var(--tool-icon-zoom, 1));
|
||||
}
|
||||
|
|
@ -114,16 +114,17 @@
|
|||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 3px;
|
||||
min-width: 16px;
|
||||
min-width: 22px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(42,36,29,0.72);
|
||||
color: #fff;
|
||||
font-size: 8.5px;
|
||||
font-size: 9.5px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px rgba(42,36,29,0.16);
|
||||
pointer-events: none;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chart-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: 3px 0 8px; }
|
||||
.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; }
|
||||
|
|
@ -307,3 +308,42 @@ meter {
|
|||
@media (min-width: 921px) {
|
||||
.mobile-mode-controls { display: none !important; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tool-pick-mode-control.tool-emoji-icon::before {
|
||||
opacity: 0.18;
|
||||
color: #3b78a0;
|
||||
font-size: 29px;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.tool-pick-mode-control {
|
||||
--tool-icon-zoom: 1;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(235,246,255,0.88));
|
||||
border-color: rgba(48,126,176,0.34);
|
||||
}
|
||||
.tool-pick-mode-control .tool-pick-mode-value {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: 5px;
|
||||
transform: translateX(-50%);
|
||||
min-width: 38px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 999px;
|
||||
z-index: 4;
|
||||
background: rgba(25,85,128,0.94);
|
||||
color: #fff;
|
||||
font-size: 8.5px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px rgba(25,85,128,0.18);
|
||||
}
|
||||
.tool-pick-mode-control[data-pick-mode="grid20"],
|
||||
.tool-pick-mode-control[data-pick-mode="grid40"],
|
||||
.tool-pick-mode-control[data-pick-mode="grid60"] {
|
||||
background: linear-gradient(180deg, #ffffff, #eaf7ff);
|
||||
border-color: rgba(38,132,190,0.56);
|
||||
box-shadow: inset 0 0 0 1px rgba(84,176,226,0.20), 0 8px 20px rgba(59,133,177,0.10);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,3 +397,35 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
|
|||
right: max(10px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* v39.16.40: selected tool descriptions appear at the top on touch devices. */
|
||||
body.touch-mobile-ui .tool-tip-popover.mobile-selection-tip {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 8px) !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%);
|
||||
width: min(680px, calc(100vw - 18px));
|
||||
max-width: calc(100vw - 18px);
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(46,38,30,.90);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.top-actions { overflow: visible; }
|
||||
.top-menu { flex: 0 0 auto; }
|
||||
.top-menu-panel { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 58px); right: max(10px, env(safe-area-inset-right, 0px)); }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.top-panel-search {
|
||||
flex: 0 0 220px;
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.top-panel-search .panel-search-input { height: 34px; }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.top-actions { overflow-x: auto; overflow-y: hidden; }
|
||||
}
|
||||
|
|
|
|||
512
index.html
512
index.html
|
|
@ -4,14 +4,14 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>たりない観察</title>
|
||||
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.31" />
|
||||
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.31" />
|
||||
<link rel="shortcut icon" href="favicon.ico?v=39.16.31" />
|
||||
<link rel="stylesheet" href="css/base.css?v=39.16.31" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=39.16.31" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=39.16.31" />
|
||||
<link rel="stylesheet" href="css/components.css?v=39.16.31" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=39.16.31" />
|
||||
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.56" />
|
||||
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.56" />
|
||||
<link rel="shortcut icon" href="favicon.ico?v=39.16.56" />
|
||||
<link rel="stylesheet" href="css/base.css?v=39.16.56" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=39.16.56" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=39.16.56" />
|
||||
<link rel="stylesheet" href="css/components.css?v=39.16.56" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=39.16.56" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
||||
|
|
@ -33,23 +33,46 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<div id="panelSearchControl" class="panel-search-control top-panel-search" role="search" aria-label="右メニュー検索">
|
||||
<input id="panelSearchInput" class="panel-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="検索" aria-label="右メニューを検索">
|
||||
<span class="panel-search-status" aria-live="polite">0</span>
|
||||
<button class="panel-search-prev" type="button" disabled aria-label="前の該当位置へ移動">↑</button>
|
||||
<button class="panel-search-next" type="button" disabled aria-label="次の該当位置へ移動">↓</button>
|
||||
</div>
|
||||
<button id="pauseBtn" class="btn primary">一時停止</button>
|
||||
<button id="speedBtn" class="btn">速度 x1</button>
|
||||
<button id="ecologyBtn" class="btn">たりないの生態</button>
|
||||
<div id="soundMenu" class="sound-menu">
|
||||
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="音">
|
||||
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>たりないの声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
|
||||
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
|
||||
<button id="saveBtn" class="btn">セーブ</button>
|
||||
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">実績 <span id="achievementButtonCount">0/43</span></button>
|
||||
<button id="resetBtn" class="btn danger reset-top-btn">リセット</button>
|
||||
<div id="topMenu" class="top-menu">
|
||||
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="メニュー">☰</button>
|
||||
<div id="topMenuPanel" class="top-menu-panel hidden" role="menu" aria-label="メニュー">
|
||||
<button id="ecologyBtn" class="top-menu-item" type="button" role="menuitem">生態</button>
|
||||
<div id="soundMenu" class="sound-menu top-menu-sound">
|
||||
<button id="soundBtn" class="top-menu-item sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="音">
|
||||
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>たりないの声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
|
||||
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="visualSettingsMenu" class="sound-menu top-menu-sound top-menu-visual">
|
||||
<button id="visualSettingsBtn" class="top-menu-item sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">表示設定</button>
|
||||
<div id="visualSettingsPanel" class="sound-panel hidden" role="menu" aria-label="表示設定">
|
||||
<label><input id="visualEffectsToggle" type="checkbox" data-visual-setting="effects" data-visual-on="high" data-visual-off="off"><span>エフェクト</span></label>
|
||||
<label><input id="visualShadowsToggle" type="checkbox" data-visual-setting="shadows" data-visual-on="high" data-visual-off="off"><span>影</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<div class="sound-panel-subtitle">描画</div>
|
||||
<label><input type="radio" name="visualDetails" data-visual-setting="details" data-visual-value="auto"><span>自動</span></label>
|
||||
<label><input type="radio" name="visualDetails" data-visual-setting="details" data-visual-value="low"><span>低</span></label>
|
||||
<label><input type="radio" name="visualDetails" data-visual-setting="details" data-visual-value="high"><span>高</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<button id="creditsBtn" class="top-menu-item" type="button" role="menuitem">クレジット</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="creditsBtn" class="btn">クレジット</button>
|
||||
<button id="saveBtn" class="btn">セーブ</button>
|
||||
<button id="visualSettingsBtn" class="btn" type="button">表示設定</button>
|
||||
<button id="resetBtn" class="btn danger">リセット</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -98,23 +121,34 @@
|
|||
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">コロニー</span><span class="panel-card-state">折り畳み</span></button>
|
||||
<div class="panel-card-body">
|
||||
<div class="stats colony-stats">
|
||||
<div class="stat"><span>総数</span><strong id="statPop">0</strong></div>
|
||||
<div class="stat"><span>死亡</span><strong id="statDead">0</strong></div>
|
||||
<div class="stat"><span>状態</span><strong id="statColonyMood">安定</strong></div>
|
||||
<div class="stat ground-stat" data-ground-toggle role="button" tabindex="0"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="地面をきりかえる"><strong id="statGroundType">砂</strong></button></div>
|
||||
</div>
|
||||
<div class="temperature-control" aria-label="気温調整">
|
||||
<div class="temperature-control-head"><span>気温</span><strong id="temperatureValue">10℃</strong><label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> 自動</label></div>
|
||||
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
|
||||
</div>
|
||||
<div class="colony-limit-control" aria-label="コロニー上限設定">
|
||||
<div class="colony-limit-head"><span>上限設定</span><small>0は上限なし</small></div>
|
||||
<div class="colony-limit-grid">
|
||||
<label class="colony-limit-row"><span>たりない個体数</span><input id="tarinaiPopulationLimitSlider" class="colony-limit-slider" type="range" min="0" max="300" step="1" value="0"><input id="tarinaiPopulationLimitInput" class="colony-limit-number" type="number" min="0" max="999" step="1" inputmode="numeric" placeholder="0"></label>
|
||||
<label class="colony-limit-row"><span>オブジェクト総数</span><input id="objectLimitSlider" class="colony-limit-slider" type="range" min="0" max="1200" step="1" value="0"><input id="objectLimitInput" class="colony-limit-number" type="number" min="0" max="9999" step="1" inputmode="numeric" placeholder="0"></label>
|
||||
<details class="colony-setting-fold colony-settings-fold">
|
||||
<summary><span>コロニー設定</span></summary>
|
||||
<div class="colony-settings-body">
|
||||
<section class="colony-setting-section ground-setting-section">
|
||||
<div class="colony-setting-section-head"><span>地面</span></div>
|
||||
<button id="groundTypeBtn" class="ground-type-btn colony-setting-action" type="button" data-ground-toggle aria-label="地面をきりかえる"><strong id="statGroundType">砂</strong><span>切り替え</span></button>
|
||||
</section>
|
||||
<section class="colony-setting-section temperature-section">
|
||||
<div class="colony-setting-section-head"><span>気温</span><strong id="temperatureValue">10℃</strong></div>
|
||||
<div class="temperature-control" aria-label="気温調整">
|
||||
<label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> 自動</label>
|
||||
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
|
||||
</div>
|
||||
</section>
|
||||
<section class="colony-setting-section colony-limit-section">
|
||||
<div class="colony-setting-section-head"><span>上限設定</span><small>0は上限なし</small></div>
|
||||
<div class="colony-limit-control" aria-label="コロニー上限設定">
|
||||
<div class="colony-limit-grid">
|
||||
<label class="colony-limit-row"><span>たりない個体数</span><input id="tarinaiPopulationLimitSlider" class="colony-limit-slider" type="range" min="0" max="300" step="1" value="0"><input id="tarinaiPopulationLimitInput" class="colony-limit-number" type="number" min="0" max="999" step="1" inputmode="numeric" placeholder="0"></label>
|
||||
<label class="colony-limit-row"><span>オブジェクト総数</span><input id="objectLimitSlider" class="colony-limit-slider" type="range" min="0" max="1200" step="1" value="0"><input id="objectLimitInput" class="colony-limit-number" type="number" min="0" max="9999" step="1" inputmode="numeric" placeholder="0"></label>
|
||||
</div>
|
||||
<div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div>
|
||||
</div>
|
||||
</details>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">個体数</button><button data-chart="life">欲求</button><button data-chart="objects">環境</button></div>
|
||||
<div id="colonyChartLegend" class="colony-chart-legend"></div>
|
||||
<div id="colonyChart" class="colony-chart" aria-label="コロニーの推移グラフ" role="img"></div>
|
||||
|
|
@ -162,6 +196,36 @@
|
|||
|
||||
</div>
|
||||
|
||||
|
||||
<div id="achievementsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="achievementsDialogTitle">
|
||||
<div class="field-dialog-panel achievement-dialog-panel">
|
||||
<div class="achievement-dialog-head">
|
||||
<div>
|
||||
<div class="achievement-dialog-kicker">たりない観察</div>
|
||||
<h2 id="achievementsDialogTitle">実績 <span id="achievementDialogCount">0 / 43</span></h2>
|
||||
</div>
|
||||
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="実績を閉じる">×</button>
|
||||
</div>
|
||||
<p class="field-dialog-lead">一部の条件は未達成でも公開されます。実績はこの端末に保存され、背景の棒グラフは全体達成率を示します。</p>
|
||||
<div id="achievementSharedStatus" class="achievement-shared-status" aria-live="polite">共有達成率を確認中…</div>
|
||||
<div id="achievementList" class="achievement-list"></div>
|
||||
<div class="field-dialog-actions achievement-dialog-actions">
|
||||
<button id="achievementResetBtn" class="btn danger achievement-reset-btn" type="button">実績をリセット</button>
|
||||
<button id="achievementUnlockAllBtn" class="btn" type="button" hidden>全実績を獲得</button>
|
||||
<button id="achievementRefreshBtn" class="btn" type="button">共有達成率を更新</button>
|
||||
<button id="achievementsCloseBtn" class="btn primary" type="button">閉じる</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="achievementToast" class="achievement-toast hidden" role="status" aria-live="polite">
|
||||
<div class="achievement-toast-icon" aria-hidden="true">★</div>
|
||||
<div class="achievement-toast-copy">
|
||||
<small>実績を解除しました</small>
|
||||
<strong id="achievementToastTitle"></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fieldDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="fieldDialogTitle">
|
||||
<div class="field-dialog-panel reset-dialog-panel">
|
||||
<h2 id="fieldDialogTitle">リセット設定</h2>
|
||||
|
|
@ -174,14 +238,14 @@
|
|||
</div>
|
||||
<div class="field-dialog-section-title">プリセット</div>
|
||||
<div class="field-choice-grid reset-preset-choice-grid">
|
||||
<button class="field-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>空白</strong><span>何も無い</span></button>
|
||||
<button class="field-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>デフォルト</strong><span>初期生成</span></button>
|
||||
<button class="field-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>アスレチック</strong><span>柵とボール</span></button>
|
||||
<button class="field-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>空白</strong><span>何も無い。</span></button>
|
||||
<button class="field-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>デフォルト</strong><span>初期生成。</span></button>
|
||||
<button class="field-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>アスレチック</strong><span>柵とボール。</span></button>
|
||||
<button class="field-choice" data-reset-preset="crowded" type="button" aria-pressed="false"><strong>過密</strong><span>大量のたりない。</span></button>
|
||||
<button class="field-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>家族</strong><span>2匹とへこ餅</span></button>
|
||||
<button class="field-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>戦争</strong><span>けんかと柵</span></button>
|
||||
<button class="field-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>幸福</strong><span>掃除機と巣箱</span></button>
|
||||
<button class="field-choice" data-reset-preset="hair_trigger" type="button" aria-pressed="false"><strong>一触即発</strong><span>中央のボール柵</span></button>
|
||||
<button class="field-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>家族</strong><span>2匹とへこ餅。</span></button>
|
||||
<button class="field-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>戦争</strong><span>けんかと柵。</span></button>
|
||||
<button class="field-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>幸福</strong><span>掃除機と巣箱。</span></button>
|
||||
<button class="field-choice" data-reset-preset="hair_trigger" type="button" aria-pressed="false"><strong>一触即発</strong><span>中央のボール柵。</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="fieldCancelBtn" class="btn" type="button">キャンセル</button>
|
||||
|
|
@ -192,40 +256,6 @@
|
|||
|
||||
|
||||
|
||||
<div id="visualSettingsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="visualSettingsDialogTitle">
|
||||
<div class="field-dialog-panel visual-settings-dialog-panel">
|
||||
<h2 id="visualSettingsDialogTitle">表示設定</h2>
|
||||
<p class="hint">自動ではFPSに応じて3段階の負荷制御を行います。高はシミュレーション間引きなし、低は常時軽量化です。</p>
|
||||
<div class="visual-setting-row">
|
||||
<div><strong>エフェクト</strong><span>破片、雨、光の演出</span></div>
|
||||
<div class="visual-setting-options" role="group" aria-label="エフェクト">
|
||||
<button type="button" data-visual-setting="effects" data-visual-value="off">OFF</button>
|
||||
<button type="button" data-visual-setting="effects" data-visual-value="high">ON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visual-setting-row">
|
||||
<div><strong>影</strong><span>地面の落ち影とスプライトの影</span></div>
|
||||
<div class="visual-setting-options" role="group" aria-label="影">
|
||||
<button type="button" data-visual-setting="shadows" data-visual-value="off">OFF</button>
|
||||
<button type="button" data-visual-setting="shadows" data-visual-value="high">ON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visual-setting-row">
|
||||
<div><strong>描画</strong><span>装飾と解像度の設定</span></div>
|
||||
<div class="visual-setting-options" role="group" aria-label="描画">
|
||||
<button type="button" data-visual-setting="details" data-visual-value="auto">自動</button>
|
||||
<button type="button" data-visual-setting="details" data-visual-value="low">低</button>
|
||||
<button type="button" data-visual-setting="details" data-visual-value="high">高</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="visualSettingsCloseBtn" class="btn" type="button">閉じる</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="creditsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="creditsDialogTitle">
|
||||
<div class="field-dialog-panel">
|
||||
<h2 id="creditsDialogTitle">クレジット</h2>
|
||||
|
|
@ -262,169 +292,171 @@
|
|||
</div>
|
||||
|
||||
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
|
||||
<script src="js/version.js?v=39.16.31" defer></script>
|
||||
<script src="js/event_bus.js?v=39.16.31" defer></script>
|
||||
<script src="js/domain_ids.js?v=39.16.31" defer></script>
|
||||
<script src="js/registry_base.js?v=39.16.31" defer></script>
|
||||
<script src="js/disease_registry.js?v=39.16.31" defer></script>
|
||||
<script src="js/sound_pack.js?v=39.16.31" defer></script>
|
||||
<script src="js/deterministic_helpers.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_tool_metadata.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_tool_definitions.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_visual_definitions.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_food_definitions.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_effect_definitions.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_registry.js?v=39.16.31" defer></script>
|
||||
<script src="js/data.js?v=39.16.31" defer></script>
|
||||
<script src="js/ground_types.js?v=39.16.31" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=39.16.31" defer></script>
|
||||
<script src="js/math.js?v=39.16.31" defer></script>
|
||||
<script src="js/geometry_helpers.js?v=39.16.31" defer></script>
|
||||
<script src="js/collision_footprint_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/physics_helpers.js?v=39.16.31" defer></script>
|
||||
<script src="js/placement_preview_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/pin_attachment_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/physics_shape_editor_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/mechanical_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/mechanical_shape_bridge.js?v=39.16.31" defer></script>
|
||||
<script src="js/constraint_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/physics_world_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/physics_projection_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/signal_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/assets.js?v=39.16.31" defer></script>
|
||||
<script src="js/audio.js?v=39.16.31" defer></script>
|
||||
<script src="js/perf_profiler.js?v=39.16.31" defer></script>
|
||||
<script src="js/display_helpers.js?v=39.16.31" defer></script>
|
||||
<script src="js/render.js?v=39.16.31" defer></script>
|
||||
<script src="js/sim_core.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=39.16.31" defer></script>
|
||||
<script src="js/structures.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_type_catalog.js?v=39.16.31" defer></script>
|
||||
<script src="js/items.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_update_policy.js?v=39.16.31" defer></script>
|
||||
<script src="js/fire_runtime_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/robot_cleaner_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=39.16.31" defer></script>
|
||||
<script src="js/update_step_pipeline_runner.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_runtime.js?v=39.16.31" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_render_helpers.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=39.16.31" defer></script>
|
||||
<script src="js/burn_motion_util.js?v=39.16.31" defer></script>
|
||||
<script src="js/ants.js?v=39.16.31" defer></script>
|
||||
<script src="js/health.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_nest_sleep_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_direct_feeding_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_render.js?v=39.16.31" defer></script>
|
||||
<script src="js/world.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_view.js?v=39.16.31" defer></script>
|
||||
<script src="js/family_graph.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_reset_presets.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_family_social.js?v=39.16.31" defer></script>
|
||||
<script src="js/impact_core_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/impact_response_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/collision_response_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_environment.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_temperature_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_pathfinding_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_grass_placement_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_ants_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/weather_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=39.16.31" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation_systems.js?v=39.16.31" defer></script>
|
||||
<script src="js/system_order.js?v=39.16.31" defer></script>
|
||||
<script src="js/simulation.js?v=39.16.31" defer></script>
|
||||
<script src="js/colony_situation_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_update.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_placement_log.js?v=39.16.31" defer></script>
|
||||
<script src="js/world_event_effects.js?v=39.16.31" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=39.16.31" defer></script>
|
||||
<script src="js/text_catalog.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_log.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_helpers.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_selected.js?v=39.16.31" defer></script>
|
||||
<script src="js/save_schema.js?v=39.16.31" defer></script>
|
||||
<script src="js/snapshot_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=39.16.31" defer></script>
|
||||
<script src="js/history_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/save_codec.js?v=39.16.31" defer></script>
|
||||
<script src="js/save_storage.js?v=39.16.31" defer></script>
|
||||
<script src="js/save_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_ground.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_tools.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_pointer_action_system.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_bind.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_charts.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_family_data.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_family_async.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=39.16.31" defer></script>
|
||||
<script src="js/ui_family_render.js?v=39.16.31" defer></script>
|
||||
<script src="js/main.js?v=39.16.31" defer></script>
|
||||
<script src="js/debug_tools.js?v=39.16.31" defer></script>
|
||||
<script src="js/version.js?v=39.16.56" defer></script>
|
||||
<script src="js/event_bus.js?v=39.16.56" defer></script>
|
||||
<script src="js/domain_ids.js?v=39.16.56" defer></script>
|
||||
<script src="js/registry_base.js?v=39.16.56" defer></script>
|
||||
<script src="js/disease_registry.js?v=39.16.56" defer></script>
|
||||
<script src="js/sound_pack.js?v=39.16.56" defer></script>
|
||||
<script src="js/deterministic_helpers.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_tool_metadata.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_tool_definitions.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_visual_definitions.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_food_definitions.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_effect_definitions.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_registry.js?v=39.16.56" defer></script>
|
||||
<script src="js/data.js?v=39.16.56" defer></script>
|
||||
<script src="js/ground_types.js?v=39.16.56" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=39.16.56" defer></script>
|
||||
<script src="js/math.js?v=39.16.56" defer></script>
|
||||
<script src="js/geometry_helpers.js?v=39.16.56" defer></script>
|
||||
<script src="js/collision_footprint_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/physics_helpers.js?v=39.16.56" defer></script>
|
||||
<script src="js/placement_preview_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/pin_attachment_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/physics_shape_editor_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/mechanical_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/mechanical_shape_bridge.js?v=39.16.56" defer></script>
|
||||
<script src="js/constraint_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/physics_world_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/physics_projection_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/signal_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/assets.js?v=39.16.56" defer></script>
|
||||
<script src="js/audio.js?v=39.16.56" defer></script>
|
||||
<script src="js/perf_profiler.js?v=39.16.56" defer></script>
|
||||
<script src="js/display_helpers.js?v=39.16.56" defer></script>
|
||||
<script src="js/render.js?v=39.16.56" defer></script>
|
||||
<script src="js/sim_core.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=39.16.56" defer></script>
|
||||
<script src="js/structures.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_type_catalog.js?v=39.16.56" defer></script>
|
||||
<script src="js/items.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_update_policy.js?v=39.16.56" defer></script>
|
||||
<script src="js/fire_runtime_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/robot_cleaner_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=39.16.56" defer></script>
|
||||
<script src="js/update_step_pipeline_runner.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_runtime.js?v=39.16.56" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_render_helpers.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=39.16.56" defer></script>
|
||||
<script src="js/burn_motion_util.js?v=39.16.56" defer></script>
|
||||
<script src="js/ants.js?v=39.16.56" defer></script>
|
||||
<script src="js/health.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_nest_sleep_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_direct_feeding_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_render.js?v=39.16.56" defer></script>
|
||||
<script src="js/world.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_view.js?v=39.16.56" defer></script>
|
||||
<script src="js/family_graph.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_reset_presets.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_family_social.js?v=39.16.56" defer></script>
|
||||
<script src="js/impact_core_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/impact_response_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/collision_response_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_environment.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_temperature_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_pathfinding_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_grass_placement_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_ants_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/weather_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=39.16.56" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation_systems.js?v=39.16.56" defer></script>
|
||||
<script src="js/system_order.js?v=39.16.56" defer></script>
|
||||
<script src="js/simulation.js?v=39.16.56" defer></script>
|
||||
<script src="js/colony_situation_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_update.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_placement_log.js?v=39.16.56" defer></script>
|
||||
<script src="js/world_event_effects.js?v=39.16.56" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=39.16.56" defer></script>
|
||||
<script src="js/text_catalog.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui.js?v=39.16.56" defer></script>
|
||||
<script src="js/game_dialogs.js?v=39.16.56" defer></script>
|
||||
<script src="js/achievements.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_log.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_helpers.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_selected.js?v=39.16.56" defer></script>
|
||||
<script src="js/save_schema.js?v=39.16.56" defer></script>
|
||||
<script src="js/snapshot_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=39.16.56" defer></script>
|
||||
<script src="js/history_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/save_codec.js?v=39.16.56" defer></script>
|
||||
<script src="js/save_storage.js?v=39.16.56" defer></script>
|
||||
<script src="js/save_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_ground.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_tools.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_pointer_action_system.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_bind.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_charts.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_family_data.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_family_async.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=39.16.56" defer></script>
|
||||
<script src="js/ui_family_render.js?v=39.16.56" defer></script>
|
||||
<script src="js/main.js?v=39.16.56" defer></script>
|
||||
<script src="js/debug_tools.js?v=39.16.56" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
1548
js/achievements.js
Normal file
1548
js/achievements.js
Normal file
File diff suppressed because it is too large
Load diff
10
js/ants.js
10
js/ants.js
|
|
@ -1,10 +1,10 @@
|
|||
"use strict";
|
||||
|
||||
const ANT_NEST_START_COUNT = 6;
|
||||
const ANT_NEST_START_COUNT = 3;
|
||||
const ANT_NEST_MAX_COUNT = 10;
|
||||
const ANT_NEST_MAX_OUTSIDE = 3;
|
||||
const ANT_WORKER_HP = 48;
|
||||
const ANT_QUEEN_HP = 270;
|
||||
const ANT_WORKER_HP = 24;
|
||||
const ANT_QUEEN_HP = 135;
|
||||
const ANT_WORKER_RADIUS = 4.2;
|
||||
const ANT_QUEEN_RADIUS = 9.5;
|
||||
const ANT_WORKER_SEARCH_SPEED = 12.9375;
|
||||
|
|
@ -91,6 +91,7 @@ class AntActor {
|
|||
|
||||
igniteFire(source = null) {
|
||||
if (this.dead) return false;
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(this, source, { world: this.world, reason: "fire" });
|
||||
this.burning = true;
|
||||
this.burnTimer = Math.max(this.burnTimer || 0, 4.2 + rand(0, 2.4));
|
||||
this.state = "panic";
|
||||
|
|
@ -412,6 +413,7 @@ class AntActor {
|
|||
const struggle = stillSleeping ? 0 : (aggressiveAttack
|
||||
? (1.25 + aggression * 1.6 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.35) / Math.max(1, haulers * 0.9)
|
||||
: (0.42 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.18) / Math.max(1, haulers));
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(this, target, { world: this.world, reason: "tarinai_struggle" });
|
||||
this.hp -= dt * struggle;
|
||||
|
||||
if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) {
|
||||
|
|
@ -424,6 +426,7 @@ class AntActor {
|
|||
dieAsCorpse(home = null) {
|
||||
if (this.dead) return;
|
||||
this.dead = true;
|
||||
globalThis.TarinaiAchievements?.recordAntKilled?.({ world: this.world, ant: this, home });
|
||||
audio.antDie?.();
|
||||
const corpse = new Item("ant_corpse", this.x, this.y);
|
||||
corpse.amount = 24;
|
||||
|
|
@ -573,4 +576,3 @@ class AntActor {
|
|||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
21
js/assets.js
21
js/assets.js
|
|
@ -70,7 +70,9 @@ const scaledSpriteCache = new Map();
|
|||
const stainOverlayCache = new Map();
|
||||
const SPRITE_CACHE_LIMIT = 128;
|
||||
const STAIN_CACHE_LIMIT = 64;
|
||||
const IMAGE_LOAD_TIMEOUT_MS = 9000;
|
||||
const IMAGE_KEEP_IDS = new Set(["smile"]);
|
||||
let imageLoadTimeoutsTotal = 0;
|
||||
|
||||
const SUNBATH_IMAGE_IDS = new Set(SPRITES
|
||||
.map(asset => asset?.id || "")
|
||||
|
|
@ -228,9 +230,22 @@ function loadImageAsset(asset, priority = "auto") {
|
|||
img.alphaBounds = ALPHA_BOUNDS[asset.id] || null;
|
||||
images.set(asset.id, img);
|
||||
let promise;
|
||||
let settled = false;
|
||||
promise = new Promise((resolve) => {
|
||||
img.onload = () => { markImageLoaded(asset, img); touchImage(asset.id); resolve(img); };
|
||||
img.onerror = () => { resolve(null); };
|
||||
const finish = (value, timedOut = false) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timedOut) imageLoadTimeoutsTotal += 1;
|
||||
resolve(value);
|
||||
};
|
||||
const timer = window.setTimeout ? window.setTimeout(() => finish(null, true), IMAGE_LOAD_TIMEOUT_MS) : null;
|
||||
img.onload = () => {
|
||||
markImageLoaded(asset, img);
|
||||
touchImage(asset.id);
|
||||
finish(img);
|
||||
};
|
||||
img.onerror = () => { finish(null); };
|
||||
}).finally(() => {
|
||||
if (imagePromises.get(asset.id) === promise) imagePromises.delete(asset.id);
|
||||
});
|
||||
|
|
@ -369,7 +384,7 @@ if (typeof window !== "undefined") {
|
|||
trim: trimImageMemory,
|
||||
unload: unloadImageAsset,
|
||||
stats() {
|
||||
return { loaded: images.size, pending: imagePromises.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size };
|
||||
return { loaded: images.size, pending: imagePromises.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size, timeouts: imageLoadTimeoutsTotal };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,6 +216,9 @@
|
|||
thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
|
||||
});
|
||||
worldRef.applyImpactDamage(t, damage, "\u885d\u7a81", { source: ball });
|
||||
if (t.dead && ball.type === "ball") {
|
||||
globalThis.TarinaiAchievements?.recordSoccerBallDeath?.({ world: worldRef, tarinai: t, ball, speed: ballSpeed, damage });
|
||||
}
|
||||
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
|
||||
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
|
||||
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
|
||||
|
|
@ -319,11 +322,17 @@
|
|||
if (!worldRef.tarinaiCollisionMemo) worldRef.tarinaiCollisionMemo = new Map();
|
||||
const providedFastSources = Array.isArray(opts.fastSources);
|
||||
const liveSource = providedFastSources ? opts.fastSources : (worldRef.tarinai || []);
|
||||
const maxSources = Math.max(1, Math.floor(Number(opts.maxSources || Infinity) || Infinity));
|
||||
const fastSources = [];
|
||||
for (const t of liveSource) {
|
||||
const sourceLen = liveSource?.length || 0;
|
||||
const cursor = sourceLen > maxSources ? (Math.max(0, Math.floor(worldRef._tarinaiHighSpeedCollisionCursor || 0)) % sourceLen) : 0;
|
||||
for (let n = 0; n < sourceLen; n += 1) {
|
||||
if (fastSources.length >= maxSources) break;
|
||||
const t = liveSource[(cursor + n) % sourceLen];
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox(t)) continue;
|
||||
if (Math.hypot(t.vx || 0, t.vy || 0) >= threshold) fastSources.push(t);
|
||||
}
|
||||
if (sourceLen > maxSources) worldRef._tarinaiHighSpeedCollisionCursor = (cursor + Math.max(1, maxSources)) % sourceLen;
|
||||
if (!fastSources.length) return;
|
||||
const live = providedFastSources ? null : [];
|
||||
if (!providedFastSources) {
|
||||
|
|
@ -389,7 +398,7 @@
|
|||
if (candidates.length >= maxCandidates) break;
|
||||
}
|
||||
candidates.sort((p, q) => (p.u - q.u) || (q.score - p.score) || String(p.key).localeCompare(String(q.key)));
|
||||
const maxImpacts = Math.max(8, Math.min(64, Number(worldRef.maxTarinaiCollisionImpacts || 32) || 32));
|
||||
const maxImpacts = Math.max(1, Math.min(64, Number(opts.maxImpacts || worldRef.maxTarinaiCollisionImpacts || 32) || 32));
|
||||
let solved = 0;
|
||||
for (const c of candidates) {
|
||||
if (solved >= maxImpacts) break;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
const recentDeaths = (Array.isArray(worldRef?.recentDeathTimes) ? worldRef.recentDeathTimes : []).filter(t => now - (Number(t) || -999) <= 30).length;
|
||||
const nearestAvg = nearestAverage(alive);
|
||||
const fieldId = String(worldRef?.fieldType || "garden");
|
||||
const overcrowdLimit = fieldId === "cage" ? 20 : (fieldId === "park" ? 100 : 50);
|
||||
const overcrowdLimit = fieldId === "cage" ? 28 : (fieldId === "park" ? 140 : 75);
|
||||
return {
|
||||
now,
|
||||
alive,
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
diseaseRatio: ratio(diseasedCount),
|
||||
breedingRatio: ratio(breedingCount),
|
||||
overcrowdLimit,
|
||||
denseCrowd: Number.isFinite(nearestAvg) && nearestAvg < 84 && n >= Math.max(8, Math.floor(overcrowdLimit * 0.55)),
|
||||
denseCrowd: Number.isFinite(nearestAvg) && nearestAvg < 58 && n >= Math.max(12, Math.floor(overcrowdLimit * 0.75)),
|
||||
};
|
||||
}
|
||||
function choose(worldRef, m) {
|
||||
|
|
@ -88,6 +88,7 @@
|
|||
const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } };
|
||||
worldRef.colonyMood = next;
|
||||
worldRef.nextColonyMoodEvalAt = now + 1.25;
|
||||
global.TarinaiAchievements?.evaluateWorld?.(worldRef, next);
|
||||
return next;
|
||||
}
|
||||
global.TarinaiColonySituationSystem = Object.freeze({ evaluate });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
(function (global) {
|
||||
const DEFAULT_SPEEDS = Object.freeze([1, 2, 4]);
|
||||
const DEFAULT_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
|
||||
const DEFAULT_PICK_MODE_ORDER = Object.freeze(["free", "grid20", "grid40", "grid60"]);
|
||||
|
||||
function emit(worldRef, type, payload) {
|
||||
worldRef?.emit?.(type, payload);
|
||||
|
|
@ -98,6 +99,30 @@
|
|||
return setToolSize(worldRef, id, next);
|
||||
}
|
||||
|
||||
function normalizeToolPickMode(mode = "free") {
|
||||
const value = String(mode || "free").toLowerCase();
|
||||
if (value === "grid") return "grid40"; // compatibility with v39.16.34
|
||||
return DEFAULT_PICK_MODE_ORDER.includes(value) ? value : "free";
|
||||
}
|
||||
|
||||
function setToolPickMode(worldRef, mode = "free") {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
const next = normalizeToolPickMode(mode);
|
||||
worldRef.toolPickMode = next;
|
||||
return ok({ mode: next });
|
||||
}
|
||||
|
||||
function cycleToolPickMode(worldRef, order = DEFAULT_PICK_MODE_ORDER) {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
const modes = Array.isArray(order) && order.length
|
||||
? order.map(normalizeToolPickMode).filter((value, index, list) => list.indexOf(value) === index)
|
||||
: Array.from(DEFAULT_PICK_MODE_ORDER);
|
||||
const current = normalizeToolPickMode(worldRef.toolPickMode);
|
||||
const index = modes.indexOf(current);
|
||||
const next = modes[(index + 1 + modes.length) % modes.length] || "free";
|
||||
return setToolPickMode(worldRef, next);
|
||||
}
|
||||
|
||||
function togglePaused(worldRef) {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
worldRef.paused = !worldRef.paused;
|
||||
|
|
@ -130,6 +155,11 @@
|
|||
return selectTool(worldRef, command.toolId);
|
||||
case "tool.size.cycle":
|
||||
return cycleToolSize(worldRef, command.toolId, command.order);
|
||||
case "tool.pick.toggle":
|
||||
case "tool.pick.cycle":
|
||||
return cycleToolPickMode(worldRef, command.order);
|
||||
case "tool.pick.set":
|
||||
return setToolPickMode(worldRef, command.mode);
|
||||
case "selection.clear":
|
||||
return clearSelection(worldRef);
|
||||
case "selection.focus":
|
||||
|
|
@ -143,12 +173,18 @@
|
|||
case "pointer.primary":
|
||||
return primaryPointer(worldRef, command.x, command.y);
|
||||
case "history.undo": {
|
||||
const deadBefore = Math.max(0, Number(worldRef?.deadCount || 0) || 0);
|
||||
const result = global.TarinaiHistory.undo(worldRef);
|
||||
return result.ok ? ok({ action: "undo" }) : fail(result.reason || "undo_failed");
|
||||
if (!result.ok) return fail(result.reason || "undo_failed");
|
||||
const deadAfter = Math.max(0, Number(worldRef?.deadCount || 0) || 0);
|
||||
global.TarinaiAchievements?.recordHistoryAction?.("undo", { world: worldRef, deadRestored: Math.max(0, deadBefore - deadAfter), label: result.label || "" });
|
||||
return ok({ action: "undo" });
|
||||
}
|
||||
case "history.redo": {
|
||||
const result = global.TarinaiHistory.redo(worldRef);
|
||||
return result.ok ? ok({ action: "redo" }) : fail(result.reason || "redo_failed");
|
||||
if (!result.ok) return fail(result.reason || "redo_failed");
|
||||
global.TarinaiAchievements?.recordHistoryAction?.("redo", { world: worldRef, label: result.label || "" });
|
||||
return ok({ action: "redo" });
|
||||
}
|
||||
default:
|
||||
return fail("unknown_command", { type: command.type || "" });
|
||||
|
|
|
|||
|
|
@ -1052,7 +1052,8 @@
|
|||
try {
|
||||
worldRef.ensureItemBuckets?.("constraint-world");
|
||||
const maxLinks = Math.max(8, Number(opts.maxLinks || 160) || 160);
|
||||
const links = [];
|
||||
const links = worldRef._constraintLinksScratch || (worldRef._constraintLinksScratch = []);
|
||||
links.length = 0;
|
||||
for (const item of worldRef.itemsOfType("rope") || []) if (item && !item.dead) links.push(item);
|
||||
for (const item of worldRef.itemsOfType("rod") || []) if (item && !item.dead) links.push(item);
|
||||
for (const item of worldRef.itemsOfType("spring") || []) if (item && !item.dead) links.push(item);
|
||||
|
|
@ -1063,15 +1064,16 @@
|
|||
// Rotate the starting point each frame so a large rope/rod set does not
|
||||
// starve low-score links that are always beyond the per-frame budget.
|
||||
const cursor = Math.max(0, Math.min(links.length - 1, Number(worldRef._constraintBudgetCursor || 0) || 0));
|
||||
const ordered = links.slice(cursor).concat(links.slice(0, cursor));
|
||||
let ran = 0;
|
||||
let visited = 0;
|
||||
let skipped = 0;
|
||||
let changed = 0;
|
||||
let rodStretchMax = 0;
|
||||
let rodCorrections = 0;
|
||||
const pending = [];
|
||||
for (const item of ordered) {
|
||||
const pending = worldRef._constraintPendingScratch || (worldRef._constraintPendingScratch = []);
|
||||
pending.length = 0;
|
||||
for (let n = 0; n < links.length; n += 1) {
|
||||
const item = links[(cursor + n) % links.length];
|
||||
if (opts.statePrepared === true) ensureConstraint(item, worldRef, { syncFromLegacy: false });
|
||||
else applyConstraintState(item);
|
||||
visited += 1;
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@
|
|||
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} items ${(w?.items || []).length} ants ${(w?.ants || []).length} effects ${(w?.effects || []).length}`,
|
||||
...saveStatsLines(),
|
||||
`visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}/${effectRender.budget ?? metrics["render.effectDrawBudget"] ?? "?"} skip:${effectRender.skipped ?? metrics["render.effectDrawSkipped"] ?? "?"}`,
|
||||
`creatures full:${creature.full ?? 0} bg:${creature.fullBudgetUsed ?? 0}/${creature.fullBudget ?? "?"} bgSkip:${creature.fullBudgetSkips ?? 0} offSkip:${creature.offscreenSkipped ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
|
||||
`creatures full:${creature.full ?? 0} bg:${creature.fullBudgetUsed ?? 0}/${creature.fullBudget ?? "?"} bgSkip:${creature.fullBudgetSkips ?? 0} vFull:${creature.visibleFullUsed ?? 0}/${creature.visibleFullBudget ?? "?"} vSkip:${creature.visibleFullSkips ?? 0} vMotion:${creature.visibleMotionUsed ?? 0}/${creature.visibleMotionBudget ?? "?"} mPressure:${creature.mechanicalPressure ?? 0}${creature.mechanicalPressureActive ? "!" : ""} mContact:${creature.mechanicalDetailedContacts ?? 0}/${creature.mechanicalContactBudget ?? "?"}+${creature.mechanicalDegradedContacts ?? 0} offSkip:${creature.offscreenSkipped ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
|
||||
`creature lanes ${topMap(creature.lanes, 6)} smoothStates ${topMap(creature.smoothStates, 4)}`,
|
||||
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} partial:${diag.spatial?.partialRebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} deferred:${diag.spatial?.deferredReads ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
|
||||
`spatial dirtyReasons ${topMap(diag.spatial?.dirtyReasons, 4)} rebuildReasons ${topMap(diag.spatial?.rebuildReasons, 4)}`,
|
||||
|
|
|
|||
|
|
@ -90,7 +90,12 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
function igniteTarinaiByFire(t, source = null, worldRef = null, force = false) {
|
||||
if (!t || t.dead) return false;
|
||||
if (typeof t.igniteFire === "function") return t.igniteFire(source, force);
|
||||
const already = Boolean(t.burning || (t.burnTimer || 0) > 0.02);
|
||||
if (already && !force) return false;
|
||||
const ignitedDuringBirthRitual = !already && Boolean((t.birthRitualTimer || 0) > 0.04 || t.state === "birth_ritual");
|
||||
t.burning = true;
|
||||
const dangerSource = source?.roles?.danger ? source : (source?._achievementDangerSource?.roles?.danger ? source._achievementDangerSource : null);
|
||||
if (dangerSource) t._achievementDangerSource = dangerSource;
|
||||
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
|
||||
t.thought = FIRE_BURNING_TEXT;
|
||||
t.lastPanicCause = "burning";
|
||||
|
|
@ -98,6 +103,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
|
||||
t.sleeping = false;
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_BURNING_TEXT, wake: true });
|
||||
if (ignitedDuringBirthRitual) global.TarinaiAchievements?.recordIgnition?.({ world: worldRef || t.world, tarinai: t, source });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -425,6 +431,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
fire.amount = Math.min(260, Math.max(fire.amount || 0, other.amount || 0) + Math.min(34, Math.max(0, Number(other.amount || 0) * 0.35)));
|
||||
fire.flameLife = Math.max(fire.flameLife || 0, other.flameLife || 0);
|
||||
fire.r = Math.min(42, Math.max(fire.r || 14, other.r || 14));
|
||||
if (!fire._achievementDangerSource && other._achievementDangerSource) fire._achievementDangerSource = other._achievementDangerSource;
|
||||
other.amount = 0;
|
||||
merged += 1;
|
||||
if (merged >= maxMerges) break;
|
||||
|
|
@ -462,6 +469,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
}
|
||||
if (!best) return null;
|
||||
best.amount = Math.min(280, Math.max(Number(best.amount || 0), Number(opts.amount || 0) * 0.45) + 14);
|
||||
if (opts.source) best._achievementDangerSource = opts.source._achievementDangerSource || opts.source;
|
||||
best.flameLife = Math.max(Number(best.flameLife || 0), Math.max(1.0, Number(opts.life || 3.5) * 0.75));
|
||||
best.r = Math.min(44, Math.max(best.r || 14, Number(opts.radius || 14)));
|
||||
best.vx = 0;
|
||||
|
|
@ -534,6 +542,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
existing.r = Math.min(42, Math.max(existing.r || 14, Number(opts.radius || 14)));
|
||||
existing.vx = 0;
|
||||
existing.vy = 0;
|
||||
if (opts.source) existing._achievementDangerSource = opts.source._achievementDangerSource || opts.source;
|
||||
worldRef.drawListDirty = true;
|
||||
return existing;
|
||||
}
|
||||
|
|
@ -544,6 +553,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
|
||||
fire.vx = 0;
|
||||
fire.vy = 0;
|
||||
if (opts.source) fire._achievementDangerSource = opts.source._achievementDangerSource || opts.source;
|
||||
const addedFire = worldRef.addItem?.(fire, "spawn-fire", { terrain: false }) || null;
|
||||
if (!addedFire) return null;
|
||||
worldRef.drawListDirty = true;
|
||||
|
|
|
|||
77
js/game_dialogs.js
Normal file
77
js/game_dialogs.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const queue = [];
|
||||
let active = null;
|
||||
|
||||
function ensureDialog() {
|
||||
let dialog = document.getElementById("gameConfirmDialog");
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("div");
|
||||
dialog.id = "gameConfirmDialog";
|
||||
dialog.className = "field-dialog game-confirm-dialog hidden";
|
||||
dialog.setAttribute("role", "alertdialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
dialog.setAttribute("aria-labelledby", "gameConfirmDialogTitle");
|
||||
dialog.setAttribute("aria-describedby", "gameConfirmDialogMessage");
|
||||
dialog.innerHTML = `
|
||||
<div class="field-dialog-panel game-confirm-panel">
|
||||
<h2 id="gameConfirmDialogTitle"></h2>
|
||||
<p id="gameConfirmDialogMessage" class="field-dialog-lead game-confirm-message"></p>
|
||||
<div class="field-dialog-actions game-confirm-actions">
|
||||
<button class="btn" data-game-dialog-cancel type="button"></button>
|
||||
<button class="btn primary" data-game-dialog-confirm type="button"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.addEventListener("click", event => {
|
||||
if (!active) return;
|
||||
if (event.target === dialog || event.target.closest("[data-game-dialog-cancel]")) settle(false);
|
||||
else if (event.target.closest("[data-game-dialog-confirm]")) settle(true);
|
||||
});
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!active || dialog.classList.contains("hidden")) return;
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation?.(); settle(false); }
|
||||
}, true);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (active || !queue.length) return;
|
||||
active = queue.shift();
|
||||
const dialog = ensureDialog();
|
||||
const title = dialog.querySelector("#gameConfirmDialogTitle");
|
||||
const message = dialog.querySelector("#gameConfirmDialogMessage");
|
||||
const confirmButton = dialog.querySelector("[data-game-dialog-confirm]");
|
||||
const cancelButton = dialog.querySelector("[data-game-dialog-cancel]");
|
||||
if (title) title.textContent = active.options.title || "\u78ba\u8a8d";
|
||||
if (message) message.textContent = active.options.message || "";
|
||||
if (confirmButton) {
|
||||
confirmButton.textContent = active.options.confirmText || "\u5b9f\u884c";
|
||||
confirmButton.classList.toggle("danger", Boolean(active.options.danger));
|
||||
confirmButton.classList.toggle("primary", !active.options.danger);
|
||||
}
|
||||
if (cancelButton) cancelButton.textContent = active.options.cancelText || "\u30ad\u30e3\u30f3\u30bb\u30eb";
|
||||
dialog.classList.remove("hidden");
|
||||
global.setTimeout(() => confirmButton?.focus?.(), 0);
|
||||
}
|
||||
|
||||
function settle(accepted) {
|
||||
if (!active) return;
|
||||
const current = active;
|
||||
active = null;
|
||||
document.getElementById("gameConfirmDialog")?.classList.add("hidden");
|
||||
current.restoreFocus?.focus?.();
|
||||
current.resolve(Boolean(accepted));
|
||||
global.setTimeout(pump, 0);
|
||||
}
|
||||
|
||||
function confirm(options = {}) {
|
||||
return new Promise(resolve => {
|
||||
queue.push({ options: { ...options }, resolve, restoreFocus: document.activeElement || null });
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
global.TarinaiGameDialogs = Object.freeze({ confirm });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -290,7 +290,7 @@ const HEALTH = (() => {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
function applyDamage(t, amount, reason = "") {
|
||||
function applyDamage(t, amount, reason = "", options = {}) {
|
||||
const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100);
|
||||
t.maxEnergy = maxEnergy;
|
||||
const before = clamp(Number(t.energy) || 0, 0, maxEnergy);
|
||||
|
|
@ -299,6 +299,7 @@ const HEALTH = (() => {
|
|||
const actualLoss = Math.max(0, before - t.energy);
|
||||
let cause = "";
|
||||
if (actualLoss > 0.01) {
|
||||
globalThis.TarinaiAchievements?.recordDamageSource?.(t, options.source || null, { world: t.world, amount: actualLoss, reason });
|
||||
cause = rememberDamage(t, actualLoss, reason);
|
||||
const plushie = (t.world?.ownedItemsFor?.(t.id) || t.world?.itemsOfType?.("plushie") || t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
|
||||
if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
t.vx += nx * push * (0.35 + p);
|
||||
t.vy += ny * push * (0.35 + p) - Math.min(210, push * 0.22) * p;
|
||||
const dealtDamage = damage * (0.35 + p * 0.65);
|
||||
if (dealtDamage > 0) t.damage(dealtDamage, reason);
|
||||
if (dealtDamage > 0) t.damage(dealtDamage, reason, { source: it });
|
||||
if (isGenkotsu) {
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
|
||||
|
|
@ -84,6 +84,7 @@
|
|||
}
|
||||
const antHit = damage > 0 ? worldRef.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
|
||||
push: p => push * (0.62 + p * 1.25),
|
||||
source: it,
|
||||
}) : 0;
|
||||
if (isGenkotsu) {
|
||||
worldRef.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75, { limit: 96, effectLimit: 6 });
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@
|
|||
}
|
||||
|
||||
function fanWindAt(fan, x, y, opts = {}) {
|
||||
if (global.TarinaiSignalSystem?.powerOverride?.(fan) === false) return null;
|
||||
if (!fan || fan.dead || fan.type !== "fan") return null;
|
||||
const range = Math.max(80, Number(opts.range || 300) || 300);
|
||||
const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62);
|
||||
|
|
@ -307,6 +308,8 @@
|
|||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
ensureFanBodyAngle(item);
|
||||
const powered = global.TarinaiSignalSystem?.powerOverride?.(item) !== false;
|
||||
if (!powered) { item.vx = 0; item.vy = 0; return true; }
|
||||
item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt;
|
||||
item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt;
|
||||
updateFanSwing(item, frameDt);
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@
|
|||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
|
||||
}
|
||||
if (!isOshibyo && t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
|
||||
if (!isOshibyo && t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2", { source: item });
|
||||
if ((worldRef.time || 0) >= (item.pinLogAt || -999) + 1.2) {
|
||||
item.pinLogAt = worldRef.time || 0;
|
||||
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
||||
|
|
@ -264,7 +264,7 @@
|
|||
item.pinDamageTick = (item.pinDamageTick || 0) + dt;
|
||||
while (item.pinDamageTick >= 0.55) {
|
||||
item.pinDamageTick -= 0.55;
|
||||
if (!isOshibyo && t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
|
||||
if (!isOshibyo && t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2", { source: item });
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
|
||||
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
|
||||
if (deterministicChance(worldRef, "pin-pain-bubble", 0.22, item, t)) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@
|
|||
(function (global) {
|
||||
function updateMotion(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "zunchi") return false;
|
||||
if (item.playerHeld || item._heldByPlayer) {
|
||||
if (item.achievementBurstOwnerId) item.achievementBurstNeverStopped = false;
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
return true;
|
||||
}
|
||||
const speed = Math.hypot(item.vx || 0, item.vy || 0);
|
||||
if (speed < 0.08) {
|
||||
if (item.achievementBurstOwnerId) item.achievementBurstNeverStopped = false;
|
||||
item.vx = 0; item.vy = 0;
|
||||
global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef);
|
||||
return true;
|
||||
|
|
@ -30,8 +37,10 @@
|
|||
worldRef.drawListDirty = true;
|
||||
}
|
||||
global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef);
|
||||
resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed);
|
||||
item.spin = (item.spin || 0) + speed * dt / Math.max(8, item.r || 14);
|
||||
const collisionSpeed = Math.hypot(item.vx || 0, item.vy || 0);
|
||||
if (collisionSpeed < 0.08 && item.achievementBurstOwnerId) item.achievementBurstNeverStopped = false;
|
||||
resolveHighSpeedTarinaiCollision(item, dt, worldRef, collisionSpeed);
|
||||
item.spin = (item.spin || 0) + collisionSpeed * dt / Math.max(8, item.r || 14);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +63,9 @@
|
|||
if (speed >= 185) {
|
||||
const damage = Math.min(18, Math.max(1, (speed - 185) / 22));
|
||||
worldRef.applyImpactDamage?.(t, damage, "\u885d\u7a81", { source: item }) || t.damage?.(damage, "\u885d\u7a81");
|
||||
if (t.dead && item.achievementBurstOwnerId === t.id && item.achievementBurstNeverStopped !== false) {
|
||||
globalThis.TarinaiAchievements?.recordSelfZunchiDeath?.({ world: worldRef, tarinai: t, zunchi: item, speed });
|
||||
}
|
||||
}
|
||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
|
||||
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
|
||||
|
|
|
|||
|
|
@ -171,7 +171,10 @@
|
|||
const max = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100);
|
||||
const scale = Math.max(0.35, Number(context.scale ?? 1) || 1);
|
||||
const amount = Math.max(18, Math.round(max * 0.38 * scale));
|
||||
const before = Number(tarinai.energy || 0) || 0;
|
||||
tarinai.recoverHealth?.(amount);
|
||||
const recovered = Math.max(0, (Number(tarinai.energy || 0) || 0) - before);
|
||||
if (recovered > 0.0001) global.TarinaiAchievements?.recordFirstAidRecovery?.({ world: tarinai.world, tarinai, item: context.item || null, source: context.source || "", recovered });
|
||||
tarinai.hpBarTimer = Math.max(tarinai.hpBarTimer || 0, 2.4);
|
||||
tarinai.spawnPowerItemEffect?.("first_aid", 0.42);
|
||||
tarinai.bubble?.("HP+", 2.2, "rgba(210,72,82,0.82)", { force: true });
|
||||
|
|
|
|||
|
|
@ -873,7 +873,7 @@ 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) + t * 10.5;
|
||||
const spin = (this.fanSpin || 0) + (window.TarinaiSignalSystem?.powerOverride?.(this) === false ? 0 : t * 10.5);
|
||||
ctx.save();
|
||||
ctx.rotate(windAngle);
|
||||
const windRange = 320;
|
||||
|
|
@ -1234,7 +1234,8 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.strokeStyle = active ? "#9b6512" : "#69717a"; ctx.lineWidth=3;
|
||||
ctx.beginPath(); ctx.roundRect?.(-27,-18,54,36,8); if (!ctx.roundRect) { ctx.rect(-27,-18,54,36); } ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = active ? "#fff6c9" : "#7b838d"; ctx.beginPath(); ctx.arc(0,-3,9,0,Math.PI*2); ctx.fill();
|
||||
const detectorLabel = this.pressureTarget === "time" ? "\u6642\u523b" : `${this.pressureTarget === "item" ? "\u7269" : "\u305f"}${this.pressureThreshold || 1}`;
|
||||
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)}`;
|
||||
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") {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
observe: { id: "observe", label: "\u305f\u308a\u306a\u3044\u30c7\u30fc\u30bf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" },
|
||||
clear_tool: { id: "clear_tool", label: "\u89e3\u9664", placeable: false, scalable: false, iconText: "\u2715", tooltip: "\u9078\u629e\u4e2d\u306e\u9053\u5177\u30dc\u30bf\u30f3\u3092\u89e3\u9664\u3057\u3001\u901a\u5e38\u306e\u89b3\u5bdf\u72b6\u614b\u306b\u623b\u3059\u3002" },
|
||||
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
|
||||
area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, iconText: "\u25a6", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" },
|
||||
area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, iconText: "\u2327", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" },
|
||||
copy: { id: "copy", label: "\u30b3\u30d4\u30fc", placeable: false, scalable: false, iconText: "\u29c9", tooltip: "\u7f6e\u3044\u305f\u30a2\u30a4\u30c6\u30e0\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u30b3\u30d4\u30fc\u3057\u3001\u6b21\u306e\u30af\u30ea\u30c3\u30af\u3067\u8a2d\u5b9a\u3054\u3068\u8cbc\u308a\u4ed8\u3051\u308b\u3002" },
|
||||
undo: { id: "undo", label: "\u623b\u3059", placeable: false, scalable: false, iconText: "\u21b6", tooltip: "\u76f4\u524d\u306e\u64cd\u4f5c\u3092\u53d6\u308a\u6d88\u3059\u3002" },
|
||||
redo: { id: "redo", label: "\u9032\u3080", placeable: false, scalable: false, iconText: "\u21b7", tooltip: "\u53d6\u308a\u6d88\u3057\u305f\u64cd\u4f5c\u3092\u3084\u308a\u76f4\u3059\u3002" },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
// Layer: data/item-type-catalog
|
||||
// Builds the current save item-type table from live tool, structure, and link definitions.
|
||||
// The item-type table remains build-defined. Schema 36 includes the current mechanical and signal tool ordering.
|
||||
// The item-type table is build-defined; removed item types are not retained for compatibility.
|
||||
(function (global) {
|
||||
const DomainIds = global.TarinaiDomainIds;
|
||||
if (!DomainIds) throw new Error("TarinaiDomainIds must be loaded before item_type_catalog.js");
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
|
||||
if (type === "pressure_switch") {
|
||||
item.pressureTarget = "tarinai";
|
||||
item.pressureComparator = "gte";
|
||||
item.pressureThreshold = 1;
|
||||
item.pressureWidth = 220;
|
||||
item.pressureHeight = 160;
|
||||
|
|
@ -268,6 +269,7 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
item.amount = 999;
|
||||
}
|
||||
|
||||
|
||||
if (type === "reciprocator") {
|
||||
item.physicsBody = {
|
||||
type: "reciprocator",
|
||||
|
|
|
|||
|
|
@ -111,8 +111,11 @@
|
|||
function runRealtime(worldRef, state, dt) {
|
||||
let ran = 0;
|
||||
const now = worldRef.time || 0;
|
||||
const nextRealtime = [];
|
||||
for (const item of state.realtime || []) {
|
||||
const realtime = state.realtime || [];
|
||||
let write = 0;
|
||||
const len = realtime.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const item = realtime[i];
|
||||
if (!item || item.dead) continue;
|
||||
const interval = itemUpdateInterval(item);
|
||||
if (!Number.isFinite(interval)) {
|
||||
|
|
@ -123,13 +126,14 @@
|
|||
item._schedulerLastAt = now;
|
||||
ran += 1;
|
||||
const nextInterval = itemUpdateInterval(item);
|
||||
if (Number.isFinite(nextInterval) && nextInterval <= 0) nextRealtime.push(item);
|
||||
if (Number.isFinite(nextInterval) && nextInterval <= 0) realtime[write++] = item;
|
||||
else scheduleItem(state, item, now, nextInterval);
|
||||
} else {
|
||||
scheduleItem(state, item, now, interval);
|
||||
}
|
||||
}
|
||||
state.realtime = nextRealtime;
|
||||
realtime.length = write;
|
||||
state.realtime = realtime;
|
||||
worldRef._itemUpdateRealtime = state.realtime;
|
||||
return ran;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,6 +324,13 @@
|
|||
return 0;
|
||||
}
|
||||
|
||||
function motionLevel(item, dt = 0.016) {
|
||||
if (!item || item.dead || !isMechanicalType(item)) return 0;
|
||||
const speed = Math.abs(signedDrive(item));
|
||||
if (item.type === "rotator" || item.type === "poison_block") return speed * Math.max(48, extent(item));
|
||||
return speed;
|
||||
}
|
||||
|
||||
function pointVelocity(item, x, y) {
|
||||
if (!item) return { x: 0, y: 0 };
|
||||
if (item.type === "rotator" || item.type === "poison_block") {
|
||||
|
|
@ -1383,8 +1390,8 @@
|
|||
function isMechanicallyActive(item) {
|
||||
if (!item || item.dead || !isMechanicalType(item)) return false;
|
||||
if (item.playerHeld || item._heldByPlayer) return false;
|
||||
if (item.type === "rotator") return isPowered(item) || Math.abs(num(bodyVelocity(item)?.angular, ps(item, "spin", 0))) > 0.004;
|
||||
if (item.type === "reciprocator") return isPowered(item) || Math.abs(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0))) > 0.08;
|
||||
if (item.type === "rotator") return motionLevel(item) > 0.55;
|
||||
if (item.type === "reciprocator") return motionLevel(item) > 0.08;
|
||||
if (item.type === "poison_block") return passiveItemAwake(item);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1396,10 +1403,17 @@
|
|||
const cacheKey = `${worldRef.itemBucketRebuildsTotal || 0}:${maxItems}`;
|
||||
const cached = worldRef._mechanicalBodiesCache;
|
||||
if (cached?.key === cacheKey && Array.isArray(cached.items)) {
|
||||
return cached.items.filter(item => item && !item.dead);
|
||||
let write = 0;
|
||||
for (let read = 0; read < cached.items.length; read += 1) {
|
||||
const item = cached.items[read];
|
||||
if (item && !item.dead) cached.items[write++] = item;
|
||||
}
|
||||
cached.items.length = write;
|
||||
return cached.items;
|
||||
}
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const seen = worldRef._mechanicalBodiesSeen || (worldRef._mechanicalBodiesSeen = new Set());
|
||||
seen.clear();
|
||||
const add = (item) => {
|
||||
if (!item || item.dead || seen.has(item)) return false;
|
||||
item.world = worldRef;
|
||||
|
|
@ -1425,7 +1439,7 @@
|
|||
}
|
||||
if (out.length >= maxItems) break;
|
||||
}
|
||||
worldRef._mechanicalBodiesCache = { key: cacheKey, items: out.slice() };
|
||||
worldRef._mechanicalBodiesCache = { key: cacheKey, items: out };
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -1473,14 +1487,17 @@
|
|||
|
||||
function buildMechanicalFrame(worldRef, opts = {}) {
|
||||
const bodies = Array.isArray(opts.bodies) ? opts.bodies : collectMechanicalBodies(worldRef, opts);
|
||||
const activeSet = opts.activeSet || new Set();
|
||||
const activeSet = opts.activeSet || (worldRef?._mechanicalActiveSet || (worldRef._mechanicalActiveSet = new Set()));
|
||||
if (!opts.activeSet) activeSet.clear();
|
||||
if (!opts.activeSet) {
|
||||
for (const item of bodies) if (isMechanicallyActive(item)) activeSet.add(item);
|
||||
}
|
||||
const focus = opts.focus !== false && worldRef?.nearbyObstacles && bodies.length > Math.max(36, Number(opts.focusThreshold || 48) || 48) && activeSet.size > 0 && activeSet.size < bodies.length * 0.65;
|
||||
let source = bodies;
|
||||
if (focus) {
|
||||
const chosen = new Set(activeSet);
|
||||
const chosen = worldRef._mechanicalFocusedSet || (worldRef._mechanicalFocusedSet = new Set());
|
||||
chosen.clear();
|
||||
for (const item of activeSet) chosen.add(item);
|
||||
for (const item of activeSet) {
|
||||
const radius = Math.max(90, reach(item) + 128);
|
||||
const candidates = worldRef.nearbyObstacles?.(item.x || 0, item.y || 0, radius, false) || [];
|
||||
|
|
@ -1489,11 +1506,15 @@
|
|||
chosen.add(other);
|
||||
}
|
||||
}
|
||||
source = Array.from(chosen);
|
||||
source = worldRef._mechanicalFocusedSource || (worldRef._mechanicalFocusedSource = []);
|
||||
source.length = 0;
|
||||
for (const item of chosen) source.push(item);
|
||||
}
|
||||
const pad = Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6;
|
||||
const records = [];
|
||||
const byItem = new Map();
|
||||
const records = worldRef?._mechanicalFrameRecords || (worldRef._mechanicalFrameRecords = []);
|
||||
const byItem = worldRef?._mechanicalFrameByItem || (worldRef._mechanicalFrameByItem = new Map());
|
||||
records.length = 0;
|
||||
byItem.clear();
|
||||
let active = activeSet.size;
|
||||
let collidable = 0;
|
||||
for (const item of source) {
|
||||
|
|
@ -1527,7 +1548,9 @@
|
|||
// bodies plus nearby mechanical neighbors instead of scanning every static
|
||||
// collider in the field.
|
||||
if (!worldRef?.nearbyObstacles || all.length <= minUseFocused || active.length > Math.max(10, all.length * 0.45)) return all;
|
||||
const chosen = new Set(active.map(r => r.item));
|
||||
const chosen = worldRef._mechanicalBroadphaseChosen || (worldRef._mechanicalBroadphaseChosen = new Set());
|
||||
chosen.clear();
|
||||
for (const r of active) chosen.add(r.item);
|
||||
const mech = global.TarinaiMechanicalSystem;
|
||||
for (const r of active) {
|
||||
const item = r.item;
|
||||
|
|
@ -1538,7 +1561,8 @@
|
|||
chosen.add(other);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
const out = worldRef._mechanicalBroadphaseRecords || (worldRef._mechanicalBroadphaseRecords = []);
|
||||
out.length = 0;
|
||||
for (const item of chosen) {
|
||||
const cached = frame.byItem.get(item);
|
||||
const rec = cached || makeBodyRecord(item, frame.activeSet, Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6);
|
||||
|
|
@ -1547,9 +1571,10 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function buildColliderGrid(records, opts = {}) {
|
||||
function buildColliderGrid(records, opts = {}, gridScratch = null) {
|
||||
const cellSize = Math.max(96, Number(opts.cellSize || 156) || 156);
|
||||
const grid = new Map();
|
||||
const grid = gridScratch || new Map();
|
||||
grid.clear();
|
||||
const maxCellsPerBody = Math.max(4, Number(opts.maxCellsPerBody || 64) || 64);
|
||||
let inserted = 0;
|
||||
for (const rec of records || []) {
|
||||
|
|
@ -1607,12 +1632,13 @@
|
|||
if (!frame || frame.collidable < 2 || frame.active <= 0) return { solved: 0, checked: 0, pairs: 0, candidates: 0, gridCells: 0 };
|
||||
const records = Array.isArray(opts.records) ? opts.records : broadphaseCandidateRecords(worldRef, frame, opts);
|
||||
if (records.length < 2) return { solved: 0, checked: 0, pairs: 0, candidates: records.length, gridCells: 0 };
|
||||
const gridInfo = buildColliderGrid(records, opts);
|
||||
const gridInfo = buildColliderGrid(records, opts, worldRef._mechanicalColliderGrid || (worldRef._mechanicalColliderGrid = new Map()));
|
||||
const grid = gridInfo.grid;
|
||||
const seen = Object.create(null);
|
||||
const maxPairs = Math.max(24, Number(opts.maxPairs || 240) || 240);
|
||||
const maxCandidates = maxPairs >= 100000000 ? Number.POSITIVE_INFINITY : Math.max(maxPairs + 48, Math.min(900, maxPairs * 3));
|
||||
const pairCandidates = [];
|
||||
const pairCandidates = worldRef._mechanicalPairCandidates || (worldRef._mechanicalPairCandidates = []);
|
||||
pairCandidates.length = 0;
|
||||
let pairs = 0;
|
||||
for (const bucket of grid.values()) {
|
||||
for (let i = 0; i < bucket.length; i += 1) {
|
||||
|
|
@ -1797,7 +1823,7 @@
|
|||
const a = itemAngle(item);
|
||||
const pose = bodyPose(item);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || (stableUnit(item.id || item.seed || "rotator", "poke-side") < 0.5 ? -1 : 1);
|
||||
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 + side * 0.32, -1.6, 1.6), "pair-spin-response");
|
||||
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 - side * 0.32, -1.6, 1.6), "pair-spin-response");
|
||||
commitBody(item);
|
||||
worldRef?.markSpatialDirty?.("poke-passive-rotator");
|
||||
return true;
|
||||
|
|
@ -1806,7 +1832,7 @@
|
|||
const a = axis(item);
|
||||
const pose = bodyPose(item);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || (stableUnit(item.id || item.seed || "reciprocator", "poke-side") < 0.5 ? -1 : 1);
|
||||
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 + side * 34, -115, 115), "pair-slide-response");
|
||||
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 - side * 34, -115, 115), "pair-slide-response");
|
||||
commitBody(item);
|
||||
worldRef?.markSpatialDirty?.("poke-passive-reciprocator");
|
||||
return true;
|
||||
|
|
@ -1827,6 +1853,8 @@
|
|||
poisonHazardRects,
|
||||
extent,
|
||||
reach,
|
||||
signedDrive,
|
||||
motionLevel,
|
||||
resetAnchor,
|
||||
axis,
|
||||
railAxisAngle,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@
|
|||
simulationThrottled: false,
|
||||
creatureCadenceScale: 0,
|
||||
creatureBackgroundBudget: 1000000000,
|
||||
pressureCreatureFullBudget: 1000000000,
|
||||
pressureCreatureMotionBudget: 1000000000,
|
||||
pressureObstacleContactBudget: 1000000000,
|
||||
collisionSourceBudget: 1000000000,
|
||||
collisionImpactBudget: 1000000000,
|
||||
itemCadenceScale: 0,
|
||||
scheduledItemBudget: 1000000000,
|
||||
aiBudget: 1000000000,
|
||||
|
|
@ -61,6 +66,11 @@
|
|||
simulationThrottled: true,
|
||||
creatureCadenceScale: 1,
|
||||
creatureBackgroundBudget: 36,
|
||||
pressureCreatureFullBudget: 36,
|
||||
pressureCreatureMotionBudget: 58,
|
||||
pressureObstacleContactBudget: 14,
|
||||
collisionSourceBudget: 36,
|
||||
collisionImpactBudget: 20,
|
||||
itemCadenceScale: 1,
|
||||
scheduledItemBudget: 30,
|
||||
aiBudget: 10,
|
||||
|
|
@ -89,6 +99,11 @@
|
|||
simulationThrottled: true,
|
||||
creatureCadenceScale: 1.55,
|
||||
creatureBackgroundBudget: 16,
|
||||
pressureCreatureFullBudget: 18,
|
||||
pressureCreatureMotionBudget: 30,
|
||||
pressureObstacleContactBudget: 7,
|
||||
collisionSourceBudget: 18,
|
||||
collisionImpactBudget: 10,
|
||||
itemCadenceScale: 1.55,
|
||||
scheduledItemBudget: 14,
|
||||
aiBudget: 6,
|
||||
|
|
|
|||
|
|
@ -385,13 +385,16 @@
|
|||
}
|
||||
|
||||
function collectPhysicsItems(worldRef, opts = {}) {
|
||||
const bodies = [];
|
||||
const constraints = [];
|
||||
if (!worldRef?.itemsOfType) return { bodies, constraints };
|
||||
if (!worldRef?.itemsOfType) return { bodies: [], constraints: [] };
|
||||
const bodies = worldRef._physicsBodiesScratch || (worldRef._physicsBodiesScratch = []);
|
||||
const constraints = worldRef._physicsConstraintsScratch || (worldRef._physicsConstraintsScratch = []);
|
||||
bodies.length = 0;
|
||||
constraints.length = 0;
|
||||
worldRef.ensureItemBuckets?.("physics-world-system");
|
||||
const maxBodies = Math.max(1, Number(opts.maxItems || Infinity) || Infinity);
|
||||
const maxConstraints = Math.max(0, Number(opts.maxLinks || Infinity) || Infinity);
|
||||
const bodyCandidates = [];
|
||||
const bodyCandidates = worldRef._physicsBodyCandidatesScratch || (worldRef._physicsBodyCandidatesScratch = []);
|
||||
bodyCandidates.length = 0;
|
||||
for (const type of BODY_TYPES) {
|
||||
for (const item of worldRef.itemsOfType(type) || []) {
|
||||
if (!item || item.dead) continue;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,28 @@
|
|||
function toolSizeName(worldRef, type) {
|
||||
return worldRef?.toolSizeFor ? worldRef.toolSizeFor(type) : (worldRef?.toolSize || "medium");
|
||||
}
|
||||
function pickMode(worldRef) {
|
||||
const value = String(worldRef?.toolPickMode || "free").toLowerCase();
|
||||
if (value === "grid") return "grid40"; // compatibility with v39.16.34
|
||||
return new Set(["grid20", "grid40", "grid60"]).has(value) ? value : "free";
|
||||
}
|
||||
function gridStep(worldRef) {
|
||||
const mode = pickMode(worldRef);
|
||||
if (mode === "grid20") return 20;
|
||||
if (mode === "grid60") return 60;
|
||||
if (mode === "grid40") return 40;
|
||||
return 0;
|
||||
}
|
||||
function snapPoint(worldRef, x = 0, y = 0) {
|
||||
const step = gridStep(worldRef);
|
||||
if (!step) return { x: num(x), y: num(y), snapped: false, step: 0 };
|
||||
return {
|
||||
x: Math.round(num(x) / step) * step,
|
||||
y: Math.round(num(y) / step) * step,
|
||||
snapped: true,
|
||||
step,
|
||||
};
|
||||
}
|
||||
function isRotatable(type) {
|
||||
return global.isRotatableItemType(type);
|
||||
}
|
||||
|
|
@ -31,13 +53,13 @@
|
|||
if (!worldRef || !rect) return true;
|
||||
const pad = global.CONFIG?.worldPadding || 30;
|
||||
const fence = global.isFenceItemType?.(itemType);
|
||||
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < (fence ? 0 : pad) || num(rect.bottom) > (worldRef.h || 0) - (fence ? 0 : pad);
|
||||
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < 0 || num(rect.bottom) > (worldRef.h || 0) - (fence ? 0 : pad);
|
||||
}
|
||||
function circleOutside(worldRef, x, y, radius) {
|
||||
if (!worldRef) return true;
|
||||
const pad = global.CONFIG?.worldPadding || 30;
|
||||
const r = Math.max(0, num(radius));
|
||||
return num(x) - r < pad || num(y) - r < pad || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0) - pad;
|
||||
return num(x) - r < pad || num(y) - r < 0 || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0) - pad;
|
||||
}
|
||||
function orientedAabb(rect) {
|
||||
if (!rect) return null;
|
||||
|
|
@ -145,14 +167,18 @@
|
|||
bottom = Math.max(bottom, num(aabb.bottom) - num(item.y));
|
||||
}
|
||||
const fence = global.isFenceItemType?.(item.type);
|
||||
const yPad = fence ? 0 : pad;
|
||||
return { x: global.clamp ? global.clamp(x, pad + left, (worldRef.w || 0) - pad - right) : Math.max(pad + left, Math.min((worldRef.w || 0) - pad - right, x)), y: global.clamp ? global.clamp(y, yPad + top, (worldRef.h || 0) - yPad - bottom) : Math.max(yPad + top, Math.min((worldRef.h || 0) - yPad - bottom, y)) };
|
||||
const bottomPad = fence ? 0 : pad;
|
||||
return { x: global.clamp ? global.clamp(x, pad + left, (worldRef.w || 0) - pad - right) : Math.max(pad + left, Math.min((worldRef.w || 0) - pad - right, x)), y: global.clamp ? global.clamp(y, top, (worldRef.h || 0) - bottomPad - bottom) : Math.max(top, Math.min((worldRef.h || 0) - bottomPad - bottom, y)) };
|
||||
}
|
||||
const radius = Math.max(14, (num(item.r, itemRadius(item.type, 12)) || itemRadius(item.type, 12)) * (item.type === "bed" ? 1.55 : 1.25));
|
||||
return { x: global.clamp ? global.clamp(x, pad + radius, (worldRef.w || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.w || 0) - pad - radius, x)), y: global.clamp ? global.clamp(y, pad + radius, (worldRef.h || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.h || 0) - pad - radius, y)) };
|
||||
return { x: global.clamp ? global.clamp(x, pad + radius, (worldRef.w || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.w || 0) - pad - radius, x)), y: global.clamp ? global.clamp(y, radius, (worldRef.h || 0) - pad - radius) : Math.max(radius, Math.min((worldRef.h || 0) - pad - radius, y)) };
|
||||
}
|
||||
function overlayForItem(item) {
|
||||
if (!item || item.dead) return null;
|
||||
if (item.type === "toilet") {
|
||||
const footprint = global.TarinaiToiletSandSystem?.footprint?.(item) || null;
|
||||
if (footprint) return { shape: "roundedRect", ...footprint };
|
||||
}
|
||||
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 };
|
||||
|
|
@ -191,7 +217,8 @@
|
|||
const p = worldRef?.pointer;
|
||||
const type = toolTypeFor(worldRef);
|
||||
if (!p?.inside || !type) return null;
|
||||
const x = num(p.x), y = num(p.y);
|
||||
const snapped = snapPoint(worldRef, p.x, p.y);
|
||||
const x = num(snapped.x), y = num(snapped.y);
|
||||
const tmp = makePreviewItem(worldRef, type, x, y);
|
||||
if (!tmp) return null;
|
||||
const r = tmp.r;
|
||||
|
|
@ -220,5 +247,8 @@
|
|||
clampPointFor,
|
||||
forItem: previewForItem,
|
||||
forWorld: previewForWorld,
|
||||
gridStep,
|
||||
pickMode,
|
||||
snapPoint,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
89
js/render.js
89
js/render.js
|
|
@ -178,8 +178,8 @@ function strokeWorldObstacleRect(ctx, rect) {
|
|||
function drawToolTargetHoverHighlight(ctx, world) {
|
||||
if (!world) return;
|
||||
const tool = String(world.tool || "");
|
||||
const mode = world.hoverGiveTarget ? "give" : (tool === "pinch" ? "pinch" : (tool === "delete" ? "delete" : ""));
|
||||
const target = mode === "give" ? world.hoverGiveTarget : (mode === "pinch" ? world.hoverGrabTarget : (mode === "delete" ? world.hoverDeleteTarget : null));
|
||||
const mode = world.hoverGiveTarget ? "give" : (tool === "poke" ? "poke" : (tool === "pinch" ? "pinch" : (tool === "delete" ? "delete" : "")));
|
||||
const target = mode === "give" ? world.hoverGiveTarget : (mode === "poke" ? world.hoverPokeTarget : (mode === "pinch" ? world.hoverGrabTarget : (mode === "delete" ? world.hoverDeleteTarget : null)));
|
||||
if (!target || target.dead || target.playerHeld || target._heldByPlayer) return;
|
||||
const p = world.pointer || {};
|
||||
if (!p.inside) return;
|
||||
|
|
@ -187,7 +187,9 @@ function drawToolTargetHoverHighlight(ctx, world) {
|
|||
? { color: "rgba(224,98,142,0.98)", fill: "rgba(224,98,142,0.14)", label: "\u4e0e\u3048\u308b" }
|
||||
: (mode === "delete"
|
||||
? { color: "rgba(224,82,68,0.98)", fill: "rgba(224,82,68,0.13)", label: "\u524a\u9664" }
|
||||
: { color: "rgba(86,142,232,0.98)", fill: "rgba(86,142,232,0.12)", label: "\u3064\u307e\u3080" });
|
||||
: (mode === "poke"
|
||||
? { color: "rgba(224,132,72,0.98)", fill: "rgba(224,132,72,0.13)", label: "\u3064\u3064\u304f" }
|
||||
: { color: "rgba(86,142,232,0.98)", fill: "rgba(86,142,232,0.12)", label: "\u3064\u307e\u3080" }));
|
||||
const pulse = 0.62 + Math.sin((world.time || 0) * 9.5) * 0.12;
|
||||
const worldLine = world.screenSizeToWorld ? world.screenSizeToWorld(4.6) : 4.6;
|
||||
const outerLine = world.screenSizeToWorld ? world.screenSizeToWorld(2.1) : 2.1;
|
||||
|
|
@ -522,6 +524,14 @@ function drawPlacementPreview(ctx, world) {
|
|||
ctx.ellipse(overlay.centerX, overlay.centerY, Math.max(1, overlay.radiusX), Math.max(1, overlay.radiusY), 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else if (overlay?.shape === "roundedRect") {
|
||||
ctx.save();
|
||||
ctx.translate(overlay.centerX, overlay.centerY);
|
||||
ctx.rotate(overlay.angle || 0);
|
||||
roundedRect(ctx, -overlay.halfWidth, -overlay.halfHeight, overlay.halfWidth * 2, overlay.halfHeight * 2, overlay.cornerRadius || 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
} else if (rect || (rects && rects.length)) {
|
||||
if (rects && rects.length) for (const rr of rects) drawRectPreview(rr);
|
||||
else drawRectPreview(rect);
|
||||
|
|
@ -575,8 +585,8 @@ function drawPlacementPreview(ctx, world) {
|
|||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
const markerX = overlay?.shape === "ellipse" ? overlay.centerX : x;
|
||||
const markerY = overlay?.shape === "ellipse" ? overlay.centerY : y;
|
||||
const markerX = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerX : x;
|
||||
const markerY = (overlay?.shape === "ellipse" || overlay?.shape === "roundedRect") ? overlay.centerY : y;
|
||||
ctx.globalAlpha = blocked ? 0.52 : 0.70;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(markerX - 8, markerY);
|
||||
|
|
@ -597,7 +607,7 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
: (world.items || []).filter(item => item && !item.dead && item.type === "pressure_switch");
|
||||
|
||||
for (const item of switches) {
|
||||
if (!item || item.dead || item.pressureTarget === "time") continue;
|
||||
if (!item || item.dead || item.pressureTarget === "time" || item.pressureTarget === "temperature") continue;
|
||||
const isEditing = editing?.item === item;
|
||||
specs.push({
|
||||
x: Number(item.x || 0) || 0,
|
||||
|
|
@ -606,6 +616,7 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
height: Math.max(60, Math.min(840, Number(isEditing ? editing.height : item.pressureHeight || 160) || 160)),
|
||||
active: Boolean(item.signalActive),
|
||||
preview: false,
|
||||
shape: "rect",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -617,6 +628,7 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
height: 160,
|
||||
active: false,
|
||||
preview: true,
|
||||
shape: "rect",
|
||||
});
|
||||
}
|
||||
if (!specs.length) return;
|
||||
|
|
@ -631,7 +643,8 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
ctx.lineWidth = Math.max(1.2, line);
|
||||
ctx.setLineDash(spec.preview ? [10, 7] : [8, 8]);
|
||||
ctx.beginPath();
|
||||
ctx.rect(spec.x - spec.width * 0.5, spec.y - spec.height * 0.5, spec.width, spec.height);
|
||||
if (spec.shape === "circle") ctx.arc(spec.x, spec.y, spec.radius, 0, Math.PI * 2);
|
||||
else ctx.rect(spec.x - spec.width * 0.5, spec.y - spec.height * 0.5, spec.width, spec.height);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
|
@ -681,6 +694,13 @@ function drawOperationToolPreview(ctx, world) {
|
|||
ctx.beginPath();
|
||||
ctx.ellipse(previewOverlay.centerX, previewOverlay.centerY, Math.max(1, previewOverlay.radiusX), Math.max(1, previewOverlay.radiusY), 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
} else if (previewOverlay?.shape === "roundedRect") {
|
||||
ctx.save();
|
||||
ctx.translate(previewOverlay.centerX, previewOverlay.centerY);
|
||||
ctx.rotate(previewOverlay.angle || 0);
|
||||
roundedRect(ctx, -previewOverlay.halfWidth, -previewOverlay.halfHeight, previewOverlay.halfWidth * 2, previewOverlay.halfHeight * 2, previewOverlay.cornerRadius || 8);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
} else if (rects.length) {
|
||||
for (const rr of rects) {
|
||||
if (rr.oriented) {
|
||||
|
|
@ -694,8 +714,8 @@ function drawOperationToolPreview(ctx, world) {
|
|||
ctx.beginPath(); ctx.ellipse(p.x, p.y, r * 1.45, r * 0.95, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
const markerX = previewOverlay?.shape === "ellipse" ? previewOverlay.centerX : p.x;
|
||||
const markerY = previewOverlay?.shape === "ellipse" ? previewOverlay.centerY : p.y;
|
||||
const markerX = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerX : item.x;
|
||||
const markerY = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerY : item.y;
|
||||
ctx.beginPath(); ctx.moveTo(markerX - 9, markerY); ctx.lineTo(markerX + 9, markerY); ctx.moveTo(markerX, markerY - 9); ctx.lineTo(markerX, markerY + 9); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -1058,6 +1078,33 @@ function syncCarriedPlushieToOwner(it, worldRef) {
|
|||
return Math.hypot((it.x || 0) - (ox || 0), (it.y || 0) - (oy || 0)) > 0.25;
|
||||
}
|
||||
|
||||
function resetScratchSet(set) {
|
||||
if (set && typeof set.clear === "function") {
|
||||
set.clear();
|
||||
return set;
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function resetScratchMap(map) {
|
||||
if (map && typeof map.clear === "function") {
|
||||
map.clear();
|
||||
return map;
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function pushRenderLayerEntry(stack, entity) {
|
||||
const pool = stack._layeredPool || (stack._layeredPool = []);
|
||||
const index = stack.layered.length;
|
||||
const entry = pool[index] || (pool[index] = { entity: null, y: 0, rank: 0 });
|
||||
entry.entity = entity;
|
||||
entry.y = renderLayerSortY(entity);
|
||||
entry.rank = renderLayerKindRank(entity);
|
||||
stack.layered.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
function collectVisibleRenderStack(worldRef, visibleRect) {
|
||||
const end = window.TarinaiPerf.begin("render.stackBuild");
|
||||
|
|
@ -1070,7 +1117,7 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
|
|||
stack.lodgedPins.length = 0;
|
||||
if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0;
|
||||
if (stack.champions) stack.champions.length = 0;
|
||||
const seen = new Set();
|
||||
const seen = worldRef._renderVisibleSeen = resetScratchSet(worldRef._renderVisibleSeen);
|
||||
const addItem = (it) => {
|
||||
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
|
||||
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it)) return;
|
||||
|
|
@ -1090,12 +1137,12 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
|
|||
return;
|
||||
}
|
||||
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
|
||||
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
|
||||
else pushRenderLayerEntry(stack, it);
|
||||
};
|
||||
const addLayered = (entity) => {
|
||||
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
|
||||
seen.add(entity);
|
||||
stack.layered.push({ entity, y: renderLayerSortY(entity), rank: renderLayerKindRank(entity) });
|
||||
pushRenderLayerEntry(stack, entity);
|
||||
if (entity.isTarinaiChampion && typeof entity.drawChampionCrown === "function") (stack.champions || (stack.champions = [])).push(entity);
|
||||
};
|
||||
worldRef.ensureSpatial?.("render-visible");
|
||||
|
|
@ -1124,7 +1171,8 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
|
|||
for (const a of antScratch) addLayered(a);
|
||||
if (!Array.isArray(worldRef.carriedPlushies) || (worldRef.time || 0) >= (worldRef._nextCarriedPlushieRefreshAt || 0)) {
|
||||
const plushies = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("plushie") : (worldRef.items || []);
|
||||
worldRef.carriedPlushies = [];
|
||||
worldRef.carriedPlushies = worldRef.carriedPlushies || [];
|
||||
worldRef.carriedPlushies.length = 0;
|
||||
for (const it of plushies || []) if (it && !it.dead && it.isStructure && it.type === "plushie" && it.carriedById) worldRef.carriedPlushies.push(it);
|
||||
worldRef._nextCarriedPlushieRefreshAt = (worldRef.time || 0) + 0.45;
|
||||
}
|
||||
|
|
@ -1647,7 +1695,7 @@ function render() {
|
|||
|
||||
// Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai.
|
||||
|
||||
const drawnAttachedPinIds = new Set();
|
||||
const drawnAttachedPinIds = world._renderDrawnAttachedPinIds = resetScratchSet(world._renderDrawnAttachedPinIds);
|
||||
const drawAttachedPinsForEntity = (entity) => {
|
||||
if (!entity) return;
|
||||
if (TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem.drawAttachedForEntity) {
|
||||
|
|
@ -1672,12 +1720,19 @@ function render() {
|
|||
}
|
||||
};
|
||||
|
||||
const carriedPlushiesByOwner = new Map();
|
||||
const carriedPlushiesByOwner = world._renderCarriedPlushiesByOwner = resetScratchMap(world._renderCarriedPlushiesByOwner);
|
||||
const carriedOwnerLists = world._renderCarriedOwnerLists || (world._renderCarriedOwnerLists = []);
|
||||
let carriedOwnerListCount = 0;
|
||||
for (const it of renderStack.carriedPlushies) {
|
||||
if (!it?.carriedById) continue;
|
||||
const list = carriedPlushiesByOwner.get(it.carriedById) || [];
|
||||
let list = carriedPlushiesByOwner.get(it.carriedById);
|
||||
if (!list) {
|
||||
list = carriedOwnerLists[carriedOwnerListCount] || (carriedOwnerLists[carriedOwnerListCount] = []);
|
||||
carriedOwnerListCount += 1;
|
||||
list.length = 0;
|
||||
carriedPlushiesByOwner.set(it.carriedById, list);
|
||||
}
|
||||
list.push(it);
|
||||
carriedPlushiesByOwner.set(it.carriedById, list);
|
||||
}
|
||||
|
||||
const endLayered = renderPerfBegin("render.draw.layered");
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
|
|||
item.robotCleanerScanTimer = 0;
|
||||
item.robotCleanerCleanFlash = 0.42;
|
||||
item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1;
|
||||
global.TarinaiAchievements?.recordRobotClean?.({ world: worldRef, robot: item, target, type });
|
||||
worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }));
|
||||
worldRef.markItemBucketsDirty?.("robot-cleaner");
|
||||
worldRef.markSpatialDirty?.("robot-cleaner");
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"itemIndex",
|
||||
"normalItems",
|
||||
"physicsItems",
|
||||
"achievementMetadata",
|
||||
]);
|
||||
let lastEncodeStats = null;
|
||||
let lastDecodeStats = null;
|
||||
|
|
@ -1225,7 +1226,7 @@
|
|||
const angle = defaultAngleMilli(type);
|
||||
writeFixedSignedDefaults(writer, list, [angle, 0, angle, Math.round(35 * Math.PI / 180 * 1000), Math.round(48 * Math.PI / 180 * 1000), 0, angle], 7);
|
||||
}
|
||||
else if (type === "pressure_switch") writeFixedSignedDefaults(writer, list, [0, 1, 220, 160], 4);
|
||||
else if (type === "pressure_switch") writeFixedSignedDefaults(writer, list, [0, 0, 10, 220, 160, 360, 1080], 7);
|
||||
else if (isRotatableSaveType(type)) writeFixedSignedDefaults(writer, list, [defaultAngleMilli(type), 0], 2);
|
||||
else if (isServingFoodSaveType(type)) writeFixedSignedDefaults(writer, list, [row?.[3] || 0, row?.[3] || 0], 2);
|
||||
else writeGenericExtra(writer, list);
|
||||
|
|
@ -1252,7 +1253,7 @@
|
|||
const angle = defaultAngleMilli(type);
|
||||
return readFixedSignedDefaults(reader, [angle, 0, angle, Math.round(35 * Math.PI / 180 * 1000), Math.round(48 * Math.PI / 180 * 1000), 0, angle], 7);
|
||||
}
|
||||
if (type === "pressure_switch") return readFixedSignedDefaults(reader, [0, 1, 220, 160], 4);
|
||||
if (type === "pressure_switch") return readFixedSignedDefaults(reader, [0, 0, 10, 220, 160, 360, 1080], 7);
|
||||
if (isRotatableSaveType(type)) return readFixedSignedDefaults(reader, [defaultAngleMilli(type), 0], 2);
|
||||
if (isServingFoodSaveType(type)) return readFixedSignedDefaults(reader, [row?.[3] || 0, row?.[3] || 0], 2);
|
||||
return readGenericExtra(reader);
|
||||
|
|
@ -1489,6 +1490,7 @@
|
|||
tarinaiRows.forEach((row, index) => writer.category("tarinaiBasic", () => writeTarinaiPayload(writer, row || [], tarinaiMetas[index], tarCoordState, worldSeed, index)));
|
||||
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
|
||||
writeItemBlocks(writer, itemRows);
|
||||
writer.category("achievementMetadata", () => writer.str(JSON.stringify(snapshot.g || null)));
|
||||
return writer.out();
|
||||
} finally {
|
||||
activeStringWriteMap = null;
|
||||
|
|
@ -1511,8 +1513,15 @@
|
|||
const tarinaiHeaders = readTarinaiBitPlane(reader, tCount);
|
||||
for (let i = 0; i < tCount; i++) t.push(readTarinaiPayload(reader, tarinaiHeaders[i], tarCoordState, String(w?.[8] || ""), i));
|
||||
const items = readItemBlocks(reader);
|
||||
let g = null;
|
||||
try {
|
||||
const encodedMetadata = reader.str();
|
||||
g = encodedMetadata ? JSON.parse(encodedMetadata) : null;
|
||||
} catch (_) {
|
||||
throw new Error("invalid achievement metadata in binary save");
|
||||
}
|
||||
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
|
||||
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items };
|
||||
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items, g };
|
||||
} finally {
|
||||
activeStringReadList = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: persistence/schema
|
||||
// Current-format identifiers. Schema 35 removes warp manual power and expands gate channels to nine.
|
||||
// Current-format identifiers. Saves are accepted only when their schema exactly matches this build.
|
||||
(function (global) {
|
||||
const ids = global.TarinaiDomainIds;
|
||||
const itemCatalog = global.TarinaiItemTypeCatalog;
|
||||
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js");
|
||||
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
|
||||
|
||||
const SNAPSHOT_VERSION = 30;
|
||||
const BINARY_SCHEMA_VERSION = 36;
|
||||
const SNAPSHOT_VERSION = 35;
|
||||
const BINARY_SCHEMA_VERSION = 41;
|
||||
|
||||
function moodValue(index, fallback = "relaxed") {
|
||||
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@
|
|||
if (!hash.startsWith("\u305f")) throw new Error("unsupported Japanese hash slot value");
|
||||
global.localStorage?.setItem(slotKey(slot), hash);
|
||||
if (meta) global.localStorage?.setItem(metaKey(slot), JSON.stringify({ ...meta, savedAt: Date.now() }));
|
||||
let filled = 0;
|
||||
for (let index = 1; index <= SLOT_COUNT; index += 1) {
|
||||
if (global.localStorage?.getItem(slotKey(index))) filled += 1;
|
||||
}
|
||||
global.TarinaiAchievements?.recordSaveSlotsFilled?.({ slot, filled, slotCount: SLOT_COUNT });
|
||||
}
|
||||
function readMeta(slot) {
|
||||
const raw = global.localStorage?.getItem(metaKey(slot));
|
||||
|
|
|
|||
|
|
@ -53,60 +53,8 @@
|
|||
}
|
||||
|
||||
|
||||
function ensureLoadConfirmDialog() {
|
||||
let confirmDialog = document.getElementById("saveLoadConfirmDialog");
|
||||
if (confirmDialog) return confirmDialog;
|
||||
confirmDialog = document.createElement("div");
|
||||
confirmDialog.id = "saveLoadConfirmDialog";
|
||||
confirmDialog.className = "field-dialog save-confirm-dialog hidden";
|
||||
confirmDialog.setAttribute("role", "alertdialog");
|
||||
confirmDialog.setAttribute("aria-modal", "true");
|
||||
confirmDialog.setAttribute("aria-labelledby", "saveLoadConfirmTitle");
|
||||
confirmDialog.setAttribute("aria-describedby", "saveLoadConfirmMessage");
|
||||
confirmDialog.innerHTML = `
|
||||
<div class="field-dialog-panel save-confirm-panel">
|
||||
<h2 id="saveLoadConfirmTitle">\u30ed\u30fc\u30c9\u524d\u306e\u78ba\u8a8d</h2>
|
||||
<p id="saveLoadConfirmMessage" class="field-dialog-lead save-confirm-message"></p>
|
||||
<p class="hint save-warning">\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002\u5fc5\u8981\u3067\u3042\u308c\u3070\u5148\u306b\u30bb\u30fc\u30d6\u3057\u3066\u304f\u3060\u3055\u3044\u3002</p>
|
||||
<div class="field-dialog-actions">
|
||||
<button class="btn" data-save-load-cancel type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
||||
<button class="btn primary" data-save-load-confirm type="button">\u30ed\u30fc\u30c9\u3059\u308b</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(confirmDialog);
|
||||
return confirmDialog;
|
||||
}
|
||||
|
||||
function requestLoadConfirmation(message) {
|
||||
const confirmDialog = ensureLoadConfirmDialog();
|
||||
const messageEl = confirmDialog.querySelector("#saveLoadConfirmMessage");
|
||||
if (messageEl) messageEl.textContent = String(message || "\u4fdd\u5b58\u3057\u305f\u72b6\u614b\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002");
|
||||
confirmDialog.classList.remove("hidden");
|
||||
const confirmBtn = confirmDialog.querySelector("[data-save-load-confirm]");
|
||||
const cancelBtn = confirmDialog.querySelector("[data-save-load-cancel]");
|
||||
queueMicrotask(() => confirmBtn?.focus?.());
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (accepted) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
confirmDialog.classList.add("hidden");
|
||||
confirmDialog.removeEventListener("click", onClick);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
resolve(Boolean(accepted));
|
||||
};
|
||||
const onClick = (event) => {
|
||||
if (event.target === confirmDialog || event.target.closest("[data-save-load-cancel]")) finish(false);
|
||||
else if (event.target.closest("[data-save-load-confirm]")) finish(true);
|
||||
};
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); finish(false); }
|
||||
else if (event.key === "Enter" && document.activeElement === confirmBtn) { event.preventDefault(); finish(true); }
|
||||
};
|
||||
confirmDialog.addEventListener("click", onClick);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
cancelBtn?.setAttribute?.("aria-label", "\u30ed\u30fc\u30c9\u3092\u30ad\u30e3\u30f3\u30bb\u30eb");
|
||||
});
|
||||
function requestConfirmation(options = {}) {
|
||||
return global.TarinaiGameDialogs?.confirm?.(options) || Promise.resolve(false);
|
||||
}
|
||||
|
||||
function ensureDialog() {
|
||||
|
|
@ -199,7 +147,11 @@
|
|||
}
|
||||
if (loadBtn) {
|
||||
const slot = Number(loadBtn.dataset.loadSlot);
|
||||
if (!await requestLoadConfirmation(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002`)) return;
|
||||
if (!await requestConfirmation({
|
||||
title: "\u30ed\u30fc\u30c9\u524d\u306e\u78ba\u8a8d",
|
||||
message: `\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`,
|
||||
confirmText: "\u30ed\u30fc\u30c9\u3059\u308b",
|
||||
})) return;
|
||||
try {
|
||||
await loadSlot(slot);
|
||||
closeDialog();
|
||||
|
|
@ -213,7 +165,12 @@
|
|||
}
|
||||
if (deleteBtn) {
|
||||
const slot = Number(deleteBtn.dataset.deleteSlot);
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u6d88\u3057\u307e\u3059\u304b\uff1f`)) return;
|
||||
if (!await requestConfirmation({
|
||||
title: "\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u306e\u524a\u9664",
|
||||
message: `\u30b9\u30ed\u30c3\u30c8${slot}\u306e\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u524a\u9664\u3057\u307e\u3059\u3002`,
|
||||
confirmText: "\u524a\u9664\u3059\u308b",
|
||||
danger: true,
|
||||
})) return;
|
||||
deleteSlot(slot);
|
||||
renderSlotList();
|
||||
global.audio?.delete?.();
|
||||
|
|
@ -255,7 +212,11 @@
|
|||
const area = dialog.querySelector("#saveHashText");
|
||||
const text = area?.value || "";
|
||||
if (!text.trim()) { global.showToast?.("\u30ed\u30fc\u30c9\u3059\u308b\u546a\u6587\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
|
||||
if (!await requestLoadConfirmation("\u8cbc\u308a\u4ed8\u3051\u305f\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002")) return;
|
||||
if (!await requestConfirmation({
|
||||
title: "\u546a\u6587\u306e\u30ed\u30fc\u30c9",
|
||||
message: "\u8cbc\u308a\u4ed8\u3051\u305f\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002",
|
||||
confirmText: "\u30ed\u30fc\u30c9\u3059\u308b",
|
||||
})) return;
|
||||
try {
|
||||
restoreSnapshot(await decodeSnapshot(text), global.world);
|
||||
closeDialog();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,35 @@
|
|||
(function (global) {
|
||||
const WIRE_TYPES = new Set(["wire", "insulated_wire"]);
|
||||
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
||||
const SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence"]);
|
||||
const SIGNAL_SOURCE_TYPES = new Set(["pressure_switch"]);
|
||||
const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence"]);
|
||||
const DETECTOR_METRICS = Object.freeze({
|
||||
tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 },
|
||||
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 },
|
||||
stress_avg: { label: "\u5e73\u5747\u30b9\u30c8\u30ec\u30b9", defaultThreshold: 70 },
|
||||
sleep_avg: { label: "\u5e73\u5747\u7761\u7720\u6b32", defaultThreshold: 70 },
|
||||
low_hp_count: { label: "\u4f4eHP\u500b\u4f53\u6570", defaultThreshold: 1 },
|
||||
sick_count: { label: "\u75c5\u6c17\u500b\u4f53\u6570", defaultThreshold: 1 },
|
||||
temperature: { label: "\u6c17\u6e29", defaultThreshold: 15 },
|
||||
zunchi_count: { label: "\u305a\u3093\u3061\u6570", defaultThreshold: 1 },
|
||||
ant_count: { label: "\u30a2\u30ea\u6570", defaultThreshold: 1 },
|
||||
water_count: { label: "\u6c34\u6ef4\u6570", defaultThreshold: 1 },
|
||||
});
|
||||
const PRESSURE_TARGETS = Object.freeze({
|
||||
tarinai: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1, spatial: true },
|
||||
item: { label: "\u30a2\u30a4\u30c6\u30e0\u6570", defaultThreshold: 1, spatial: true },
|
||||
time: { label: "\u6642\u523b", defaultThreshold: 0, spatial: false },
|
||||
hunger_avg: DETECTOR_METRICS.hunger_avg,
|
||||
stress_avg: DETECTOR_METRICS.stress_avg,
|
||||
sleep_avg: DETECTOR_METRICS.sleep_avg,
|
||||
low_hp_count: DETECTOR_METRICS.low_hp_count,
|
||||
sick_count: DETECTOR_METRICS.sick_count,
|
||||
temperature: { ...DETECTOR_METRICS.temperature, spatial: false },
|
||||
zunchi_count: DETECTOR_METRICS.zunchi_count,
|
||||
ant_count: DETECTOR_METRICS.ant_count,
|
||||
water_count: DETECTOR_METRICS.water_count,
|
||||
});
|
||||
|
||||
|
||||
function num(value, fallback = 0) {
|
||||
const n = Number(value);
|
||||
|
|
@ -29,8 +57,9 @@
|
|||
return from < to ? now >= from && now < to : now >= from || now < to;
|
||||
}
|
||||
function normalizePressureTarget(value) {
|
||||
return value === "time" ? "time" : (value === "item" ? "item" : "tarinai");
|
||||
return PRESSURE_TARGETS[value] ? value : "tarinai";
|
||||
}
|
||||
function normalizeComparator(value) { return value === "lte" ? "lte" : "gte"; }
|
||||
function isWire(item) { return Boolean(item && !item.dead && WIRE_TYPES.has(item.type)); }
|
||||
|
||||
function emitEffect(worldRef, type, x, y, options = {}) {
|
||||
|
|
@ -67,32 +96,48 @@
|
|||
|| (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null);
|
||||
}
|
||||
|
||||
function countPressureTargets(item, worldRef) {
|
||||
function insideRect(item, target, width, height) {
|
||||
const tr = Math.max(0, num(target?.r ?? target?.radius));
|
||||
return Math.abs(num(target?.x) - num(item.x)) <= width * 0.5 + tr
|
||||
&& Math.abs(num(target?.y) - num(item.y)) <= height * 0.5 + tr;
|
||||
}
|
||||
|
||||
function detectorTarinai(item, worldRef, width, height) {
|
||||
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
|
||||
return (worldRef.nearbyTarinai?.(item.x, item.y, radius, true) || worldRef.tarinai || [])
|
||||
.filter(t => t && !t.dead && !worldRef.isTarinaiHiddenInNestBox?.(t) && insideRect(item, t, width, height));
|
||||
}
|
||||
function detectorItems(item, worldRef, width, height, type = "") {
|
||||
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
|
||||
return (worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items || [])
|
||||
.filter(it => it && !it.dead && it !== item && !LINK_TYPES.has(it.type)
|
||||
&& (!type || it.type === type) && insideRect(item, it, width, height));
|
||||
}
|
||||
function average(list, valueFor) {
|
||||
if (!list.length) return 0;
|
||||
return list.reduce((sum, value) => sum + num(valueFor(value)), 0) / list.length;
|
||||
}
|
||||
function detectorMetricValue(item, worldRef) {
|
||||
const width = clamp(item.pressureWidth || 220, 60, 840);
|
||||
const height = clamp(item.pressureHeight || 160, 60, 840);
|
||||
const halfW = width * 0.5;
|
||||
const halfH = height * 0.5;
|
||||
const radius = Math.hypot(halfW, halfH) + 48;
|
||||
const inside = target => {
|
||||
const tr = Math.max(0, num(target?.r ?? target?.radius));
|
||||
return Math.abs(num(target?.x) - num(item.x)) <= halfW + tr && Math.abs(num(target?.y) - num(item.y)) <= halfH + tr;
|
||||
};
|
||||
if (item.pressureTarget === "item") {
|
||||
const nearby = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items || [];
|
||||
let count = 0;
|
||||
for (const target of nearby) {
|
||||
if (!target || target.dead || target === item || LINK_TYPES.has(target.type)) continue;
|
||||
if (inside(target)) count += 1;
|
||||
}
|
||||
return count;
|
||||
const target = normalizePressureTarget(item.pressureTarget);
|
||||
if (target === "temperature") return num(worldRef.temperatureAt?.(item.x, item.y, null), global.CONFIG?.standardTemperature ?? 15);
|
||||
if (target === "ant_count") {
|
||||
const radius = Math.hypot(width * 0.5, height * 0.5) + 32;
|
||||
return (worldRef.nearbyAnts?.(item.x, item.y, radius, true) || worldRef.ants || [])
|
||||
.filter(a => a && !a.dead && insideRect(item, a, width, height)).length;
|
||||
}
|
||||
const nearby = worldRef.nearbyTarinai?.(item.x, item.y, radius, true) || worldRef.tarinai || [];
|
||||
let count = 0;
|
||||
for (const target of nearby) {
|
||||
if (!target || target.dead || worldRef.isTarinaiHiddenInNestBox?.(target)) continue;
|
||||
if (inside(target)) count += 1;
|
||||
}
|
||||
return count;
|
||||
if (target === "item") return detectorItems(item, worldRef, width, height).length;
|
||||
if (target === "zunchi_count") return detectorItems(item, worldRef, width, height, "zunchi").length;
|
||||
if (target === "water_count") return detectorItems(item, worldRef, width, height, "water").length;
|
||||
const actors = detectorTarinai(item, worldRef, width, height);
|
||||
if (target === "tarinai") return actors.length;
|
||||
if (target === "hunger_avg") return average(actors, t => t.hunger);
|
||||
if (target === "stress_avg") return average(actors, t => t.stress);
|
||||
if (target === "sleep_avg") return average(actors, t => t.needs?.sleep ?? t.needRaw?.sleep ?? t.sleepPressure);
|
||||
if (target === "low_hp_count") return actors.filter(t => num(t.hp, num(t.maxHp, 100)) / Math.max(1, num(t.maxHp, 100)) <= 0.35).length;
|
||||
if (target === "sick_count") return actors.filter(t => t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease).length;
|
||||
return actors.length;
|
||||
}
|
||||
|
||||
function updatePressureSwitches(worldRef) {
|
||||
|
|
@ -100,27 +145,51 @@
|
|||
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
|
||||
if (!item || item.dead) continue;
|
||||
item.pressureTarget = normalizePressureTarget(item.pressureTarget);
|
||||
item.pressureThreshold = Math.max(1, Math.min(999, Number(item.pressureThreshold || 1) | 0));
|
||||
item.pressureComparator = normalizeComparator(item.pressureComparator);
|
||||
const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1;
|
||||
item.pressureThreshold = clamp(num(item.pressureThreshold, defaultThreshold), -999, 999);
|
||||
item.pressureWidth = clamp(item.pressureWidth || 220, 60, 840);
|
||||
item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840);
|
||||
item.pressureTimeStart = normalizeMinute(item.pressureTimeStart, 360);
|
||||
item.pressureTimeEnd = normalizeMinute(item.pressureTimeEnd, 1080);
|
||||
let count = 0;
|
||||
let value = 0;
|
||||
let active = false;
|
||||
if (item.pressureTarget === "time") {
|
||||
active = isMinuteInRange(minute, item.pressureTimeStart, item.pressureTimeEnd);
|
||||
count = active ? 1 : 0;
|
||||
value = active ? 1 : 0;
|
||||
} else {
|
||||
count = countPressureTargets(item, worldRef);
|
||||
active = count >= item.pressureThreshold;
|
||||
value = detectorMetricValue(item, worldRef);
|
||||
active = item.pressureComparator === "lte" ? value <= item.pressureThreshold : value >= item.pressureThreshold;
|
||||
}
|
||||
if (active !== Boolean(item.signalActive)) worldRef.drawListDirty = true;
|
||||
item.pressureCount = count;
|
||||
item.pressureCount = value;
|
||||
item.pressureValue = value;
|
||||
item.pressureCurrentMinute = minute;
|
||||
item.signalActive = active;
|
||||
}
|
||||
}
|
||||
|
||||
function setEffectivePower(node, directDriven, directOn, worldRef) {
|
||||
const wasOn = Boolean(node?._achievementSignalWasOn);
|
||||
node._directSignalDriven = Boolean(directDriven);
|
||||
node._directSignalOn = Boolean(directDriven && directOn);
|
||||
node._achievementSignalWasOn = node._directSignalOn;
|
||||
if (!wasOn && node._directSignalOn) {
|
||||
global.TarinaiAchievements?.recordSignalActivation?.({ world: worldRef, target: node, type: node.type });
|
||||
if (node.type === "robot_cleaner") global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot: node, source: "signal-power" });
|
||||
}
|
||||
node._signalDriven = node._directSignalDriven;
|
||||
node._signalOn = node._directSignalOn;
|
||||
if (node.type === "gate_fence" && node._signalDriven) {
|
||||
const nextOpen = Boolean(node._signalOn);
|
||||
if (Boolean(node.gateOpen) !== nextOpen) {
|
||||
node.gateOpen = nextOpen;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("signal-gate-toggle");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSignalNetwork(worldRef) {
|
||||
const adjacency = new Map();
|
||||
const connected = new Set();
|
||||
|
|
@ -133,6 +202,13 @@
|
|||
adjacency.get(a).add(b); adjacency.get(b).add(a);
|
||||
connected.add(a); connected.add(b);
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (const wire of worldRef.items || []) {
|
||||
if (!isWire(wire)) continue;
|
||||
const pb = global.TarinaiPhysicsBodySystem;
|
||||
|
|
@ -165,33 +241,27 @@
|
|||
const node = stack.pop();
|
||||
if (!node || visited.has(node)) continue;
|
||||
visited.add(node); component.push(node);
|
||||
if (node.type === "pressure_switch" && node.signalActive) active = true;
|
||||
if (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) active = true;
|
||||
for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next);
|
||||
}
|
||||
const hasSource = component.some(node => node.type === "pressure_switch");
|
||||
const hasSource = component.some(node => SIGNAL_SOURCE_TYPES.has(node.type));
|
||||
const componentSet = new Set(component);
|
||||
for (const node of component) {
|
||||
if (!SIGNAL_SINK_TYPES.has(node.type)) continue;
|
||||
node._signalDriven = hasSource;
|
||||
node._signalOn = hasSource && active;
|
||||
if (node.type === "gate_fence" && hasSource) {
|
||||
const nextOpen = Boolean(active);
|
||||
if (Boolean(node.gateOpen) !== nextOpen) {
|
||||
node.gateOpen = nextOpen;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("signal-gate-toggle");
|
||||
}
|
||||
}
|
||||
if (!DIRECT_SIGNAL_SINK_TYPES.has(node.type)) continue;
|
||||
node._wireSignalDriven = hasSource;
|
||||
node._wireSignalOn = hasSource && active;
|
||||
setEffectivePower(node, hasSource, active, worldRef);
|
||||
}
|
||||
for (const entry of wires) {
|
||||
if ((entry.a && component.includes(entry.a)) || (entry.b && component.includes(entry.b))) entry.wire.signalActive = hasSource && active;
|
||||
if ((entry.a && componentSet.has(entry.a)) || (entry.b && componentSet.has(entry.b))) entry.wire.signalActive = hasSource && active;
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of worldRef.items || []) {
|
||||
if (!item || item.dead) continue;
|
||||
if (SIGNAL_SINK_TYPES.has(item.type) && !connected.has(item)) {
|
||||
item._signalDriven = false;
|
||||
item._signalOn = false;
|
||||
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !connected.has(item)) {
|
||||
item._wireSignalDriven = false; item._wireSignalOn = false;
|
||||
setEffectivePower(item, false, false, worldRef);
|
||||
}
|
||||
if (isWire(item) && !wires.some(entry => entry.wire === item)) item.signalActive = false;
|
||||
}
|
||||
|
|
@ -216,6 +286,7 @@
|
|||
wire._shockCooldowns.set(target, now + 0.38);
|
||||
const isAnt = target.kind === "worker" || target.kind === "queen" || (target.r && !target.radius && Number.isFinite(Number(target.hp)));
|
||||
if (isAnt) {
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(target, wire, { world: worldRef, reason: "electric_wire" });
|
||||
target.hp = Math.max(0, num(target.hp, target.maxHp || 32) - 2.4);
|
||||
target.hpBarTimer = Math.max(num(target.hpBarTimer), 0.85);
|
||||
target.state = "panic";
|
||||
|
|
@ -287,5 +358,9 @@
|
|||
isWire,
|
||||
isMinuteInRange,
|
||||
currentMinuteOfDay,
|
||||
SENSOR_METRICS: DETECTOR_METRICS,
|
||||
DETECTOR_METRICS,
|
||||
PRESSURE_TARGETS,
|
||||
detectorMetricValue,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,80 @@
|
|||
return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().creatureBackgroundBudget || 36));
|
||||
}
|
||||
|
||||
function profileNumber(name, fallback) {
|
||||
const value = Number(global.TarinaiPerf?.performanceProfile?.()[name]);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function resetMechanicalCrowdPressure(worldRef) {
|
||||
const pressure = worldRef._mechanicalCrowdPressure || (worldRef._mechanicalCrowdPressure = { ids: new Set(), sources: [], active: false, count: 0, mechanical: 0, frame: 0 });
|
||||
pressure.ids.clear();
|
||||
pressure.sources.length = 0;
|
||||
pressure.active = false;
|
||||
pressure.count = 0;
|
||||
pressure.mechanical = 0;
|
||||
pressure.detailedContacts = 0;
|
||||
pressure.degradedContacts = 0;
|
||||
pressure.skippedContacts = 0;
|
||||
pressure.contactBudget = 0;
|
||||
pressure.frame = (pressure.frame || 0) + 1;
|
||||
pressure.profile = global.TarinaiPerf?.simulationOptimizationTier?.() || "balanced";
|
||||
return pressure;
|
||||
}
|
||||
|
||||
function hasMechanicalPressureSources(worldRef) {
|
||||
const counts = worldRef?.itemCounts || {};
|
||||
return Boolean((counts.rotator || 0) + (counts.reciprocator || 0) + (counts.poison_block || 0));
|
||||
}
|
||||
|
||||
function mechanicalItemActive(item, worldRef) {
|
||||
if (!item || item.dead) return false;
|
||||
const pb = global.TarinaiPhysicsBodySystem;
|
||||
const body = pb?.ensureBody?.(item, worldRef, { syncFromLegacy: false }) || item.physicsBody || null;
|
||||
const velocity = body?.velocity || {};
|
||||
const motor = body?.motor || {};
|
||||
const mech = global.TarinaiMechanicalSystem;
|
||||
if (item.type === "rotator") return Number(mech?.motionLevel?.(item) || 0) > 0.55;
|
||||
if (item.type === "reciprocator") {
|
||||
const drive = (motor.powered !== false ? Math.max(0, Number(motor.speed || item.railMotorSpeed || 0) || 0) : 0) + Math.abs(Number(velocity.linear || item.slideSpeed || 0) || 0);
|
||||
return drive > 0.08;
|
||||
}
|
||||
if (item.type === "poison_block") return Boolean(global.TarinaiMechanicalSystem?.passiveItemAwake?.(item));
|
||||
return false;
|
||||
}
|
||||
|
||||
function prepareMechanicalCrowdPressure(worldRef, visibleRect) {
|
||||
const pressure = resetMechanicalCrowdPressure(worldRef);
|
||||
if (!hasMechanicalPressureSources(worldRef)) return pressure;
|
||||
pressure.contactBudget = Math.max(1, profileNumber("pressureObstacleContactBudget", 10));
|
||||
if (!worldRef?.itemsOfType || !worldRef?.nearbyTarinai) return pressure;
|
||||
const mech = global.TarinaiMechanicalSystem;
|
||||
if (!mech?.reach) return pressure;
|
||||
const types = ["rotator", "reciprocator", "poison_block"];
|
||||
let checked = 0;
|
||||
for (const type of types) {
|
||||
for (const item of worldRef.itemsOfType(type) || []) {
|
||||
if (!item || item.dead || !mechanicalItemActive(item, worldRef)) continue;
|
||||
checked += 1;
|
||||
pressure.mechanical += 1;
|
||||
pressure.sources.push(item);
|
||||
const radius = Math.max(120, (mech.reach(item) || item.r || 80) + 140);
|
||||
const candidates = worldRef.nearbyTarinai(item.x || 0, item.y || 0, radius, true) || [];
|
||||
for (const t of candidates) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
if (visibleRect && !global.TarinaiGeometry?.pointInRect?.(t.x || 0, t.y || 0, visibleRect)) continue;
|
||||
pressure.ids.add(t.id || t.familyKey || t);
|
||||
}
|
||||
if (checked >= 8) break;
|
||||
}
|
||||
if (checked >= 8) break;
|
||||
}
|
||||
pressure.count = pressure.ids.size;
|
||||
pressure.active = pressure.count >= 10;
|
||||
pressure.intensity = Math.min(1, pressure.count / 44);
|
||||
return pressure;
|
||||
}
|
||||
|
||||
function consumesBackgroundBudget(cadence) {
|
||||
if (!cadence || cadence.urgent || cadence.visible) return false;
|
||||
return true;
|
||||
|
|
@ -27,6 +101,10 @@
|
|||
const visibleRect = h.updateVisibleWorldRect(worldRef, 160);
|
||||
const nearVisibleRect = h.updateVisibleWorldRect(worldRef, 520);
|
||||
const now = worldRef.time || 0;
|
||||
const pressure = prepareMechanicalCrowdPressure(worldRef, visibleRect);
|
||||
const pressureActive = Boolean(pressure?.active);
|
||||
const visibleFullBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000;
|
||||
const visibleMotionBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureMotionBudget", 36)) : 1000000000;
|
||||
const stats = {
|
||||
total: 0,
|
||||
full: 0,
|
||||
|
|
@ -47,6 +125,18 @@
|
|||
fullBudget: backgroundFullBudgetFor(worldRef),
|
||||
fullBudgetUsed: 0,
|
||||
fullBudgetSkips: 0,
|
||||
visibleFullBudget,
|
||||
visibleFullUsed: 0,
|
||||
visibleFullSkips: 0,
|
||||
visibleMotionBudget,
|
||||
visibleMotionUsed: 0,
|
||||
visibleMotionSkips: 0,
|
||||
mechanicalPressure: pressure?.count || 0,
|
||||
mechanicalPressureActive: pressureActive,
|
||||
mechanicalContactBudget: pressure?.contactBudget || 0,
|
||||
mechanicalDetailedContacts: pressure?.detailedContacts || 0,
|
||||
mechanicalDegradedContacts: pressure?.degradedContacts || 0,
|
||||
mechanicalSkippedContacts: pressure?.skippedContacts || 0,
|
||||
offscreenSkipped: 0,
|
||||
};
|
||||
const prevSpatialDeferMode = worldRef.deferSpatialRebuildMode || "";
|
||||
|
|
@ -91,6 +181,14 @@
|
|||
t._nextLowFreqUpdateAt = now + interval * (0.65 + jitter * 0.70);
|
||||
}
|
||||
if (now >= t._nextLowFreqUpdateAt) {
|
||||
if (cadence.visible && stats.visibleFullUsed >= stats.visibleFullBudget) {
|
||||
const jitter = stableUnit(t.id || t.familyKey || Math.random(), `visible-creature-budget:${Math.floor(now * 5)}`);
|
||||
t._nextLowFreqUpdateAt = now + Math.max(dt, interval * (0.20 + jitter * 0.25));
|
||||
stats.visibleFullSkips += 1;
|
||||
stats.skipped += 1;
|
||||
inc(stats.skippedStates, behaviorId);
|
||||
continue;
|
||||
}
|
||||
if (usesBackgroundBudget && stats.fullBudgetUsed >= stats.fullBudget) {
|
||||
const jitter = stableUnit(t.id || t.familyKey || Math.random(), `creature-budget:${Math.floor(now * 4)}`);
|
||||
t._nextLowFreqUpdateAt = now + Math.max(dt, interval * (0.18 + jitter * 0.18));
|
||||
|
|
@ -105,11 +203,19 @@
|
|||
t._updateAccum = 0;
|
||||
t._nextLowFreqUpdateAt = now + interval * (0.92 + jitter * 0.22);
|
||||
stats.full += 1;
|
||||
if (cadence.visible) stats.visibleFullUsed += 1;
|
||||
if (usesBackgroundBudget) stats.fullBudgetUsed += 1;
|
||||
global.TarinaiCreatureRuntime.updateOne(t, runDt, { context });
|
||||
} else if (cadence.smoothMotion) {
|
||||
if (cadence.visible && stats.visibleMotionUsed >= stats.visibleMotionBudget) {
|
||||
stats.visibleMotionSkips += 1;
|
||||
stats.skipped += 1;
|
||||
inc(stats.skippedStates, behaviorId);
|
||||
continue;
|
||||
}
|
||||
if (global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) {
|
||||
stats.smooth += 1;
|
||||
if (cadence.visible) stats.visibleMotionUsed += 1;
|
||||
inc(stats.smoothStates, behaviorId);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -120,6 +226,9 @@
|
|||
} finally {
|
||||
worldRef.deferSpatialRebuildDepth = Math.max(0, (worldRef.deferSpatialRebuildDepth || 1) - 1);
|
||||
worldRef.deferSpatialRebuildMode = prevSpatialDeferMode;
|
||||
stats.mechanicalDetailedContacts = pressure?.detailedContacts || 0;
|
||||
stats.mechanicalDegradedContacts = pressure?.degradedContacts || 0;
|
||||
stats.mechanicalSkippedContacts = pressure?.skippedContacts || 0;
|
||||
worldRef._creatureUpdateStats = stats;
|
||||
if (end) end();
|
||||
}
|
||||
|
|
@ -154,7 +263,15 @@
|
|||
if (fastTarinai?.length) {
|
||||
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
|
||||
const endCollision = global.TarinaiPerf.begin("update.tarinai.collisions") || null;
|
||||
try { worldRef.resolveTarinaiHighSpeedCollisions(dt, { fastSources: fastTarinai, threshold: collisionSpeedThreshold }); }
|
||||
try {
|
||||
const profile = global.TarinaiPerf?.performanceProfile?.() || {};
|
||||
worldRef.resolveTarinaiHighSpeedCollisions(dt, {
|
||||
fastSources: fastTarinai,
|
||||
threshold: collisionSpeedThreshold,
|
||||
maxSources: profile.collisionSourceBudget,
|
||||
maxImpacts: profile.collisionImpactBudget,
|
||||
});
|
||||
}
|
||||
finally { if (endCollision) endCollision(); }
|
||||
} else {
|
||||
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@
|
|||
q(worldRef?.birthSerial, 1, 0),
|
||||
q(worldRef?.tarinaiPopulationLimit, 1, 0),
|
||||
q(worldRef?.objectLimit, 1, 0),
|
||||
[...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
|
||||
[...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
|
||||
q(worldRef?.achievementPlayerPlacementCount, 1, 0),
|
||||
q(worldRef?.achievementLastInterventionAt, 10, 0),
|
||||
q(worldRef?.achievementNoDeathStartAt, 10, -10),
|
||||
q(worldRef?.achievementDirectFeedCount, 1, 0),
|
||||
];
|
||||
}
|
||||
function applyCompactWorld(worldRef, arr = []) {
|
||||
|
|
@ -193,6 +199,12 @@
|
|||
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
|
||||
worldRef.tarinaiPopulationLimit = Math.max(0, u(arr[10], 1, 0));
|
||||
worldRef.objectLimit = Math.max(0, u(arr[11], 1, 0));
|
||||
worldRef.achievementNaturalSlaveGenerations = Array.isArray(arr[12]) ? [...new Set(arr[12].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [];
|
||||
worldRef.achievementNaturalKingGenerations = Array.isArray(arr[13]) ? [...new Set(arr[13].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [];
|
||||
worldRef.achievementPlayerPlacementCount = Math.max(0, u(arr[14], 1, 0));
|
||||
worldRef.achievementLastInterventionAt = Math.max(0, u(arr[15], 10, totalTime));
|
||||
worldRef.achievementNoDeathStartAt = u(arr[16], 10, -1);
|
||||
worldRef.achievementDirectFeedCount = Math.max(0, u(arr[17], 1, 0));
|
||||
worldRef.day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
|
||||
worldRef.time = totalTime - (worldRef.day - 1) * dayLength;
|
||||
worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||||
|
|
@ -205,6 +217,68 @@
|
|||
worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || { id: moodId, label: moodId, effects: { personality: {} } };
|
||||
worldRef.setGroundType?.(enumValue(GROUND_IDS, arr[1], "soil"), { silent: true, force: true });
|
||||
}
|
||||
function finiteOrNull(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function compactAchievementMetadata(worldRef, tarinaiLive = [], itemRecords = []) {
|
||||
return {
|
||||
w: [
|
||||
[...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
|
||||
[...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
|
||||
Math.max(0, Math.floor(Number(worldRef?.achievementDirectFeedCount || 0) || 0)),
|
||||
Math.max(0, Math.floor(Number(worldRef?.achievementRobotCleanCount || 0) || 0)),
|
||||
Math.max(0, Math.floor(Number(worldRef?.achievementEnemyAntKills || 0) || 0)),
|
||||
finiteOrNull(worldRef?.achievementLastTarinaiDamageAt),
|
||||
finiteOrNull(worldRef?.achievementSelfSufficientStartAt),
|
||||
finiteOrNull(worldRef?.achievementLastDirectFeedAt),
|
||||
finiteOrNull(worldRef?.achievementLastInterventionAt),
|
||||
finiteOrNull(worldRef?.achievementNoDeathStartAt),
|
||||
finiteOrNull(worldRef?.achievementHappyStartAt),
|
||||
],
|
||||
t: tarinaiLive.map(t => [
|
||||
Math.max(0, Math.floor(Number(t?._achievementDirectFeedCount || 0) || 0)),
|
||||
Math.max(0, Math.floor(Number(t?._achievementDirectTreatmentCount || 0) || 0)),
|
||||
finiteOrNull(t?._achievementFightMochiAt),
|
||||
finiteOrNull(t?._achievementSaunaHotAt),
|
||||
]),
|
||||
i: itemRecords.map(rec => [
|
||||
rec?.item?._achievementPlayerPlaced ? 1 : 0,
|
||||
finiteOrNull(rec?.item?._achievementPlacedAt),
|
||||
]),
|
||||
};
|
||||
}
|
||||
function applyAchievementMetadata(worldRef, metadata = null) {
|
||||
if (!worldRef || !metadata || typeof metadata !== "object") return false;
|
||||
const w = Array.isArray(metadata.w) ? metadata.w : [];
|
||||
worldRef.achievementNaturalSlaveGenerations = Array.isArray(w[0]) ? [...new Set(w[0].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : (worldRef.achievementNaturalSlaveGenerations || []);
|
||||
worldRef.achievementNaturalKingGenerations = Array.isArray(w[1]) ? [...new Set(w[1].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : (worldRef.achievementNaturalKingGenerations || []);
|
||||
worldRef.achievementDirectFeedCount = Math.max(0, Math.floor(Number(w[2] || 0) || 0));
|
||||
worldRef.achievementRobotCleanCount = Math.max(0, Math.floor(Number(w[3] || 0) || 0));
|
||||
worldRef.achievementEnemyAntKills = Math.max(0, Math.floor(Number(w[4] || 0) || 0));
|
||||
worldRef.achievementLastTarinaiDamageAt = w[5] != null && Number.isFinite(Number(w[5])) ? Number(w[5]) : -Infinity;
|
||||
worldRef.achievementSelfSufficientStartAt = w[6] != null && Number.isFinite(Number(w[6])) ? Number(w[6]) : -1;
|
||||
worldRef.achievementLastDirectFeedAt = w[7] != null && Number.isFinite(Number(w[7])) ? Number(w[7]) : -Infinity;
|
||||
worldRef.achievementLastInterventionAt = w[8] != null && Number.isFinite(Number(w[8])) ? Number(w[8]) : Math.max(0, Number(worldRef.time || 0) || 0);
|
||||
worldRef.achievementNoDeathStartAt = w[9] != null && Number.isFinite(Number(w[9])) ? Number(w[9]) : -1;
|
||||
worldRef.achievementHappyStartAt = w[10] != null && Number.isFinite(Number(w[10])) ? Number(w[10]) : -1;
|
||||
const tarRows = Array.isArray(metadata.t) ? metadata.t : [];
|
||||
(worldRef.tarinai || []).forEach((t, index) => {
|
||||
const row = Array.isArray(tarRows[index]) ? tarRows[index] : [];
|
||||
t._achievementDirectFeedCount = Math.max(0, Math.floor(Number(row[0] || 0) || 0));
|
||||
t._achievementDirectTreatmentCount = Math.max(0, Math.floor(Number(row[1] || 0) || 0));
|
||||
t._achievementFightMochiAt = row[2] != null && Number.isFinite(Number(row[2])) ? Number(row[2]) : null;
|
||||
t._achievementSaunaHotAt = row[3] != null && Number.isFinite(Number(row[3])) ? Number(row[3]) : null;
|
||||
});
|
||||
const itemRows = Array.isArray(metadata.i) ? metadata.i : [];
|
||||
(worldRef.items || []).forEach((item, index) => {
|
||||
const row = Array.isArray(itemRows[index]) ? itemRows[index] : [];
|
||||
item._achievementPlayerPlaced = Boolean(row[0]);
|
||||
item._achievementPlacedAt = row[1] != null && Number.isFinite(Number(row[1])) ? Number(row[1]) : null;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function compactRelationships(t, tarinaiIndex) {
|
||||
const out = [];
|
||||
for (const [id, rel] of Object.entries(t.relationships || {})) {
|
||||
|
|
@ -347,14 +421,16 @@
|
|||
q(item.fanBodyAngle ?? item.angle ?? 0, 1000),
|
||||
];
|
||||
if (type === "pressure_switch") {
|
||||
const targetCode = item.pressureTarget === "time" ? 2 : (item.pressureTarget === "item" ? 1 : 0);
|
||||
const width = Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0));
|
||||
const height = Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0));
|
||||
const start = Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0));
|
||||
const end = Math.max(0, Math.min(1435, Number(item.pressureTimeEnd ?? 1080) | 0));
|
||||
const third = targetCode === 2 ? width * 1440 + start : width;
|
||||
const fourth = targetCode === 2 ? height * 1440 + end : height;
|
||||
return [targetCode, Math.max(1, Math.min(999, Number(item.pressureThreshold || 1) | 0)), third, fourth];
|
||||
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),
|
||||
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)),
|
||||
Math.max(0, Math.min(1435, Number(item.pressureTimeEnd ?? 1080) | 0)),
|
||||
];
|
||||
}
|
||||
if (global.isRotatableItemType(type)) return [q(item.angle, 1000, q(global.defaultItemAngle(type), 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0];
|
||||
if (global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
|
||||
|
|
@ -444,16 +520,14 @@
|
|||
item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0);
|
||||
item.fanBodyAngle = global.normalizedItemAngle(u(extra[6], 1000, item.angle), item.angle);
|
||||
} else if (type === "pressure_switch") {
|
||||
const targetCode = Number(extra[0]) | 0;
|
||||
const rawThird = Number(extra[2]);
|
||||
const rawFourth = Number(extra[3]);
|
||||
const packedTime = targetCode === 2 && rawThird >= 1440 && rawFourth >= 1440;
|
||||
item.pressureTarget = targetCode === 2 ? "time" : (targetCode === 1 ? "item" : "tarinai");
|
||||
item.pressureThreshold = Math.max(1, Math.min(999, Number(extra[1] || 1) | 0));
|
||||
item.pressureWidth = targetCode === 2 ? (packedTime ? Math.max(60, Math.min(840, Math.floor(rawThird / 1440))) : 220) : Math.max(60, Math.min(840, Number(extra[2] || 220) | 0));
|
||||
item.pressureHeight = targetCode === 2 ? (packedTime ? Math.max(60, Math.min(840, Math.floor(rawFourth / 1440))) : 160) : Math.max(60, Math.min(840, Number(extra[3] || 160) | 0));
|
||||
item.pressureTimeStart = targetCode === 2 ? Math.max(0, Math.min(1435, (packedTime ? rawThird % 1440 : rawThird) | 0)) : 360;
|
||||
item.pressureTimeEnd = targetCode === 2 ? Math.max(0, Math.min(1435, (packedTime ? rawFourth % 1440 : rawFourth) | 0)) : 1080;
|
||||
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.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));
|
||||
item.pressureTimeEnd = Math.max(0, Math.min(1435, Number(extra[6] ?? 1080) | 0));
|
||||
item.signalActive = false;
|
||||
} else if (global.isRotatableItemType(type)) {
|
||||
const fallback = global.defaultItemAngle(type);
|
||||
|
|
@ -490,6 +564,7 @@
|
|||
w: compactWorld(worldRef),
|
||||
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
|
||||
i: itemRows,
|
||||
g: compactAchievementMetadata(worldRef, tarinaiLive, itemRecords),
|
||||
};
|
||||
}
|
||||
function restoreSnapshot(snapshot, worldRef = global.world) {
|
||||
|
|
@ -574,6 +649,7 @@
|
|||
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
|
||||
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships; delete t.__savedStateInfo;
|
||||
}
|
||||
applyAchievementMetadata(worldRef, snapshot.g || null);
|
||||
const updateNeedsRuntime = global.TarinaiNeedsRuntime?.updateNeeds;
|
||||
if (typeof updateNeedsRuntime === "function") {
|
||||
for (const t of worldRef.tarinai) updateNeedsRuntime(t, worldRef, 0);
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) {
|
|||
t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012;
|
||||
}
|
||||
if (isSpecial && t.applyParamItemEffect) t.applyParamItemEffect(foodType || target.type, target, { source: "eating", nutrition: eating, silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" });
|
||||
globalThis.TarinaiAchievements?.recordConsumableUse?.({ world, tarinai: t, item: target, type: foodType || target.type, source: "eating" });
|
||||
if (foodType === "sweet") {
|
||||
applyNeedRelief(t, { food: -eating * 0.4, fulfill: -eating * 0.2 });
|
||||
if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const d = contact.distance;
|
|||
tarinai.bubble("\u738B", 2.4, "rgba(166,112,22,0.84)");
|
||||
tarinai.world?.log?.(`${tarinai.name}\u306F${itemLabel}\u306B\u89E6\u308C\u3066\u305F\u308A\u306A\u3044\u738B\u306B\u306A\u3063\u305F\u3002`, "event", { participants: [tarinai], sound: false });
|
||||
}
|
||||
global.TarinaiAchievements?.recordConsumableUse?.({ world: tarinai.world, tarinai, item: it, type: it.type, source: "contact" });
|
||||
global.TarinaiStructureLifecycle?.deleteItem?.(tarinai.world, it, { reason: `consumed-${it.type}`, userReason: `${itemLabel}\u304C\u4F7F\u308F\u308C\u305F`, wake: false, panicOwner: false });
|
||||
tarinai.world?.recordFamily?.(tarinai);
|
||||
tarinai.world?.markFamilyTreeDirty?.(`consume-${it.type}`);
|
||||
|
|
|
|||
|
|
@ -129,8 +129,10 @@
|
|||
applyFoodGiftPersonality(tarinai, nutrition);
|
||||
spawnFoodGiftHearts(tarinai, nutrition);
|
||||
tarinai.bubble?.("\u2661", 2.2, "rgba(190,82,132,0.80)");
|
||||
global.TarinaiAchievements?.recordDirectFeed?.({ world: worldRef, tarinai, item, type, source: options.source || "direct" });
|
||||
} else {
|
||||
tarinai.focusPulseTimer = Math.max(Number(tarinai.focusPulseTimer || 0) || 0, 0.72);
|
||||
global.TarinaiAchievements?.recordDirectCare?.({ world: worldRef, tarinai, item, type, treatment: MEDICINE_TOOL_TYPES.has(type), source: options.source || "direct" });
|
||||
}
|
||||
|
||||
const sourceLabel = options.source === "pinch" ? "\u624b\u6e21\u3057\u3067" : "\u76f4\u63a5";
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
updateZunchiDisease(dt) {
|
||||
this.zunchiStain = clamp(this.zunchiStain || 0, 0, 100);
|
||||
this.zunchiDiseaseCooldown = Math.max(0, (this.zunchiDiseaseCooldown || 0) - dt);
|
||||
const shelteredFromRain = globalThis.TarinaiRainShelterSystem?.isSheltered?.(this.world, this.x, this.y) || false;
|
||||
const shelteredFromRain = globalThis.TarinaiRainShelterSystem?.isSheltered?.(this.world, this.x, this.y, this) || false;
|
||||
if (this.world?.weather === "light_rain" && !shelteredFromRain) {
|
||||
this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100);
|
||||
if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006);
|
||||
|
|
@ -576,8 +576,11 @@
|
|||
if (this.dead) return false;
|
||||
const already = Boolean(this.burning || (this.burnTimer || 0) > 0.02);
|
||||
if (already && !force) return false;
|
||||
const ignitedDuringBirthRitual = !already && Boolean((this.birthRitualTimer || 0) > 0.04 || this.state === "birth_ritual");
|
||||
const text = globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
|
||||
this.burning = true;
|
||||
const dangerSource = source?.roles?.danger ? source : (source?._achievementDangerSource?.roles?.danger ? source._achievementDangerSource : null);
|
||||
if (dangerSource) this._achievementDangerSource = dangerSource;
|
||||
this.burnTimer = Math.max(Number(this.burnTimer || 0) || 0, 5.2 + deterministicRange(this.world, "tarinai-burn-duration", 0, 2.8, this, source || null));
|
||||
this.firePanicTimer = 0;
|
||||
this.lastPanicCause = "burning";
|
||||
|
|
@ -599,6 +602,7 @@
|
|||
this.bubble?.("\u3042\u3061\u3085\u3044\uff01", 1.35, "rgba(214,82,38,0.90)", { force: true });
|
||||
}
|
||||
this.recordChangeCause?.("\u708e\u4e0a", "\u708e\u4e0a");
|
||||
if (ignitedDuringBirthRitual) globalThis.TarinaiAchievements?.recordIgnition?.({ world: this.world, tarinai: this, source });
|
||||
return true;
|
||||
},
|
||||
|
||||
|
|
@ -606,6 +610,7 @@
|
|||
if (!this.burning && !(this.burnTimer > 0)) return false;
|
||||
this.burning = false;
|
||||
this.burnTimer = 0;
|
||||
this._achievementDangerSource = null;
|
||||
this.firePanicTimer = Math.max(this.firePanicTimer || 0, 0.25);
|
||||
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.35);
|
||||
if (reason) this.thought = reason;
|
||||
|
|
@ -642,7 +647,7 @@
|
|||
return;
|
||||
}
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 6.2, safety: dt * 3.0 });
|
||||
if (typeof this.damage === "function") this.damage(dt * 3.8, "\u708e\u4e0a");
|
||||
if (typeof this.damage === "function") this.damage(dt * 3.8, "\u708e\u4e0a", { source: this._achievementDangerSource || null });
|
||||
else this.energy = clamp((this.energy || 0) - dt * 0.12, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
|
||||
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.22);
|
||||
const water = globalThis.TarinaiItemDynamicToolSystem?.contactWaterDrop?.(this.world, this.x, this.y, Math.max(this.radius || 18, 16));
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@
|
|||
|
||||
normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); },
|
||||
|
||||
damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); },
|
||||
damage(amount, reason = "", options = {}) { return HEALTH.applyDamage(this, amount, reason, options); },
|
||||
|
||||
wakeFromDamage(reason = "") {
|
||||
if (this.dead) return false;
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@
|
|||
const push = clamp(speed * 1.7, 180, 620);
|
||||
ant.vx = (ant.vx || 0) + px * push;
|
||||
ant.vy = (ant.vy || 0) + py * push;
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(ant, this, { world: this.world, reason: "tarinai_ammo_collision" });
|
||||
ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? 32) - 4);
|
||||
ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.2);
|
||||
this.world.spawnFallEffect?.(ant.x, ant.y, 0.65);
|
||||
|
|
|
|||
|
|
@ -45,10 +45,17 @@
|
|||
return best;
|
||||
}
|
||||
|
||||
function enclosedShelterFor(worldRef, target = null) {
|
||||
if (!worldRef || !target?.insideNestBoxId) return null;
|
||||
const holder = worldRef.nestBoxById?.(target.insideNestBoxId)
|
||||
|| (worldRef.items || []).find(item => item && !item.dead && item.id === target.insideNestBoxId)
|
||||
|| null;
|
||||
return holder && (holder.type === "nest_box" || holder.type === "pipe") ? holder : null;
|
||||
}
|
||||
function shelterRange(item = null) { return parasolRange(item); }
|
||||
function shelterAt(worldRef, x, y) { return parasolAt(worldRef, x, y); }
|
||||
function isSheltered(worldRef, x, y) { return Boolean(parasolAt(worldRef, x, y)); }
|
||||
function blocksWeatherAt(worldRef, x, y) { return Boolean(parasolAt(worldRef, x, y)); }
|
||||
function shelterAt(worldRef, x, y, target = null) { return parasolAt(worldRef, x, y) || enclosedShelterFor(worldRef, target); }
|
||||
function isSheltered(worldRef, x, y, target = null) { return Boolean(shelterAt(worldRef, x, y, target)); }
|
||||
function blocksWeatherAt(worldRef, x, y, target = null) { return isSheltered(worldRef, x, y, target); }
|
||||
|
||||
function updateBody(dt) {
|
||||
const parent = this.parentToFollow ? this.parentToFollow() : null;
|
||||
|
|
@ -102,7 +109,7 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
const parasolSystem = Object.freeze({ parasolFootprint, parasolRange, parasolContains, parasolAt, shelterRange, shelterAt, isSheltered, blocksWeatherAt });
|
||||
const parasolSystem = Object.freeze({ parasolFootprint, parasolRange, parasolContains, parasolAt, enclosedShelterFor, shelterRange, shelterAt, isSheltered, blocksWeatherAt });
|
||||
global.TarinaiParasolSystem = parasolSystem;
|
||||
global.TarinaiRainShelterSystem = parasolSystem;
|
||||
global.TarinaiLocalEnvironmentSystem = Object.freeze({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ function tarinaiSpriteReversesFacing(sprite) {
|
|||
if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.02 || (tarinai.pokeFlashTimer || 0) > 0.03) return true;
|
||||
if ((tarinai.focusPulseTimer || 0) > 0.04 || tarinai.state === "cursor_friend" || tarinai.state === "cursor_enemy") return true;
|
||||
const tier = window.TarinaiPerf.renderQualityTier() || "high";
|
||||
if (tier === "low" && worldRef._mechanicalCrowdPressure?.active) return false;
|
||||
if (tier === "low") return false;
|
||||
if (tier === "mid") {
|
||||
const p = worldRef.pointer;
|
||||
|
|
@ -615,11 +616,11 @@ function tarinaiSpriteReversesFacing(sprite) {
|
|||
};
|
||||
if (stressActive) {
|
||||
const pct = clamp((this.stress || 0) / 130, 0, 1);
|
||||
drawMeter(stressY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1));
|
||||
drawMeter(stressY, pct, "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1));
|
||||
}
|
||||
if (hpActive) {
|
||||
const pct = typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp(this.energy / Math.max(1, this.maxEnergy || 100), 0, 1);
|
||||
drawMeter(hpY, pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1));
|
||||
drawMeter(hpY, pct, "rgba(118,190,80,0.88)", clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,10 @@
|
|||
if (typeof applyNeedShock === "function") applyNeedShock(o, { safety: 12, health: 6 });
|
||||
const damageToThis = fightHeadbuttDamage(o, this, this.world, "to-this");
|
||||
const damageToOther = fightHeadbuttDamage(this, o, this.world, "to-other");
|
||||
this._achievementLastFightAttackerId = o.id;
|
||||
this._achievementLastFightDamageAt = this.world.time || 0;
|
||||
o._achievementLastFightAttackerId = this.id;
|
||||
o._achievementLastFightDamageAt = this.world.time || 0;
|
||||
this.damage(damageToThis, "\u55a7\u5629");
|
||||
o.damage(damageToOther, "\u55a7\u5629");
|
||||
this.world.maybeDefeatFromFightDamage?.(this, o, damageToThis);
|
||||
|
|
@ -567,7 +571,10 @@
|
|||
const aggression = Math.max(0, this.currentPersonality?.aggression || 0);
|
||||
if ((this.world.time || 0) >= (this.nextAntAttackAt || 0)) {
|
||||
this.nextAntAttackAt = (this.world.time || 0) + 0.34;
|
||||
if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (this.outgoingDamage ? this.outgoingDamage(4.6 + aggression * 3.4) : (4.6 + aggression * 3.4)));
|
||||
if (Number.isFinite(this.target.hp)) {
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(this.target, this, { world: this.world, reason: "tarinai_attack" });
|
||||
this.target.hp = Math.max(0, this.target.hp - (this.outgoingDamage ? this.outgoingDamage(4.6 + aggression * 3.4) : (4.6 + aggression * 3.4)));
|
||||
}
|
||||
this.fearTimer = Math.max(this.fearTimer, 0.12);
|
||||
this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
|
||||
if (deterministicChance(this.world, "ant-attack-bubble", 0.55, this, this.target)) this.bubble("!", 0.8, "rgba(145,68,52,0.76)");
|
||||
|
|
@ -698,6 +705,7 @@
|
|||
this.dead = true;
|
||||
this.deathReason = finalReason;
|
||||
this.world.deadCount += 1;
|
||||
globalThis.TarinaiAchievements?.recordDeath?.({ world: this.world, tarinai: this, reason: finalReason, fromDamage: Boolean(opts.fromDamage), achievementDangerKill: Boolean(opts.achievementDangerKill) });
|
||||
this.world.lastDeathAt = this.world.time || 0;
|
||||
if (!Array.isArray(this.world.recentDeathTimes)) this.world.recentDeathTimes = [];
|
||||
this.world.recentDeathTimes.push(this.world.lastDeathAt);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
|
||||
function activeMechanicalObstacleNear(tarinai, worldRef = null) {
|
||||
if (!tarinai || !worldRef || worldRef.isTarinaiHiddenInNestBox?.(tarinai)) return false;
|
||||
const pressure = worldRef._mechanicalCrowdPressure;
|
||||
if (pressure?.active && pressure.ids) {
|
||||
return pressure.ids.has(tarinai.id || tarinai.familyKey || tarinai);
|
||||
}
|
||||
const radius = Math.max(150, (Number(tarinai.radius) || 22) + 190);
|
||||
const source = worldRef.nearbyItems?.(tarinai.x || 0, tarinai.y || 0, radius, true) || worldRef.items || [];
|
||||
for (const it of source) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
t.stressBarTimer = Math.max(0, (t.stressBarTimer || 0) - dt);
|
||||
t.updateZunchiDisease(dt);
|
||||
t.updateSpecialDiseases(dt);
|
||||
t._achievementLaxativeActiveThisFrame = Boolean(t.hasItemEffect?.("laxative"));
|
||||
if (t.updateBurning) t.updateBurning(dt);
|
||||
if (t.updateItemEffects) t.updateItemEffects(dt);
|
||||
if (t.hasItemEffect?.("sedative")) {
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
t.sunbathCooldown = Math.max(0, (t.sunbathCooldown || 0) - dt);
|
||||
if (t.freezeSleepDiseaseMotion(dt)) {
|
||||
t.checkLife(dt);
|
||||
if (!t.dead) t._achievementLaxativeActiveThisFrame = false;
|
||||
return { done: true };
|
||||
}
|
||||
global.TarinaiCursorCareSystem.updateOne(t, dt);
|
||||
|
|
@ -200,7 +202,7 @@
|
|||
t.vx *= Math.pow(0.996, dt * 60);
|
||||
t.vy *= Math.pow(0.996, dt * 60);
|
||||
} else if (t.world.weather === "light_rain") {
|
||||
const shelteredFromRain = global.TarinaiRainShelterSystem?.isSheltered?.(t.world, t.x, t.y) || false;
|
||||
const shelteredFromRain = global.TarinaiRainShelterSystem?.isSheltered?.(t.world, t.x, t.y, t) || false;
|
||||
if (!shelteredFromRain) {
|
||||
t.energy += dt * 0.018;
|
||||
applyNeedRelief(t, { health: -dt * 1, safety: -dt * 1 });
|
||||
|
|
@ -222,6 +224,7 @@
|
|||
|
||||
if (t.freezeSleepDiseaseMotion(dt)) {
|
||||
t.checkLife(dt);
|
||||
if (!t.dead) t._achievementLaxativeActiveThisFrame = false;
|
||||
return { done: true };
|
||||
}
|
||||
return { done: false };
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
const sleepingAtFrameEnd = t.state === "sleep" || t.sleeping;
|
||||
if (context.wasSleepingAtFrameStart && !sleepingAtFrameEnd && !t.dead) t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 10);
|
||||
t.checkLife(dt);
|
||||
if (!t.dead) t._achievementLaxativeActiveThisFrame = false;
|
||||
return { done: true };
|
||||
}
|
||||
|
||||
|
|
|
|||
11
js/ui.js
11
js/ui.js
|
|
@ -5,6 +5,9 @@ const ctx = canvas.getContext("2d");
|
|||
const ui = {
|
||||
pauseBtn: document.getElementById("pauseBtn"),
|
||||
speedBtn: document.getElementById("speedBtn"),
|
||||
topMenu: document.getElementById("topMenu"),
|
||||
topMenuBtn: document.getElementById("topMenuBtn"),
|
||||
topMenuPanel: document.getElementById("topMenuPanel"),
|
||||
ecologyBtn: document.getElementById("ecologyBtn"),
|
||||
soundBtn: document.getElementById("soundBtn"),
|
||||
soundMenu: document.getElementById("soundMenu"),
|
||||
|
|
@ -13,9 +16,9 @@ const ui = {
|
|||
creditsBtn: document.getElementById("creditsBtn"),
|
||||
saveBtn: document.getElementById("saveBtn"),
|
||||
visualSettingsBtn: document.getElementById("visualSettingsBtn"),
|
||||
visualSettingsMenu: document.getElementById("visualSettingsMenu"),
|
||||
visualSettingsPanel: document.getElementById("visualSettingsPanel"),
|
||||
resetBtn: document.getElementById("resetBtn"),
|
||||
visualSettingsDialog: document.getElementById("visualSettingsDialog"),
|
||||
visualSettingsCloseBtn: document.getElementById("visualSettingsCloseBtn"),
|
||||
creditsDialog: document.getElementById("creditsDialog"),
|
||||
creditsCloseBtn: document.getElementById("creditsCloseBtn"),
|
||||
panel: document.querySelector(".panel"),
|
||||
|
|
@ -35,11 +38,9 @@ const ui = {
|
|||
backToGameBtn: document.getElementById("backToGameBtn"),
|
||||
soundCategoryControls: document.getElementById("soundPanel"),
|
||||
toast: document.getElementById("toast"),
|
||||
statPop: document.getElementById("statPop"),
|
||||
statSick: document.getElementById("statSick"),
|
||||
statJuvenile: document.getElementById("statJuvenile"),
|
||||
statElder: document.getElementById("statElder"),
|
||||
statDead: document.getElementById("statDead"),
|
||||
statColonyMood: document.getElementById("statColonyMood"),
|
||||
temperatureValue: document.getElementById("temperatureValue"),
|
||||
temperatureSlider: document.getElementById("temperatureSlider"),
|
||||
|
|
@ -99,7 +100,7 @@ const uiCache = {
|
|||
chartDeathEventSeen: null,
|
||||
lastChartDraw: "",
|
||||
chartMode: "population",
|
||||
chartHiddenSeries: new Set(),
|
||||
chartHiddenSeries: new Set(["ants"]),
|
||||
canvasDpr: 0,
|
||||
panning: false,
|
||||
panLastX: 0,
|
||||
|
|
|
|||
109
js/ui_bind.js
109
js/ui_bind.js
|
|
@ -5,6 +5,7 @@ function bindUI() {
|
|||
loadLogPushSettings();
|
||||
syncLogPushControls();
|
||||
syncAudioControls();
|
||||
syncVisualSettingsPanel();
|
||||
window.TarinaiTooltips.applyToolPalette();
|
||||
window.TarinaiSaveSystem.bindSaveSystem();
|
||||
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
|
||||
|
|
@ -39,22 +40,26 @@ function bindUI() {
|
|||
return window.matchMedia?.("(min-width: 981px)")?.matches ?? window.innerWidth >= 981;
|
||||
}
|
||||
|
||||
function syncVisualSettingsDialog() {
|
||||
function syncVisualSettingsPanel() {
|
||||
const settings = window.TarinaiPerf?.visualSettings?.() || {};
|
||||
ui.visualSettingsDialog?.querySelectorAll("[data-visual-setting]").forEach((btn) => {
|
||||
const active = settings[btn.dataset.visualSetting] === btn.dataset.visualValue;
|
||||
btn.classList.toggle("active", active);
|
||||
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
for (const input of ui.visualSettingsPanel?.querySelectorAll?.("[data-visual-setting]") || []) {
|
||||
const key = input.dataset.visualSetting || "";
|
||||
if (input.type === "checkbox") input.checked = settings[key] === (input.dataset.visualOn || "high");
|
||||
else input.checked = settings[key] === input.dataset.visualValue;
|
||||
}
|
||||
ui.visualSettingsBtn?.setAttribute("aria-expanded", ui.visualSettingsPanel && !ui.visualSettingsPanel.classList.contains("hidden") ? "true" : "false");
|
||||
}
|
||||
|
||||
function openVisualSettingsDialog() {
|
||||
syncVisualSettingsDialog();
|
||||
ui.visualSettingsDialog?.classList.remove("hidden");
|
||||
function closeVisualSettingsPanel() {
|
||||
ui.visualSettingsPanel?.classList.add("hidden");
|
||||
syncVisualSettingsPanel();
|
||||
}
|
||||
|
||||
function closeVisualSettingsDialog() {
|
||||
ui.visualSettingsDialog?.classList.add("hidden");
|
||||
function toggleVisualSettingsPanel() {
|
||||
const opening = Boolean(ui.visualSettingsPanel?.classList.contains("hidden"));
|
||||
if (opening && typeof closeSoundPanel === "function") closeSoundPanel();
|
||||
ui.visualSettingsPanel?.classList.toggle("hidden", !opening);
|
||||
syncVisualSettingsPanel();
|
||||
}
|
||||
|
||||
function markCollapsibleCardState(card, collapsed, { user = false } = {}) {
|
||||
|
|
@ -96,12 +101,10 @@ function bindUI() {
|
|||
if (!panel) return false;
|
||||
window.TarinaiTooltips.hide();
|
||||
if (tab === "selected" && (!world.selected || world.selected.dead || !world.tarinai.includes(world.selected))) {
|
||||
selectTool("observe");
|
||||
uiCache.showSelectedEmpty = true;
|
||||
uiCache.selectedEmpty = false;
|
||||
renderSelected();
|
||||
showToast("\u305F\u308A\u306A\u3044\u304C\u672A\u9078\u629E\u3067\u3059\u3002\u305F\u308A\u306A\u3044\u30C7\u30FC\u30BF\u3092\u958B\u3051\u308B\u72B6\u614B\u306B\u3057\u307E\u3057\u305F\u3002");
|
||||
}
|
||||
}
|
||||
const target = panelSectionForTab(tab);
|
||||
if (!target) return false;
|
||||
target.classList?.remove?.("hidden");
|
||||
|
|
@ -315,6 +318,7 @@ function bindUI() {
|
|||
syncMobileRotateControls();
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !ui.topMenuPanel?.classList.contains("hidden")) closeTopMenu();
|
||||
const tag = String(e.target?.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || e.target?.isContentEditable) return;
|
||||
const type = activeRotatableToolType();
|
||||
|
|
@ -418,7 +422,6 @@ function bindUI() {
|
|||
const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead);
|
||||
if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; }
|
||||
window.TarinaiCommands.dispatch(world, { type: "selection.focus", target: t, options: { pulse: 1.15 } });
|
||||
selectTool("observe");
|
||||
renderStats();
|
||||
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
|
||||
});
|
||||
|
|
@ -491,14 +494,36 @@ function bindUI() {
|
|||
}
|
||||
syncTopButtons();
|
||||
});
|
||||
function closeTopMenu() {
|
||||
ui.topMenuPanel?.classList.add("hidden");
|
||||
ui.topMenuBtn?.setAttribute("aria-expanded", "false");
|
||||
if (typeof closeSoundPanel === "function") closeSoundPanel();
|
||||
closeVisualSettingsPanel();
|
||||
}
|
||||
function toggleTopMenu() {
|
||||
const opening = Boolean(ui.topMenuPanel?.classList.contains("hidden"));
|
||||
ui.topMenuPanel?.classList.toggle("hidden", !opening);
|
||||
ui.topMenuBtn?.setAttribute("aria-expanded", opening ? "true" : "false");
|
||||
if (!opening && typeof closeSoundPanel === "function") closeSoundPanel();
|
||||
if (!opening) closeVisualSettingsPanel();
|
||||
}
|
||||
ui.topMenuBtn?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
audio.uiClick?.();
|
||||
toggleTopMenu();
|
||||
});
|
||||
ui.topMenuPanel?.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
ui.speedBtn?.addEventListener("click", () => {
|
||||
audio.uiClick?.();
|
||||
window.TarinaiCommands.dispatch(world, { type: "simulation.speed.cycle", speeds: [1, 2, 4] });
|
||||
syncTopButtons();
|
||||
});
|
||||
ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); openEcologyDialog(); });
|
||||
ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); openEcologyDialog(); });
|
||||
ui.soundBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
closeVisualSettingsPanel();
|
||||
toggleSoundPanel();
|
||||
});
|
||||
ui.soundPanel?.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
|
@ -509,32 +534,37 @@ function bindUI() {
|
|||
showToast(on ? "\u52b9\u679c\u97f3\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u52b9\u679c\u97f3\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!ui.soundMenu || ui.soundMenu.contains(e.target)) return;
|
||||
closeSoundPanel();
|
||||
if (ui.soundMenu?.contains(e.target) || ui.visualSettingsMenu?.contains(e.target)) return;
|
||||
if (typeof closeSoundPanel === "function") closeSoundPanel();
|
||||
closeVisualSettingsPanel();
|
||||
if (!ui.topMenu?.contains(e.target)) closeTopMenu();
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!isActiveToolMode()) return;
|
||||
if (clickedInsideGameOrToolUi(e.target)) return;
|
||||
selectTool("observe"); // \u9053\u5177\u8a2d\u7f6e\u30e2\u30fc\u30c9\u3092\u89e3\u9664
|
||||
});
|
||||
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); ui.creditsDialog?.classList.remove("hidden"); });
|
||||
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); ui.creditsDialog?.classList.remove("hidden"); });
|
||||
ui.creditsCloseBtn?.addEventListener("click", () => ui.creditsDialog?.classList.add("hidden"));
|
||||
ui.creditsDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === ui.creditsDialog) ui.creditsDialog.classList.add("hidden");
|
||||
});
|
||||
ui.visualSettingsBtn?.addEventListener("click", () => {
|
||||
ui.visualSettingsBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
audio.uiClick?.();
|
||||
openVisualSettingsDialog();
|
||||
toggleVisualSettingsPanel();
|
||||
});
|
||||
ui.visualSettingsCloseBtn?.addEventListener("click", closeVisualSettingsDialog);
|
||||
ui.visualSettingsDialog?.addEventListener("click", (e) => {
|
||||
if (e.target === ui.visualSettingsDialog) { closeVisualSettingsDialog(); return; }
|
||||
const btn = e.target.closest("[data-visual-setting]");
|
||||
if (!btn) return;
|
||||
const ok = window.TarinaiPerf?.setVisualSetting?.(btn.dataset.visualSetting || "", btn.dataset.visualValue || "");
|
||||
ui.visualSettingsPanel?.addEventListener("click", (e) => e.stopPropagation());
|
||||
ui.visualSettingsPanel?.addEventListener("change", (e) => {
|
||||
const input = e.target.closest("[data-visual-setting]");
|
||||
if (!input) return;
|
||||
const value = input.type === "checkbox"
|
||||
? (input.checked ? (input.dataset.visualOn || "high") : (input.dataset.visualOff || "off"))
|
||||
: (input.dataset.visualValue || "");
|
||||
const ok = window.TarinaiPerf?.setVisualSetting?.(input.dataset.visualSetting || "", value);
|
||||
if (!ok) return;
|
||||
audio.uiClick?.();
|
||||
syncVisualSettingsDialog();
|
||||
syncVisualSettingsPanel();
|
||||
resizeCanvas?.();
|
||||
render?.();
|
||||
});
|
||||
|
|
@ -604,7 +634,9 @@ function bindUI() {
|
|||
showToast("\u9053\u5177\u9078\u629E\u3092\u89E3\u9664\u3057\u307E\u3057\u305F\u3002");
|
||||
return true;
|
||||
}
|
||||
if (world.tool === tool && cycleToolSize(tool)) return true;
|
||||
if (world.tool === tool) {
|
||||
if (cycleToolSize(tool)) return true;
|
||||
}
|
||||
selectTool(tool);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -619,15 +651,26 @@ function bindUI() {
|
|||
markToolCategoryState(category, collapsed, { user: true });
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest(".tool");
|
||||
if (!btn) return;
|
||||
if (btn.dataset.suppressNextClick === "1") {
|
||||
btn.dataset.suppressNextClick = "";
|
||||
const pickModeBtn = e.target.closest("[data-pick-mode-control]");
|
||||
if (pickModeBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
audio.uiClick?.();
|
||||
cycleToolPickMode();
|
||||
window.TarinaiTooltips?.showSelection?.(pickModeBtn, { delay: 5000 });
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest(".tool");
|
||||
if (!btn) return;
|
||||
if (e.target.closest(".tool-size-badge")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
audio.uiClick?.();
|
||||
cycleToolSize(btn.dataset.tool);
|
||||
return;
|
||||
}
|
||||
activateControlTool(btn.dataset.tool);
|
||||
window.TarinaiTooltips?.showSelection?.(btn, { delay: 5000 });
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
|
|
|
|||
|
|
@ -39,14 +39,13 @@ function renderStats() {
|
|||
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
|
||||
stress: avg(t => t.stress),
|
||||
temperature: Number(temp) || 0,
|
||||
ants: (world.ants || []).reduce((n, ant) => n + (ant && !ant.dead ? 1 : 0), 0),
|
||||
...needAverages,
|
||||
objects: { ...(world.itemCounts || {}) },
|
||||
};
|
||||
setTextIfChanged(ui.statPop, "pop", values.pop);
|
||||
setTextIfChanged(ui.statSick, "sick", values.sick);
|
||||
setTextIfChanged(ui.statJuvenile, "juvenile", values.juvenile);
|
||||
setTextIfChanged(ui.statElder, "elder", values.elder);
|
||||
setTextIfChanged(ui.statDead, "dead", values.dead);
|
||||
const colonyMood = world.evaluateColonyMood?.() || world.colonyMood || { label: "\u5b89\u5b9a" };
|
||||
setTextIfChanged(ui.statColonyMood, "colonyMood", colonyMood?.label || "\u5b89\u5b9a");
|
||||
const tempLabel = `${Math.round(temp)}\u2103`;
|
||||
|
|
@ -106,6 +105,7 @@ function updateColonyHistory(values) {
|
|||
sick: 0,
|
||||
juvenile: 0,
|
||||
elder: 0,
|
||||
ants: 0,
|
||||
stress: 0,
|
||||
temperature: 0,
|
||||
...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])),
|
||||
|
|
@ -127,6 +127,7 @@ function updateColonyHistory(values) {
|
|||
sick: bucket.sums.sick / bucket.count,
|
||||
juvenile: bucket.sums.juvenile / bucket.count,
|
||||
elder: bucket.sums.elder / bucket.count,
|
||||
ants: bucket.sums.ants / bucket.count,
|
||||
stress: bucket.sums.stress / bucket.count,
|
||||
temperature: bucket.sums.temperature / bucket.count,
|
||||
...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])),
|
||||
|
|
@ -156,6 +157,7 @@ function updateColonyHistory(values) {
|
|||
bucket.sums.sick += values.sick || 0;
|
||||
bucket.sums.juvenile += values.juvenile || 0;
|
||||
bucket.sums.elder += values.elder || 0;
|
||||
bucket.sums.ants += values.ants || 0;
|
||||
bucket.sums.stress += values.stress || 0;
|
||||
bucket.sums.temperature += Number(values.temperature || 0) || 0;
|
||||
for (const k of needKeys) bucket.sums[`need_${k}`] += values[`need_${k}`] || 0;
|
||||
|
|
@ -175,6 +177,7 @@ function chartRows(values) {
|
|||
sick: 0,
|
||||
juvenile: 0,
|
||||
elder: 0,
|
||||
ants: 0,
|
||||
stress: 0,
|
||||
temperature: 0,
|
||||
births: 0,
|
||||
|
|
@ -308,6 +311,7 @@ function chartSeries(mode = "population") {
|
|||
{ key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" },
|
||||
{ key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" },
|
||||
{ key: "temperature", label: "\u6c17\u6e29", color: "#c46f3c" },
|
||||
{ key: "ants", label: "\u30a2\u30ea", color: "#36454f" },
|
||||
];
|
||||
return [
|
||||
{ key: "pop", label: "\u7dcf\u6570", color: "#5d8ee6" },
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
world.hoverGrabTarget = null;
|
||||
world.hoverDeleteTarget = null;
|
||||
world.hoverGiveTarget = null;
|
||||
world.hoverPokeTarget = null;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@
|
|||
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
|
||||
} else {
|
||||
if (isPinType(target.type) && target.pinState === "lodged") global.TarinaiItemDynamicPinSystem.detach(target, world, "pinch");
|
||||
if (target.achievementBurstOwnerId) target.achievementBurstNeverStopped = false;
|
||||
target.dropTimer = 0;
|
||||
target.dropMax = 0;
|
||||
target.dropImpactDone = true;
|
||||
|
|
@ -199,6 +200,7 @@
|
|||
if (options.notifyNoTarget) showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
|
||||
return false;
|
||||
}
|
||||
global.TarinaiAchievements?.recordIntervention?.({ world, tool: "pinch", target });
|
||||
global.TarinaiHistory.capture(world, "pinch");
|
||||
audio.grab?.();
|
||||
uiCache.grabbing = true;
|
||||
|
|
@ -249,14 +251,12 @@
|
|||
const beforeX = Number(target.x || 0) || 0;
|
||||
const beforeY = Number(target.y || 0) || 0;
|
||||
const pad = target.radius || target.r || 16;
|
||||
if (uiCache.grabKind === "tarinai" && !p.inside) {
|
||||
const raw = clientToWorldRaw(clientX, clientY);
|
||||
target.x = raw.x;
|
||||
target.y = raw.y;
|
||||
} else {
|
||||
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
|
||||
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
|
||||
let desired = (uiCache.grabKind === "tarinai" && !p.inside) ? clientToWorldRaw(clientX, clientY) : { x: p.x, y: p.y };
|
||||
if (String(world.toolPickMode || "free") !== "free") {
|
||||
desired = global.TarinaiPlacementPreviewSystem?.snapPoint?.(world, desired.x, desired.y) || desired;
|
||||
}
|
||||
target.x = clamp(desired.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
|
||||
target.y = clamp(desired.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
|
||||
const movedX = (Number(target.x || 0) || 0) - beforeX;
|
||||
const movedY = (Number(target.y || 0) || 0) - beforeY;
|
||||
if (uiCache.grabKind === "item" && target.type === "reciprocator") {
|
||||
|
|
@ -278,6 +278,11 @@
|
|||
target._lastHeldByPlayerAt = world.time || 0;
|
||||
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
|
||||
if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
|
||||
} else if (String(world.toolPickMode || "free") !== "free") {
|
||||
target.vx = 0;
|
||||
target.vy = 0;
|
||||
uiCache.grabVelocityX = 0;
|
||||
uiCache.grabVelocityY = 0;
|
||||
} else {
|
||||
target.vx = clamp(uiCache.grabVelocityX || dx * 16, -820, 820);
|
||||
target.vy = clamp(uiCache.grabVelocityY || dy * 16, -820, 820);
|
||||
|
|
@ -317,8 +322,9 @@
|
|||
: null;
|
||||
directlyGiven = Boolean(result);
|
||||
if (!directlyGiven) {
|
||||
const releaseVx = clamp(Number(uiCache.grabVelocityX || target.vx || 0) || 0, -900, 900);
|
||||
const releaseVy = clamp(Number(uiCache.grabVelocityY || target.vy || 0) || 0, -900, 900);
|
||||
const gridRelease = String(world.toolPickMode || "free") !== "free";
|
||||
const releaseVx = gridRelease ? 0 : clamp(Number(uiCache.grabVelocityX || target.vx || 0) || 0, -900, 900);
|
||||
const releaseVy = gridRelease ? 0 : clamp(Number(uiCache.grabVelocityY || target.vy || 0) || 0, -900, 900);
|
||||
const releaseSpeed = Math.hypot(releaseVx, releaseVy);
|
||||
if (target.type === "plushie" && typeof target.fling === "function") {
|
||||
const sourceX = Number(uiCache.grabLastWorldX ?? target.x) || target.x;
|
||||
|
|
@ -589,7 +595,18 @@
|
|||
setGiveHover(giveTarget);
|
||||
if (world.hoverGrabTarget) { world.hoverGrabTarget = null; world.drawListDirty = true; }
|
||||
if (world.hoverDeleteTarget) { world.hoverDeleteTarget = null; world.drawListDirty = true; }
|
||||
if (world.hoverPokeTarget) { world.hoverPokeTarget = null; world.drawListDirty = true; }
|
||||
canvas.style.cursor = "pointer";
|
||||
} else if (world.tool === "poke" && p.inside && inactivePointerAction) {
|
||||
setGiveHover(null);
|
||||
const target = world.findPokeTargetAt ? world.findPokeTargetAt(p.x, p.y) : null;
|
||||
if (world.hoverPokeTarget !== target) {
|
||||
world.hoverPokeTarget = target || null;
|
||||
world.drawListDirty = true;
|
||||
}
|
||||
if (world.hoverGrabTarget) { world.hoverGrabTarget = null; world.drawListDirty = true; }
|
||||
if (world.hoverDeleteTarget) { world.hoverDeleteTarget = null; world.drawListDirty = true; }
|
||||
canvas.style.cursor = target ? "pointer" : "default";
|
||||
} else if (world.tool === "pinch" && p.inside && inactivePointerAction) {
|
||||
setGiveHover(null);
|
||||
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
|
||||
|
|
@ -598,6 +615,7 @@
|
|||
world.drawListDirty = true;
|
||||
}
|
||||
if (world.hoverDeleteTarget) { world.hoverDeleteTarget = null; world.drawListDirty = true; }
|
||||
if (world.hoverPokeTarget) { world.hoverPokeTarget = null; world.drawListDirty = true; }
|
||||
canvas.style.cursor = target ? "grab" : "default";
|
||||
} else if (world.tool === "delete" && p.inside && inactivePointerAction) {
|
||||
setGiveHover(null);
|
||||
|
|
@ -607,6 +625,7 @@
|
|||
world.drawListDirty = true;
|
||||
}
|
||||
if (world.hoverGrabTarget) { world.hoverGrabTarget = null; world.drawListDirty = true; }
|
||||
if (world.hoverPokeTarget) { world.hoverPokeTarget = null; world.drawListDirty = true; }
|
||||
canvas.style.cursor = target ? "pointer" : "default";
|
||||
} else {
|
||||
setGiveHover(null);
|
||||
|
|
@ -618,6 +637,10 @@
|
|||
world.hoverDeleteTarget = null;
|
||||
world.drawListDirty = true;
|
||||
}
|
||||
if (world.hoverPokeTarget) {
|
||||
world.hoverPokeTarget = null;
|
||||
world.drawListDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -640,6 +663,7 @@
|
|||
world.hoverGrabTarget = null;
|
||||
world.hoverDeleteTarget = null;
|
||||
world.hoverGiveTarget = null;
|
||||
world.hoverPokeTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,6 @@ function focusTarinaiById(id) {
|
|||
const t = world?.tarinai?.find(o => o && !o.dead && (o.id === id || o.familyKey === id));
|
||||
if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; }
|
||||
window.TarinaiCommands.dispatch(world, { type: "selection.focus", target: t, options: { pulse: 1.25 } });
|
||||
selectTool("observe");
|
||||
uiCache.selectedSnapshot = "";
|
||||
renderSelected();
|
||||
renderStats();
|
||||
|
|
|
|||
221
js/ui_tools.js
221
js/ui_tools.js
|
|
@ -51,21 +51,33 @@ function drawPhysicsToolIcon(canvas, toolId) {
|
|||
} else if (toolId === "glass_wall") {
|
||||
rounded(28,35,104,90,8,"rgba(111,205,225,0.34)","#4d8795",8); line(42,105,78,50,7,"#d9f7ff"); line(83,105,116,55,5,"#d9f7ff");
|
||||
} else if (toolId === "bounce_fence") {
|
||||
line(22,104,138,104,15,"#e06788"); line(34,72,126,72,10,"#f3a9bb");
|
||||
for (const x of [48,80,112]) { line(x,126,x,42,7,"#6c5960"); ctx.beginPath(); ctx.moveTo(x-12,57); ctx.lineTo(x,40); ctx.lineTo(x+12,57); ctx.lineWidth=7; ctx.strokeStyle="#e06788"; ctx.stroke(); }
|
||||
line(28,42,28,124,13,"#5b5260");
|
||||
line(132,42,132,124,13,"#5b5260");
|
||||
const pts = [[36,82],[52,62],[68,102],[84,62],[100,102],[124,82]];
|
||||
ctx.beginPath(); ctx.moveTo(...pts[0]); for (const p of pts.slice(1)) ctx.lineTo(...p);
|
||||
ctx.lineWidth = 13; ctx.strokeStyle = "#e05286"; ctx.stroke();
|
||||
} else if (toolId === "gate_fence") {
|
||||
line(28,32,28,132,13,"#665c50"); line(132,32,132,132,13,"#665c50"); line(34,92,74,72,12,"#c18c55"); line(126,92,86,72,12,"#c18c55"); circle(80,75,8,"#f2d197","#6d5842",5);
|
||||
} else if (toolId === "rotator") {
|
||||
circle(80,80,20,"#e4aa48","#5f574d",8);
|
||||
for (const a of [0, Math.PI*2/3, Math.PI*4/3]) { const x=80+Math.cos(a)*52, y=80+Math.sin(a)*52; line(80,80,x,y,15,"#6d7c8c"); circle(x,y,9,"#cfd8df","#53606b",5); }
|
||||
} else if (toolId === "poison_block") {
|
||||
rounded(31,31,98,98,18,"#8f6cad","#4f3d60",8); circle(62,68,10,"#f1e6f7","#4f3d60",4); circle(98,68,10,"#f1e6f7","#4f3d60",4); line(60,105,100,105,8,"#f1e6f7"); line(56,118,104,90,6,"#eadff0"); line(56,90,104,118,6,"#eadff0");
|
||||
rounded(31,31,98,98,18,"#65b94f","#285f35",8);
|
||||
circle(62,68,10,"#eaffdf","#285f35",4); circle(98,68,10,"#eaffdf","#285f35",4);
|
||||
line(60,105,100,105,8,"#eaffdf"); line(56,118,104,90,6,"#d5f6ca"); line(56,90,104,118,6,"#d5f6ca");
|
||||
} else if (toolId === "reciprocator") {
|
||||
line(22,80,138,80,11,"#657381"); rounded(57,51,46,58,10,"#d68c4f","#5d5046",7);
|
||||
ctx.beginPath(); ctx.moveTo(42,48); ctx.lineTo(24,62); ctx.lineTo(42,76); ctx.moveTo(118,48); ctx.lineTo(136,62); ctx.lineTo(118,76); ctx.lineWidth=8; ctx.strokeStyle="#4f7fa8"; ctx.stroke();
|
||||
line(24,98,136,98,13,"#536474");
|
||||
rounded(56,55,48,58,10,"#e59a52","#536474",8);
|
||||
line(34,64,50,64,8,"#536474");
|
||||
line(110,64,126,64,8,"#536474");
|
||||
} else if (toolId === "one_way_fence") {
|
||||
line(24,112,136,112,14,"#756653"); for (const x of [34,64,94,124]) line(x,45,x,124,7,"#9c8a72");
|
||||
ctx.beginPath(); ctx.moveTo(48,67); ctx.lineTo(104,67); ctx.lineTo(88,49); ctx.moveTo(104,67); ctx.lineTo(88,85); ctx.lineWidth=12; ctx.strokeStyle="#4b9d62"; ctx.stroke();
|
||||
// Slatted barrier with a single clear pass direction through its center.
|
||||
line(24,119,136,119,13,"#6f6254");
|
||||
for (const x of [32,54,106,128]) line(x,45,x,126,7,"#a28d73");
|
||||
rounded(56,52,48,48,10,"rgba(67,164,111,0.18)","#3e8f62",5);
|
||||
line(48,76,111,76,12,"#3e9d69");
|
||||
ctx.beginPath(); ctx.moveTo(94,56); ctx.lineTo(116,76); ctx.lineTo(94,96);
|
||||
ctx.lineWidth=12; ctx.strokeStyle="#3e9d69"; ctx.stroke();
|
||||
} else if (toolId === "fence_h") {
|
||||
line(24,55,24,130,12,"#75512f"); line(136,55,136,130,12,"#75512f"); line(24,72,136,72,14,"#b9854d"); line(24,108,136,108,14,"#b9854d");
|
||||
} else { ctx.restore(); return false; }
|
||||
|
|
@ -112,8 +124,181 @@ const SIZE_ORDER = window.TarinaiItemToolMetadata?.TOOL_SIZE_ORDER || ["small",
|
|||
const SIZE_LABELS = window.TarinaiItemToolMetadata?.TOOL_SIZE_LABELS || { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
|
||||
const TOOL_SHORTCUTS = window.TarinaiItemToolMetadata?.TOOL_SHORTCUTS || {};
|
||||
|
||||
const PICK_MODE_LABELS = Object.freeze({
|
||||
free: "\u81ea\u7531",
|
||||
grid20: "20px",
|
||||
grid40: "40px",
|
||||
grid60: "60px",
|
||||
});
|
||||
|
||||
const PICK_MODE_NAMES = Object.freeze({
|
||||
free: "\u81ea\u7531\u914d\u7f6e",
|
||||
grid20: "20px\u683c\u5b50",
|
||||
grid40: "40px\u683c\u5b50",
|
||||
grid60: "60px\u683c\u5b50",
|
||||
});
|
||||
|
||||
function normalizedToolPickMode() {
|
||||
const value = String(world?.toolPickMode || "free").toLowerCase();
|
||||
if (value === "grid") return "grid40";
|
||||
return PICK_MODE_LABELS[value] ? value : "free";
|
||||
}
|
||||
|
||||
function toolPickModeLabel() {
|
||||
return PICK_MODE_LABELS[normalizedToolPickMode()] || "\u81ea\u7531";
|
||||
}
|
||||
|
||||
function createToolPickModeControl() {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "tool tool-emoji-icon tool-pick-mode-control";
|
||||
btn.type = "button";
|
||||
btn.dataset.pickModeControl = "1";
|
||||
btn.style.setProperty("--tool-emoji", JSON.stringify("\u25a6"));
|
||||
const label = document.createElement("span");
|
||||
label.className = "tool-label";
|
||||
label.textContent = "\u5438\u7740";
|
||||
const value = document.createElement("b");
|
||||
value.className = "tool-pick-mode-value";
|
||||
btn.append(label, value);
|
||||
window.TarinaiTooltips?.bind?.(btn, () => {
|
||||
const mode = normalizedToolPickMode();
|
||||
const name = PICK_MODE_NAMES[mode] || PICK_MODE_NAMES.free;
|
||||
return `\u5438\u7740\u30e2\u30fc\u30c9: ${name}\u3002\u62bc\u3059\u3068\u81ea\u7531\u914d\u7f6e\u300120px\u683c\u5b50\u300140px\u683c\u5b50\u300160px\u683c\u5b50\u306e\u9806\u306b\u5207\u308a\u66ff\u3048\u307e\u3059\u3002`;
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
|
||||
const PANEL_SEARCH_STATE = { query: "", matches: [], index: -1 };
|
||||
|
||||
function panelSearchControl() {
|
||||
return document.getElementById("panelSearchControl") || document.querySelector(".panel-search-control");
|
||||
}
|
||||
|
||||
function normalizePanelSearchText(value = "") {
|
||||
return String(value || "").normalize("NFKC").replace(/\s+/g, " ").trim().toLocaleLowerCase("ja-JP");
|
||||
}
|
||||
|
||||
function clearPanelSearchHighlights() {
|
||||
const root = ui?.panel || document.querySelector(".panel");
|
||||
for (const el of root?.querySelectorAll?.(".panel-search-match, .panel-search-current") || []) {
|
||||
el.classList.remove("panel-search-match", "panel-search-current");
|
||||
}
|
||||
PANEL_SEARCH_STATE.matches = [];
|
||||
PANEL_SEARCH_STATE.index = -1;
|
||||
}
|
||||
|
||||
function panelSearchHighlightTarget(el) {
|
||||
return el?.closest?.(".tool, .panel-card-toggle, .tool-category-toggle, .stat, .chart-series-toggle, .log-entry, .selected-category, .colony-setting-section, label, button, summary") || el;
|
||||
}
|
||||
|
||||
function panelSearchElementText(el) {
|
||||
if (!el) return "";
|
||||
const parts = [el.textContent, el.getAttribute?.("aria-label"), el.dataset?.tip];
|
||||
if (typeof el.__tarinaiTipProvider === "function") {
|
||||
try { parts.push(el.__tarinaiTipProvider()); } catch (_) { /* Ignore a transient tooltip provider failure. */ }
|
||||
}
|
||||
for (const tipEl of el.querySelectorAll?.("[data-tip]") || []) parts.push(tipEl.dataset.tip);
|
||||
return normalizePanelSearchText(parts.filter(Boolean).join(" "));
|
||||
}
|
||||
|
||||
function panelSearchCandidates(root) {
|
||||
const selector = ".tool, .panel-card-toggle, .tool-category-toggle, .stat, .chart-series-toggle, .log-entry, .selected-category, .colony-setting-section, summary, label, button, option, h2, h3, p, small, strong, span";
|
||||
const seen = new Set();
|
||||
const candidates = [];
|
||||
for (const el of root?.querySelectorAll?.(selector) || []) {
|
||||
if (!el || el.closest(".panel-search-control")) continue;
|
||||
if (el.matches('.tool-category[data-tool-category="operate"] .tool-label')) continue;
|
||||
const target = panelSearchHighlightTarget(el);
|
||||
if (!target || seen.has(target)) continue;
|
||||
const text = panelSearchElementText(target);
|
||||
if (!text) continue;
|
||||
seen.add(target);
|
||||
candidates.push({ target, text });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function runPanelSearch(query, { preserveIndex = false } = {}) {
|
||||
const root = ui?.panel || document.querySelector(".panel");
|
||||
const normalized = normalizePanelSearchText(query);
|
||||
const previousIndex = PANEL_SEARCH_STATE.index;
|
||||
clearPanelSearchHighlights();
|
||||
PANEL_SEARCH_STATE.query = normalized;
|
||||
const control = panelSearchControl();
|
||||
const status = control?.querySelector?.(".panel-search-status");
|
||||
const prev = control?.querySelector?.(".panel-search-prev");
|
||||
const next = control?.querySelector?.(".panel-search-next");
|
||||
if (!root || !normalized) {
|
||||
if (status) status.textContent = "0";
|
||||
if (prev) prev.disabled = true;
|
||||
if (next) next.disabled = true;
|
||||
return [];
|
||||
}
|
||||
const matches = [];
|
||||
for (const candidate of panelSearchCandidates(root)) {
|
||||
if (!candidate.text.includes(normalized)) continue;
|
||||
candidate.target.classList.add("panel-search-match");
|
||||
matches.push(candidate.target);
|
||||
}
|
||||
PANEL_SEARCH_STATE.matches = matches;
|
||||
PANEL_SEARCH_STATE.index = preserveIndex && matches.length ? Math.min(previousIndex, matches.length - 1) : -1;
|
||||
if (status) status.textContent = String(matches.length);
|
||||
if (prev) prev.disabled = matches.length === 0;
|
||||
if (next) next.disabled = matches.length === 0;
|
||||
return matches;
|
||||
}
|
||||
|
||||
function revealPanelSearchTarget(target) {
|
||||
if (!target) return;
|
||||
for (const category of [target.closest?.(".tool-category")].filter(Boolean)) {
|
||||
category.classList.remove("collapsed");
|
||||
category.querySelector(".tool-category-toggle")?.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
for (const card of [target.closest?.(".collapsible-card")].filter(Boolean)) {
|
||||
card.classList.remove("collapsed");
|
||||
card.querySelector(".panel-card-toggle")?.setAttribute("aria-expanded", "true");
|
||||
const state = card.querySelector(".panel-card-state");
|
||||
if (state) state.textContent = "\u6298\u308a\u7573\u307f";
|
||||
}
|
||||
for (const details of target.closest?.("details") ? [target.closest("details")] : []) details.open = true;
|
||||
}
|
||||
|
||||
function movePanelSearchMatch(input, direction = 1) {
|
||||
const query = normalizePanelSearchText(input?.value || PANEL_SEARCH_STATE.query);
|
||||
const matches = runPanelSearch(query, { preserveIndex: true });
|
||||
if (!matches.length) return;
|
||||
for (const el of matches) el.classList.remove("panel-search-current");
|
||||
const step = direction < 0 ? -1 : 1;
|
||||
const base = PANEL_SEARCH_STATE.index < 0 ? (step > 0 ? -1 : 0) : PANEL_SEARCH_STATE.index;
|
||||
PANEL_SEARCH_STATE.index = (base + step + matches.length) % matches.length;
|
||||
const target = matches[PANEL_SEARCH_STATE.index];
|
||||
revealPanelSearchTarget(target);
|
||||
target.classList.add("panel-search-current");
|
||||
const status = panelSearchControl()?.querySelector?.(".panel-search-status");
|
||||
if (status) status.textContent = `${PANEL_SEARCH_STATE.index + 1}/${matches.length}`;
|
||||
requestAnimationFrame(() => target.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }));
|
||||
}
|
||||
|
||||
function bindPanelSearchControl() {
|
||||
const control = panelSearchControl();
|
||||
const input = control?.querySelector?.(".panel-search-input");
|
||||
const prev = control?.querySelector?.(".panel-search-prev");
|
||||
const next = control?.querySelector?.(".panel-search-next");
|
||||
if (!control || !input || control.dataset.searchBound === "1") return;
|
||||
control.dataset.searchBound = "1";
|
||||
input.addEventListener("input", () => runPanelSearch(input.value));
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); movePanelSearchMatch(input, event.shiftKey ? -1 : 1); }
|
||||
if (event.key === "Escape") { input.value = ""; runPanelSearch(""); }
|
||||
});
|
||||
prev?.addEventListener("click", () => movePanelSearchMatch(input, -1));
|
||||
next?.addEventListener("click", () => movePanelSearchMatch(input, 1));
|
||||
}
|
||||
|
||||
function renderToolPalette() {
|
||||
if (!ui?.toolPalette || typeof toolCategories !== "function") return;
|
||||
bindPanelSearchControl();
|
||||
ui.toolPalette.innerHTML = "";
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const category of toolCategories()) {
|
||||
|
|
@ -157,6 +342,7 @@ function renderToolPalette() {
|
|||
if (!hasPhysicsIcon) attachToolSpritePreview(btn, def, toolId);
|
||||
if (toolId === world?.tool) btn.classList.add("selected");
|
||||
body.appendChild(btn);
|
||||
if (category.id === "operate" && toolId === "clear_tool") body.appendChild(createToolPickModeControl());
|
||||
}
|
||||
section.append(toggle, body);
|
||||
frag.appendChild(section);
|
||||
|
|
@ -211,6 +397,25 @@ function cycleToolSize(tool) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function cycleToolPickMode() {
|
||||
const result = window.TarinaiCommands.dispatch(world, { type: "tool.pick.cycle" });
|
||||
if (!result?.ok) return false;
|
||||
syncToolSizeBadges();
|
||||
showToast(`\u5438\u7740: ${PICK_MODE_LABELS[result.mode] || "\u81ea\u7531"}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncToolPickModeControls() {
|
||||
const mode = normalizedToolPickMode();
|
||||
for (const btn of ui?.toolPalette?.querySelectorAll?.("[data-pick-mode-control]") || []) {
|
||||
const value = btn.querySelector(".tool-pick-mode-value");
|
||||
if (value) value.textContent = PICK_MODE_LABELS[mode] || "\u81ea\u7531";
|
||||
btn.dataset.pickMode = mode;
|
||||
btn.setAttribute("aria-label", `\u5438\u7740\u30e2\u30fc\u30c9: ${PICK_MODE_NAMES[mode] || PICK_MODE_NAMES.free}\u3002\u62bc\u3059\u3068\u6b21\u306e\u30e2\u30fc\u30c9\u3078\u5207\u308a\u66ff\u3048\u307e\u3059\u3002`);
|
||||
btn.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
|
||||
function syncToolSizeBadges() {
|
||||
for (const btn of uiCache.toolButtons || []) {
|
||||
const tool = btn.dataset.tool || "";
|
||||
|
|
@ -227,5 +432,7 @@ function syncToolSizeBadges() {
|
|||
btn.appendChild(badge);
|
||||
}
|
||||
badge.textContent = SIZE_LABELS[size] || size;
|
||||
badge.title = "\u30b5\u30a4\u30ba\u3092\u5207\u308a\u66ff\u3048\u308b";
|
||||
}
|
||||
syncToolPickModeControls();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
(function (global) {
|
||||
let activeTooltipElement = null;
|
||||
let hideTimer = 0;
|
||||
let longPressTimer = 0;
|
||||
|
||||
function ensureTooltipPopover() {
|
||||
let pop = document.getElementById("toolTipPopover");
|
||||
|
|
@ -36,6 +35,7 @@
|
|||
const pop = ensureTooltipPopover();
|
||||
activeTooltipElement = el;
|
||||
pop.textContent = tip;
|
||||
pop.classList.remove("mobile-selection-tip");
|
||||
pop.classList.remove("hidden");
|
||||
const rect = el.getBoundingClientRect();
|
||||
const popW = pop.offsetWidth || 220;
|
||||
|
|
@ -53,8 +53,9 @@
|
|||
function hideTooltip() {
|
||||
activeTooltipElement = null;
|
||||
if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0; }
|
||||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = 0; }
|
||||
ensureTooltipPopover().classList.add("hidden");
|
||||
const pop = ensureTooltipPopover();
|
||||
pop.classList.add("hidden");
|
||||
pop.classList.remove("mobile-selection-tip");
|
||||
}
|
||||
|
||||
function refreshTooltip(el = null) {
|
||||
|
|
@ -83,22 +84,25 @@
|
|||
el.addEventListener("mouseleave", hideTooltip);
|
||||
el.addEventListener("blur", hideTooltip);
|
||||
el.addEventListener("pointerdown", () => {
|
||||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = 0; }
|
||||
if (activeTooltipElement === el || document.body.classList.contains("touch-mobile-ui")) hideTooltip();
|
||||
if (activeTooltipElement === el && !document.body.classList.contains("touch-mobile-ui")) hideTooltip();
|
||||
});
|
||||
el.addEventListener("touchstart", () => {
|
||||
if (!document.body.classList.contains("touch-mobile-ui")) return;
|
||||
if (longPressTimer) clearTimeout(longPressTimer);
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = 0;
|
||||
el.dataset.suppressNextClick = "1";
|
||||
setTimeout(() => { if (el.dataset.suppressNextClick === "1") el.dataset.suppressNextClick = ""; }, 900);
|
||||
showTooltip(el, { autoHide: true, delay: 2600 });
|
||||
}, 520);
|
||||
}, { passive: true });
|
||||
el.addEventListener("touchmove", () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = 0; } }, { passive: true });
|
||||
el.addEventListener("touchend", () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = 0; } }, { passive: true });
|
||||
el.addEventListener("touchcancel", () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = 0; } }, { passive: true });
|
||||
}
|
||||
|
||||
function showSelection(el, options = {}) {
|
||||
if (!document.body.classList.contains("touch-mobile-ui")) return false;
|
||||
if (!el || !document.documentElement.contains(el)) return false;
|
||||
const tip = formatTooltipText(resolveTip(el));
|
||||
if (!tip) return false;
|
||||
if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0; }
|
||||
const pop = ensureTooltipPopover();
|
||||
activeTooltipElement = el;
|
||||
pop.textContent = tip;
|
||||
pop.classList.add("mobile-selection-tip");
|
||||
pop.classList.remove("hidden");
|
||||
pop.style.left = "50%";
|
||||
pop.style.top = "max(8px, env(safe-area-inset-top))";
|
||||
hideTimer = setTimeout(hideTooltip, Math.max(1000, Number(options.delay || 5000)));
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyToolPaletteTooltips() {
|
||||
|
|
@ -128,6 +132,7 @@
|
|||
bind: bindTooltip,
|
||||
hide: hideTooltip,
|
||||
refresh: refreshTooltip,
|
||||
showSelection,
|
||||
applyToolPalette: applyToolPaletteTooltips,
|
||||
});
|
||||
})(window);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
(function () {
|
||||
const APP_VERSION = "39.16.31";
|
||||
const APP_BUILD = "tarinai-fixes-v39-16-30";
|
||||
const APP_VERSION = "39.16.56";
|
||||
const APP_BUILD = "tarinai-ui-v39-16-56";
|
||||
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;
|
||||
|
||||
|
|
|
|||
15
js/world.js
15
js/world.js
|
|
@ -12,6 +12,7 @@ class World { constructor() {
|
|||
this.tool = "observe";
|
||||
this.toolSize = "medium";
|
||||
this.toolSizes = {};
|
||||
this.toolPickMode = "free";
|
||||
this.toolAngles = {};
|
||||
this.tarinaiPopulationLimit = 0;
|
||||
this.objectLimit = 0;
|
||||
|
|
@ -20,6 +21,7 @@ class World { constructor() {
|
|||
this.hoverGrabTarget = null;
|
||||
this.hoverDeleteTarget = null;
|
||||
this.hoverGiveTarget = null;
|
||||
this.hoverPokeTarget = null;
|
||||
this.shootCursorActive = false;
|
||||
this.shootPointerRawX = 0;
|
||||
this.shootPointerRawY = 0;
|
||||
|
|
@ -84,8 +86,21 @@ class World { constructor() {
|
|||
this.antUpdatePhase = 0;
|
||||
this.selected = null;
|
||||
this.hoverGiveTarget = null;
|
||||
this.hoverPokeTarget = null;
|
||||
this.deadCount = 0;
|
||||
this.birthEventCount = 0;
|
||||
this.achievementNaturalSlaveGenerations = [];
|
||||
this.achievementNaturalKingGenerations = [];
|
||||
this.achievementPlayerPlacementCount = 0;
|
||||
this.achievementDirectFeedCount = 0;
|
||||
this.achievementRobotCleanCount = 0;
|
||||
this.achievementEnemyAntKills = 0;
|
||||
this.achievementLastTarinaiDamageAt = -Infinity;
|
||||
this.achievementSelfSufficientStartAt = -1;
|
||||
this.achievementLastDirectFeedAt = -Infinity;
|
||||
this.achievementLastInterventionAt = 0;
|
||||
this.achievementNoDeathStartAt = -1;
|
||||
this.achievementHappyStartAt = -1;
|
||||
this.liveIdNext = 1;
|
||||
this.liveIdSerial = 0;
|
||||
this.liveTarinai = new Map();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
this.antNestUpdateAccum = 0;
|
||||
const nests = this.itemsOfType?.("ant_nest") || (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
|
||||
for (const nest of nests) {
|
||||
if (global.TarinaiSignalSystem?.powerOverride?.(nest) === false) continue;
|
||||
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
|
||||
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
maxHp: ANT_WORKER_HP || 32,
|
||||
});
|
||||
this.ants.push(a);
|
||||
global.TarinaiAchievements?.recordAntPopulation?.(this);
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
|
||||
nest.antSpawnTimer = rand(2.5, 5.5) + outside * 0.65;
|
||||
this.drawListDirty = true;
|
||||
|
|
@ -70,7 +72,8 @@
|
|||
target.x = home.x;
|
||||
target.y = home.y;
|
||||
audio.antNest?.();
|
||||
target.die("\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f");
|
||||
global.TarinaiAchievements?.recordDamageSource?.(target, home, { world: this, reason: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f" });
|
||||
target.die("\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f", { achievementDangerKill: true });
|
||||
this.drawListDirty = true;
|
||||
},
|
||||
|
||||
|
|
@ -93,6 +96,7 @@
|
|||
foundingDelayUntil: (this.time || 0) + 0.35,
|
||||
});
|
||||
this.ants.push(queen);
|
||||
global.TarinaiAchievements?.recordAntPopulation?.(this);
|
||||
audio.queenAnt?.();
|
||||
this.drawListDirty = true;
|
||||
return queen;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
if (p <= 0 && d > radius) continue;
|
||||
const damage = typeof damageFn === "function" ? damageFn(p, ant, d) : Number(damageFn || 0);
|
||||
if (damage > 0) {
|
||||
global.TarinaiAchievements?.recordAntDamageSource?.(ant, opts.source || null, { world: this, reason });
|
||||
ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage);
|
||||
ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.25);
|
||||
}
|
||||
|
|
@ -118,7 +119,7 @@
|
|||
const d = Math.hypot(dx, dy) || 1;
|
||||
if (d > blastRadius) continue;
|
||||
const p = clamp(1 - d / blastRadius, 0, 1);
|
||||
t.damage(5 + p * 18, "\u7206\u7af9");
|
||||
t.damage(5 + p * 18, "\u7206\u7af9", { source: it });
|
||||
if (t.addStress) t.addStress(22 * p, { threshold: 8 });
|
||||
t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
|
||||
|
|
@ -152,6 +153,7 @@
|
|||
if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it);
|
||||
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
|
||||
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
|
||||
source: it,
|
||||
});
|
||||
let blastedBalls = 0;
|
||||
for (const ball of (this.itemsOfType ? (this.itemsOfType("ball") || []).concat(this.itemsOfType("balloon") || []) : this.items)) {
|
||||
|
|
@ -248,6 +250,7 @@
|
|||
}
|
||||
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", {
|
||||
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale,
|
||||
source: t,
|
||||
});
|
||||
for (const ball of (this.itemsOfType ? (this.itemsOfType("ball") || []).concat(this.itemsOfType("balloon") || []) : this.items)) {
|
||||
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue;
|
||||
|
|
@ -387,6 +390,10 @@
|
|||
it.amount = 240;
|
||||
it.vx = Math.cos(a) * deterministicRange(this, "zunchi-burst-speed-x", 260, 560, source, pin, i) + deterministicRange(this, "zunchi-burst-jitter-x", -40, 40, source, pin, i);
|
||||
it.vy = Math.sin(a) * deterministicRange(this, "zunchi-burst-speed-y", 200, 480, source, pin, i) - deterministicRange(this, "zunchi-burst-up", 30, 160, source, pin, i);
|
||||
it.achievementBurstOwnerId = source?.id || "";
|
||||
it.achievementBurstNeverStopped = true;
|
||||
it.achievementBurstLaunchedAt = this.time || 0;
|
||||
if (pin?.type === "oshibyo") it._achievementDangerSource = pin;
|
||||
it.stage = "fresh";
|
||||
if (this.addItem?.(it, "zunchi-burst")) spawned += 1;
|
||||
}
|
||||
|
|
@ -570,7 +577,7 @@
|
|||
if (physicalHit) {
|
||||
target.lastPhysicalCollisionDamageAt = this.time || 0;
|
||||
}
|
||||
if (target.damage) target.damage(amount, normalized);
|
||||
if (target.damage) target.damage(amount, normalized, options);
|
||||
else if (Number.isFinite(target.hp)) target.hp -= amount;
|
||||
return true;
|
||||
},
|
||||
|
|
@ -652,6 +659,7 @@
|
|||
if (!tarinai.becomeTarinaiChampion?.({ fightRecord: true })) return false;
|
||||
this.spawnBubble?.(tarinai.x, tarinai.y - tarinai.radius * 1.35, "\u738B\u8005", "rgba(144,96,24,0.84)");
|
||||
this.log?.(`${tarinai.name}\u306F\u55A7\u5629${total}\u56DE\u3001\u52DD\u7387${Math.round(winRate * 100)}%\u3067\u305F\u308A\u306A\u3044\u738B\u8005\u306B\u306A\u3063\u305F\u3002`, "fight", { participants: other ? [tarinai, other] : [tarinai] });
|
||||
globalThis.TarinaiAchievements?.recordNaturalTarinaiKing?.({ world: this, tarinai, other, wins, losses, total, winRate });
|
||||
return true;
|
||||
},
|
||||
|
||||
|
|
@ -666,7 +674,8 @@
|
|||
const oldName = tarinai.formerName || tarinai.name;
|
||||
if (!tarinai.becomeZunchiSlave?.({ fightRecord: true, locked: true })) return false;
|
||||
this.spawnBubble?.(tarinai.x, tarinai.y - tarinai.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)");
|
||||
this.log?.(`${oldName}\u306f\u55a7\u5629\u30925\u56de\u7d4c\u9a13\u3057\u3001\u52dd\u738750%\u4ee5\u4e0b\u3067\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: other ? [tarinai, other] : [tarinai] });
|
||||
this.log?.(`${oldName}\u306f\u55a7\u5629\u3092${total}\u56de\u7d4c\u9a13\u3057\u3001\u52dd\u738730%\u4ee5\u4e0b\u3067\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: other ? [tarinai, other] : [tarinai] });
|
||||
globalThis.TarinaiAchievements?.recordNaturalZunchiSlave?.({ world: this, tarinai, other, wins, losses, total, winRate });
|
||||
return true;
|
||||
},
|
||||
|
||||
|
|
@ -735,8 +744,8 @@
|
|||
return global.TarinaiCollisionResponseSystem.resolveBallInteractions(this, dt);
|
||||
},
|
||||
|
||||
resolveTarinaiHighSpeedCollisions(dt) {
|
||||
return global.TarinaiCollisionResponseSystem.resolveTarinaiHighSpeedCollisions(this, dt);
|
||||
resolveTarinaiHighSpeedCollisions(dt, opts = {}) {
|
||||
return global.TarinaiCollisionResponseSystem.resolveTarinaiHighSpeedCollisions(this, dt, opts);
|
||||
}
|
||||
}));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@
|
|||
const base = Math.max(1, Number((body?.hazard?.damage ?? global.TarinaiPhysicsBodySystem.scalar(rect.item, "damage", 7)) || 7) || 7);
|
||||
const damage = clamp(base * (solid ? 1.0 : 0.72) + speed * 0.018, 2.0, 18.0);
|
||||
if (typeof worldRef.applyImpactDamage === "function") worldRef.applyImpactDamage(t, damage, "\u6bd2\u30d6\u30ed\u30c3\u30af", { source: rect.item, x: t.x, y: t.y });
|
||||
else if (typeof t.damage === "function") t.damage(damage, "\u6bd2\u30d6\u30ed\u30c3\u30af");
|
||||
else if (typeof t.damage === "function") t.damage(damage, "\u6bd2\u30d6\u30ed\u30c3\u30af", { source: rect.item });
|
||||
t.showHpBar?.(4.8);
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.82);
|
||||
t.pokeFlashTimer = Math.max(t.pokeFlashTimer || 0, 0.28);
|
||||
|
|
@ -287,6 +287,63 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function mechanicalPressureForTarinaiLocal(worldRef, t) {
|
||||
const pressure = worldRef?._mechanicalCrowdPressure;
|
||||
if (!pressure?.active || !pressure.ids || !t) return null;
|
||||
return pressure.ids.has(t.id || t.familyKey || t) ? pressure : null;
|
||||
}
|
||||
|
||||
function consumeMechanicalPressureContactLocal(worldRef, t) {
|
||||
const pressure = mechanicalPressureForTarinaiLocal(worldRef, t);
|
||||
if (!pressure) return true;
|
||||
const budget = Math.max(1, Number(pressure.contactBudget || 8) || 8);
|
||||
if ((pressure.detailedContacts || 0) < budget) {
|
||||
pressure.detailedContacts = (pressure.detailedContacts || 0) + 1;
|
||||
return true;
|
||||
}
|
||||
pressure.skippedContacts = (pressure.skippedContacts || 0) + 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
function cheapMechanicalPressureContactLocal(worldRef, t, rr, opts = {}) {
|
||||
const pressure = mechanicalPressureForTarinaiLocal(worldRef, t);
|
||||
if (!pressure || !Array.isArray(pressure.sources) || !pressure.sources.length) return false;
|
||||
const maxSources = Math.max(1, Math.min(4, Number(opts.maxSources || 2) || 2));
|
||||
const maxRects = Math.max(1, Math.min(12, Number(opts.maxRects || 5) || 5));
|
||||
const x = Number(t.x || 0) || 0;
|
||||
const y = Number(t.y || 0) || 0;
|
||||
let sourceChecks = 0;
|
||||
let rectChecks = 0;
|
||||
let pushed = false;
|
||||
for (const item of pressure.sources) {
|
||||
if (!item || item.dead) continue;
|
||||
const reach = global.TarinaiMechanicalShapeSystem?.reach?.(item) || Math.max(64, Number(item.r || item.radius || 64) || 64);
|
||||
if (distXY(x, y, Number(item.x || 0) || 0, Number(item.y || 0) || 0) > reach + rr + 56) continue;
|
||||
sourceChecks += 1;
|
||||
for (const rect of worldRef.solidObstacleRects?.(item) || []) {
|
||||
rectChecks += 1;
|
||||
if (Geometry.circleOverlapsRect(x, y, rr, rect, 2.0) && worldRef.pushTarinaiOutOfRect(t, rect, rr, { slop: 0.55, pressureCheap: true })) {
|
||||
pushed = true;
|
||||
break;
|
||||
}
|
||||
if (rectChecks >= maxRects) break;
|
||||
}
|
||||
if (pushed || sourceChecks >= maxSources || rectChecks >= maxRects) break;
|
||||
}
|
||||
pressure.degradedContacts = (pressure.degradedContacts || 0) + 1;
|
||||
if (pushed) {
|
||||
t.lastSolidObstacleCollisionAt = worldRef.time || 0;
|
||||
t.vx = clamp(Number(t.vx || 0) || 0, -420, 420) * 0.82;
|
||||
t.vy = clamp(Number(t.vy || 0) || 0, -420, 420) * 0.82;
|
||||
t.impulseVx = clamp(Number(t.impulseVx || 0) || 0, -260, 260) * 0.72;
|
||||
t.impulseVy = clamp(Number(t.impulseVy || 0) || 0, -260, 260) * 0.72;
|
||||
} else {
|
||||
t.vx = clamp(Number(t.vx || 0) || 0, -520, 520) * 0.94;
|
||||
t.vy = clamp(Number(t.vy || 0) || 0, -520, 520) * 0.94;
|
||||
}
|
||||
return pushed;
|
||||
}
|
||||
|
||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
nearest(entity, types, maxDist = Infinity) {
|
||||
let best = null, bestD = maxDist;
|
||||
|
|
@ -780,6 +837,17 @@
|
|||
t.lastBounceFenceEffectAt = now;
|
||||
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" }));
|
||||
}
|
||||
} else if (r.mechanical && opts.pressureCheap) {
|
||||
const omega = r.rotator ? clamp(Number(r.angularVelocity || 0) || 0, -4.5, 4.5) : 0;
|
||||
const rx = Number(t.x || 0) - Number(r.centerX ?? r.item?.x ?? r.cx ?? 0);
|
||||
const ry = Number(t.y || 0) - Number(r.centerY ?? r.item?.y ?? r.cy ?? 0);
|
||||
const tvx = r.rotator && Math.abs(omega) > 0.012 ? clamp(-ry * omega, -180, 180) : 0;
|
||||
const tvy = r.rotator && Math.abs(omega) > 0.012 ? clamp(rx * omega, -180, 180) : 0;
|
||||
t.vx = clamp(preVx * 0.62 + nx * 46 + tvx * 0.42, -360, 360);
|
||||
t.vy = clamp(preVy * 0.62 + ny * 46 + tvy * 0.42, -360, 360);
|
||||
t.impulseVx = clamp((Number(t.impulseVx || 0) || 0) * 0.45 + nx * 34, -220, 220);
|
||||
t.impulseVy = clamp((Number(t.impulseVy || 0) || 0) * 0.45 + ny * 34, -220, 220);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.05);
|
||||
} else if (r.mechanical) {
|
||||
global.TarinaiMechanicalSystem.applySurfaceVelocityToCircle(t, r, nx, ny, preVx, preVy, {
|
||||
damping: r.rotator ? 0.78 : (r.poisonBlock ? 0.74 : 0.80),
|
||||
|
|
@ -809,13 +877,24 @@
|
|||
return false;
|
||||
}
|
||||
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
|
||||
const maxStep = Math.max(5, Math.min(12, Number(opts.maxStep || rr * 0.48) || 8));
|
||||
const pressure = mechanicalPressureForTarinaiLocal(this, t);
|
||||
if (pressure && !consumeMechanicalPressureContactLocal(this, t)) {
|
||||
t.x += moveX * 0.72;
|
||||
t.y += moveY * 0.72;
|
||||
const pushed = cheapMechanicalPressureContactLocal(this, t, rr, { maxSources: 2, maxRects: 5 });
|
||||
if (pushed) this.markSpatialDirty?.("tarinai-pressure-cheap-contact");
|
||||
return pushed;
|
||||
}
|
||||
const pressureLight = Boolean(pressure);
|
||||
const maxStep = pressureLight ? Math.max(8, Math.min(16, Number(opts.maxStep || rr * 0.72) || 12)) : Math.max(5, Math.min(12, Number(opts.maxStep || rr * 0.48) || 8));
|
||||
const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep)));
|
||||
const stepX = moveX / steps;
|
||||
const stepY = moveY / steps;
|
||||
const fastSweep = dist > rr * 1.25 || Math.hypot(Number(t.vx || 0) || 0, Number(t.vy || 0) || 0) > 170;
|
||||
const sweepMaxChecks = Math.max(opts.light ? (fastSweep ? 18 : 10) : (fastSweep ? 44 : 32), Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24);
|
||||
const sweepMaxRects = fastSweep ? 108 : 72;
|
||||
const sweepMaxChecks = pressureLight
|
||||
? Math.min(fastSweep ? 16 : 10, Math.max(6, Number(opts.maxChecks || 0) || 10))
|
||||
: Math.max(opts.light ? (fastSweep ? 18 : 10) : (fastSweep ? 44 : 32), Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24);
|
||||
const sweepMaxRects = pressureLight ? (fastSweep ? 36 : 24) : (fastSweep ? 108 : 72);
|
||||
let collided = false;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const sx = t.x;
|
||||
|
|
@ -825,8 +904,9 @@
|
|||
const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, {
|
||||
searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170),
|
||||
maxChecks: sweepMaxChecks,
|
||||
maxRects: sweepMaxRects,
|
||||
});
|
||||
maxRects: sweepMaxRects,
|
||||
mechanicalFallback: !pressureLight,
|
||||
});
|
||||
if (hit && hit.t <= 1) {
|
||||
const safeT = Math.max(0, hit.t - 0.035);
|
||||
t.x = sx + stepX * safeT;
|
||||
|
|
@ -842,6 +922,7 @@
|
|||
maxChecks: sweepMaxChecks,
|
||||
maxRects: sweepMaxRects,
|
||||
slop: 0.30,
|
||||
skipPressureBudget: true,
|
||||
oneWayDx: stepX,
|
||||
oneWayDy: stepY,
|
||||
}) || true;
|
||||
|
|
@ -855,6 +936,7 @@
|
|||
maxChecks: sweepMaxChecks,
|
||||
maxRects: sweepMaxRects,
|
||||
slop: 0.35,
|
||||
skipPressureBudget: true,
|
||||
oneWayDx: stepX,
|
||||
oneWayDy: stepY,
|
||||
});
|
||||
|
|
@ -870,6 +952,10 @@
|
|||
resolveSolidObstacleCollision(t, opts = {}) {
|
||||
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false;
|
||||
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
|
||||
const pressure = mechanicalPressureForTarinaiLocal(this, t);
|
||||
if (pressure && !opts.pressureCheap && !opts.skipPressureBudget && !consumeMechanicalPressureContactLocal(this, t)) {
|
||||
return cheapMechanicalPressureContactLocal(this, t, rr, { maxSources: 2, maxRects: 5 });
|
||||
}
|
||||
this.resolvePoisonBlockContactDamage(t, rr);
|
||||
let pushed = false;
|
||||
let bounced = false;
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
|||
const presetGround = groundForResetPreset(preset);
|
||||
if (presetGround && window.TarinaiGround.exists(presetGround)) this.groundType = presetGround;
|
||||
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||||
global.TarinaiAchievements?.resetWorldProgress?.(this);
|
||||
|
||||
generatePresetWorld(this, field, preset, seedPopulation);
|
||||
|
||||
|
|
@ -863,6 +864,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
|||
});
|
||||
if (!child) return null;
|
||||
this.birthEventCount = Math.max(0, Number(this.birthEventCount || 0) || 0) + 1;
|
||||
globalThis.TarinaiAchievements?.recordBirth?.({ world: this, child, parents: [a, b] });
|
||||
if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true });
|
||||
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
|
||||
a.hasPaired = true;
|
||||
|
|
@ -1133,6 +1135,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
|||
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b], sound: false });
|
||||
a.lastLog = b.lastLog = this.time;
|
||||
}
|
||||
global.TarinaiAchievements?.recordFightStarted?.({ world: this, a, b, initiator });
|
||||
return true;
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
worldRef?.markItemBucketsDirty?.("robot-cleaner-settings");
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
worldRef?.log?.("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u5bfe\u8c61\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002", "observe");
|
||||
global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot, source: "robot-cleaner-settings" });
|
||||
if (typeof showToast === "function") showToast("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u3092\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002");
|
||||
global.render?.();
|
||||
close();
|
||||
|
|
@ -662,8 +663,9 @@
|
|||
if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; }
|
||||
if (item.type === "robot_cleaner") { data.robotCleanerMask = Number(item.robotCleanerMask ?? 3) | 0; data.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); }
|
||||
if (item.type === "pressure_switch") {
|
||||
data.pressureTarget = item.pressureTarget === "time" ? "time" : (item.pressureTarget === "item" ? "item" : "tarinai");
|
||||
data.pressureThreshold = Math.max(1, Math.min(999, Number(item.pressureThreshold || 1) | 0));
|
||||
data.pressureTarget = item.pressureTarget || "tarinai";
|
||||
data.pressureComparator = item.pressureComparator === "lte" ? "lte" : "gte";
|
||||
data.pressureThreshold = Math.max(-999, Math.min(999, Number(item.pressureThreshold ?? 1) || 0));
|
||||
data.pressureWidth = Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0));
|
||||
data.pressureHeight = Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0));
|
||||
data.pressureTimeStart = Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0));
|
||||
|
|
@ -730,8 +732,10 @@
|
|||
if (item.roles) item.roles.food = Boolean(item.storedFoodType);
|
||||
}
|
||||
if (item.type === "pressure_switch") {
|
||||
item.pressureTarget = data.pressureTarget === "time" ? "time" : (data.pressureTarget === "item" ? "item" : "tarinai");
|
||||
item.pressureThreshold = Math.max(1, Math.min(999, Number(data.pressureThreshold || 1) | 0));
|
||||
const targets = new Set(["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"]);
|
||||
item.pressureTarget = targets.has(data.pressureTarget) ? data.pressureTarget : "tarinai";
|
||||
item.pressureComparator = data.pressureComparator === "lte" ? "lte" : "gte";
|
||||
item.pressureThreshold = Math.max(-999, Math.min(999, Number(data.pressureThreshold ?? 1) || 0));
|
||||
item.pressureWidth = Math.max(60, Math.min(840, Number(data.pressureWidth || 220) | 0));
|
||||
item.pressureHeight = Math.max(60, Math.min(840, Number(data.pressureHeight || 160) | 0));
|
||||
item.pressureTimeStart = Math.max(0, Math.min(1435, Number(data.pressureTimeStart ?? 360) | 0));
|
||||
|
|
@ -809,15 +813,41 @@
|
|||
|
||||
|
||||
|
||||
const DETECTOR_TARGETS = Object.freeze({
|
||||
tarinai: { label: "\u305f\u308a\u306a\u3044\u6570", threshold: 1, min: 0, max: 300, step: 1, spatial: true },
|
||||
item: { label: "\u30a2\u30a4\u30c6\u30e0\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
||||
time: { label: "\u6642\u523b", threshold: 0, spatial: false, time: true },
|
||||
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", threshold: 70, min: 0, max: 100, step: 1, spatial: true },
|
||||
stress_avg: { label: "\u5e73\u5747\u30b9\u30c8\u30ec\u30b9", threshold: 70, min: 0, max: 100, step: 1, spatial: true },
|
||||
sleep_avg: { label: "\u5e73\u5747\u7761\u7720\u6b32", threshold: 70, min: 0, max: 100, step: 1, spatial: true },
|
||||
low_hp_count: { label: "\u4f4eHP\u500b\u4f53\u6570", threshold: 1, min: 0, max: 300, step: 1, spatial: true },
|
||||
sick_count: { label: "\u75c5\u6c17\u500b\u4f53\u6570", threshold: 1, min: 0, max: 300, step: 1, spatial: true },
|
||||
temperature: { label: "\u6c17\u6e29", threshold: 15, min: -100, max: 120, step: 1, unit: "\u2103", spatial: false },
|
||||
zunchi_count: { label: "\u305a\u3093\u3061\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
||||
ant_count: { label: "\u30a2\u30ea\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
||||
water_count: { label: "\u6c34\u6ef4\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
||||
});
|
||||
|
||||
function ensureSignalItemEditor() {
|
||||
const options = `<option value="tarinai">\u305f\u308a\u306a\u3044\u6570</option><option value="item">\u30a2\u30a4\u30c6\u30e0\u6570</option><option value="time">\u6642\u523b</option><option value="hunger_avg">\u5e73\u5747\u7a7a\u8179</option><option value="stress_avg">\u5e73\u5747\u30b9\u30c8\u30ec\u30b9</option><option value="sleep_avg">\u5e73\u5747\u7761\u7720\u6b32</option><option value="low_hp_count">\u4f4eHP\u500b\u4f53\u6570</option><option value="sick_count">\u75c5\u6c17\u500b\u4f53\u6570</option><option value="temperature">\u6c17\u6e29</option><option value="zunchi_count">\u305a\u3093\u3061\u6570</option><option value="ant_count">\u30a2\u30ea\u6570</option><option value="water_count">\u6c34\u6ef4\u6570</option>`;
|
||||
return ensureEditorDialog("signalItemEditor", "rotator-editor hidden", `
|
||||
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signalItemEditorTitle">
|
||||
<div class="rotator-editor-titlebar"><h2 id="signalItemEditorTitle">\u691c\u77e5\u5668\u8a2d\u5b9a</h2><button id="signalItemEditorClose" class="rotator-editor-close" type="button">\u00d7</button></div>
|
||||
<div class="rotator-editor-controls">
|
||||
<label>\u691c\u77e5\u5bfe\u8c61 <select id="pressureSwitchTarget"><option value="tarinai">\u305f\u308a\u306a\u3044</option><option value="item">\u30a2\u30a4\u30c6\u30e0</option><option value="time">\u6642\u523b</option></select></label>
|
||||
<label id="pressureSwitchCountRow" class="pressure-spatial-control">\u8d77\u52d5\u500b\u6570 <input id="pressureSwitchCount" type="number" min="1" max="999" step="1"></label>
|
||||
<label id="pressureSwitchWidthRow" class="pressure-spatial-control">\u6a2a\u5e45 <input id="pressureSwitchWidth" type="range" min="60" max="840" step="20"><input id="pressureSwitchWidthNum" type="number" min="60" max="840" step="20"> px</label>
|
||||
<label id="pressureSwitchHeightRow" class="pressure-spatial-control">\u7e26\u5e45 <input id="pressureSwitchHeight" type="range" min="60" max="840" step="20"><input id="pressureSwitchHeightNum" type="number" min="60" max="840" step="20"> px</label>
|
||||
<label>\u691c\u77e5\u5bfe\u8c61 <select id="pressureSwitchTarget">${options}</select></label>
|
||||
<label id="pressureSwitchComparatorRow">\u6bd4\u8f03 <select id="pressureSwitchComparator"><option value="gte">\u4ee5\u4e0a</option><option value="lte">\u4ee5\u4e0b</option></select></label>
|
||||
<div id="pressureSwitchCountRow" class="pressure-numeric-control">
|
||||
<div class="pressure-time-summary"><span>\u3057\u304d\u3044\u5024</span><output id="pressureSwitchCountLabel">1</output></div>
|
||||
<input id="pressureSwitchCount" class="pressure-value-slider" type="range" min="0" max="300" step="1">
|
||||
</div>
|
||||
<div id="pressureSwitchWidthRow" class="pressure-numeric-control pressure-spatial-control">
|
||||
<div class="pressure-time-summary"><span>\u6a2a\u5e45</span><output id="pressureSwitchWidthLabel">220px</output></div>
|
||||
<input id="pressureSwitchWidth" class="pressure-value-slider" type="range" min="60" max="840" step="20">
|
||||
</div>
|
||||
<div id="pressureSwitchHeightRow" class="pressure-numeric-control pressure-spatial-control">
|
||||
<div class="pressure-time-summary"><span>\u7e26\u5e45</span><output id="pressureSwitchHeightLabel">160px</output></div>
|
||||
<input id="pressureSwitchHeight" class="pressure-value-slider" type="range" min="60" max="840" step="20">
|
||||
</div>
|
||||
<div id="pressureSwitchTimeRow" class="pressure-time-control" hidden>
|
||||
<div class="pressure-time-summary"><span>\u4f5c\u52d5\u6642\u9593\u5e2f</span><span><output id="pressureSwitchTimeStartLabel">06:00</output><span aria-hidden="true"> \u301c </span><output id="pressureSwitchTimeEndLabel">18:00</output></span></div>
|
||||
<div id="pressureSwitchTimeRange" class="pressure-time-range">
|
||||
|
|
@ -835,11 +865,14 @@
|
|||
if (!item || item.type !== "pressure_switch") return false;
|
||||
const dialog = ensureSignalItemEditor();
|
||||
const target = dialog.querySelector("#pressureSwitchTarget");
|
||||
const comparator = dialog.querySelector("#pressureSwitchComparator");
|
||||
const count = dialog.querySelector("#pressureSwitchCount");
|
||||
const countLabel = dialog.querySelector("#pressureSwitchCountLabel");
|
||||
const width = dialog.querySelector("#pressureSwitchWidth");
|
||||
const widthNum = dialog.querySelector("#pressureSwitchWidthNum");
|
||||
const widthLabel = dialog.querySelector("#pressureSwitchWidthLabel");
|
||||
const height = dialog.querySelector("#pressureSwitchHeight");
|
||||
const heightNum = dialog.querySelector("#pressureSwitchHeightNum");
|
||||
const heightLabel = dialog.querySelector("#pressureSwitchHeightLabel");
|
||||
const comparatorRow = dialog.querySelector("#pressureSwitchComparatorRow");
|
||||
const countRow = dialog.querySelector("#pressureSwitchCountRow");
|
||||
const widthRow = dialog.querySelector("#pressureSwitchWidthRow");
|
||||
const heightRow = dialog.querySelector("#pressureSwitchHeightRow");
|
||||
|
|
@ -850,81 +883,80 @@
|
|||
const timeEnd = dialog.querySelector("#pressureSwitchTimeEnd");
|
||||
const timeStartLabel = dialog.querySelector("#pressureSwitchTimeStartLabel");
|
||||
const timeEndLabel = dialog.querySelector("#pressureSwitchTimeEndLabel");
|
||||
const normalizeMinute = (value, fallback) => {
|
||||
const n = Number(value);
|
||||
const minute = Number.isFinite(n) ? Math.round(n / 5) * 5 : fallback;
|
||||
return Math.max(0, Math.min(1435, minute));
|
||||
};
|
||||
const formatMinute = value => {
|
||||
const minute = normalizeMinute(value, 0);
|
||||
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
|
||||
};
|
||||
target.value = item.pressureTarget === "time" ? "time" : (item.pressureTarget === "item" ? "item" : "tarinai");
|
||||
count.value = String(item.pressureThreshold || 1);
|
||||
width.value = widthNum.value = String(item.pressureWidth || 220);
|
||||
height.value = heightNum.value = String(item.pressureHeight || 160);
|
||||
const normalizeMinute = (value, fallback) => Math.max(0, Math.min(1435, Math.round((Number.isFinite(Number(value)) ? Number(value) : fallback) / 5) * 5));
|
||||
const formatMinute = value => { const minute = normalizeMinute(value, 0); return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; };
|
||||
target.value = DETECTOR_TARGETS[item.pressureTarget] ? item.pressureTarget : "tarinai";
|
||||
comparator.value = item.pressureComparator === "lte" ? "lte" : "gte";
|
||||
count.value = String(Number(item.pressureThreshold ?? DETECTOR_TARGETS[target.value].threshold));
|
||||
width.value = String(item.pressureWidth || 220);
|
||||
height.value = String(item.pressureHeight || 160);
|
||||
timeStart.value = String(normalizeMinute(item.pressureTimeStart, 360));
|
||||
timeEnd.value = String(normalizeMinute(item.pressureTimeEnd, 1080));
|
||||
const updateTimeRange = () => {
|
||||
const start = normalizeMinute(timeStart.value, 360);
|
||||
const end = normalizeMinute(timeEnd.value, 1080);
|
||||
timeStart.value = String(start);
|
||||
timeEnd.value = String(end);
|
||||
timeStartLabel.textContent = formatMinute(start);
|
||||
timeEndLabel.textContent = formatMinute(end);
|
||||
const startPct = start / 1435 * 100;
|
||||
const endPct = end / 1435 * 100;
|
||||
const idle = "rgba(94,112,128,0.22)";
|
||||
const active = "rgba(67,158,218,0.78)";
|
||||
const start = normalizeMinute(timeStart.value, 360), end = normalizeMinute(timeEnd.value, 1080);
|
||||
timeStart.value = String(start); timeEnd.value = String(end);
|
||||
timeStartLabel.textContent = formatMinute(start); timeEndLabel.textContent = formatMinute(end);
|
||||
const startPct = start / 1435 * 100, endPct = end / 1435 * 100;
|
||||
const idle = "rgba(94,112,128,0.22)", active = "rgba(67,158,218,0.78)";
|
||||
timeTrack.style.background = start <= end
|
||||
? `linear-gradient(to right, ${idle} 0%, ${idle} ${startPct}%, ${active} ${startPct}%, ${active} ${endPct}%, ${idle} ${endPct}%, ${idle} 100%)`
|
||||
: `linear-gradient(to right, ${active} 0%, ${active} ${endPct}%, ${idle} ${endPct}%, ${idle} ${startPct}%, ${active} ${startPct}%, ${active} 100%)`;
|
||||
if (timeRange) timeRange.dataset.wraps = start > end ? "1" : "0";
|
||||
};
|
||||
const updateOverlay = () => {
|
||||
if (target.value === "time") {
|
||||
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
|
||||
if (!def.spatial) {
|
||||
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
|
||||
} else {
|
||||
worldRef._pressureSwitchOverlay = {
|
||||
item,
|
||||
width: Math.max(60, Math.min(840, Number(widthNum.value || width.value || 220) | 0)),
|
||||
height: Math.max(60, Math.min(840, Number(heightNum.value || height.value || 160) | 0)),
|
||||
};
|
||||
worldRef._pressureSwitchOverlay = { item, width: Math.max(60, Math.min(840, Number(width.value || 220) | 0)), height: Math.max(60, Math.min(840, Number(height.value || 160) | 0)) };
|
||||
}
|
||||
global.render?.();
|
||||
};
|
||||
const updateMode = () => {
|
||||
const timeMode = target.value === "time";
|
||||
countRow.hidden = timeMode;
|
||||
widthRow.hidden = timeMode;
|
||||
heightRow.hidden = timeMode;
|
||||
timeRow.hidden = !timeMode;
|
||||
updateTimeRange();
|
||||
updateOverlay();
|
||||
const formatDetectorValue = (value, def) => `${Number(value) || 0}${def?.unit || ""}`;
|
||||
const updateNumericDisplays = () => {
|
||||
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
|
||||
const threshold = Math.max(Number(count.min), Math.min(Number(count.max), Number(count.value) || 0));
|
||||
count.value = String(threshold);
|
||||
countLabel.textContent = formatDetectorValue(threshold, def);
|
||||
count.setAttribute("aria-valuetext", countLabel.textContent);
|
||||
widthLabel.textContent = `${Number(width.value) || 0}px`;
|
||||
heightLabel.textContent = `${Number(height.value) || 0}px`;
|
||||
width.setAttribute("aria-valuetext", widthLabel.textContent);
|
||||
height.setAttribute("aria-valuetext", heightLabel.textContent);
|
||||
};
|
||||
syncEditorInputPair(width, widthNum, updateOverlay);
|
||||
syncEditorInputPair(height, heightNum, updateOverlay);
|
||||
timeStart.oninput = updateTimeRange;
|
||||
timeEnd.oninput = updateTimeRange;
|
||||
target.onchange = updateMode;
|
||||
updateMode();
|
||||
const close = () => {
|
||||
dialog.classList.add("hidden");
|
||||
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
|
||||
global.render?.();
|
||||
let previousTarget = target.value;
|
||||
const updateMode = (targetChanged = false) => {
|
||||
const def = DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
|
||||
count.min = String(def.min ?? 0);
|
||||
count.max = String(def.max ?? 100);
|
||||
count.step = String(def.step ?? 1);
|
||||
if (targetChanged && previousTarget !== target.value) count.value = String(def.threshold);
|
||||
previousTarget = target.value;
|
||||
comparatorRow.hidden = Boolean(def.time);
|
||||
countRow.hidden = Boolean(def.time);
|
||||
widthRow.hidden = !def.spatial;
|
||||
heightRow.hidden = !def.spatial;
|
||||
timeRow.hidden = !def.time;
|
||||
updateNumericDisplays(); updateTimeRange(); updateOverlay();
|
||||
};
|
||||
count.oninput = updateNumericDisplays;
|
||||
width.oninput = () => { updateNumericDisplays(); updateOverlay(); };
|
||||
height.oninput = () => { updateNumericDisplays(); updateOverlay(); };
|
||||
timeStart.oninput = updateTimeRange; timeEnd.oninput = updateTimeRange;
|
||||
target.onchange = () => updateMode(true);
|
||||
updateMode(false);
|
||||
const close = () => { dialog.classList.add("hidden"); if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null; global.render?.(); };
|
||||
dialog.querySelector("#signalItemEditorApply").onclick = () => {
|
||||
global.TarinaiHistory.capture(worldRef, "detector-edit");
|
||||
item.pressureTarget = target.value === "time" ? "time" : (target.value === "item" ? "item" : "tarinai");
|
||||
item.pressureThreshold = Math.max(1, Math.min(999, Number(count.value || 1) | 0));
|
||||
item.pressureWidth = Math.max(60, Math.min(840, Number(widthNum.value || width.value || 220) | 0));
|
||||
item.pressureHeight = Math.max(60, Math.min(840, Number(heightNum.value || height.value || 160) | 0));
|
||||
item.pressureTarget = DETECTOR_TARGETS[target.value] ? target.value : "tarinai";
|
||||
item.pressureComparator = comparator.value === "lte" ? "lte" : "gte";
|
||||
const targetDef = DETECTOR_TARGETS[item.pressureTarget] || DETECTOR_TARGETS.tarinai;
|
||||
item.pressureThreshold = Math.max(Number(targetDef.min ?? 0), Math.min(Number(targetDef.max ?? 100), Number(count.value || 0)));
|
||||
item.pressureWidth = Math.max(60, Math.min(840, Number(width.value || 220) | 0));
|
||||
item.pressureHeight = Math.max(60, Math.min(840, Number(height.value || 160) | 0));
|
||||
item.pressureTimeStart = normalizeMinute(timeStart.value, 360);
|
||||
item.pressureTimeEnd = normalizeMinute(timeEnd.value, 1080);
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("detector-edit");
|
||||
global.render?.();
|
||||
close();
|
||||
worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("detector-edit"); global.render?.(); close();
|
||||
};
|
||||
dialog.querySelector("#signalItemEditorCancel").onclick = close;
|
||||
dialog.querySelector("#signalItemEditorClose").onclick = close;
|
||||
|
|
@ -932,6 +964,7 @@
|
|||
dialog.classList.remove("hidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEditableItem(item) {
|
||||
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan" || item.type === "pressure_switch");
|
||||
}
|
||||
|
|
@ -1124,9 +1157,10 @@
|
|||
|
||||
copyPreviewItemAt(x, y) {
|
||||
if (!this.copyBuffer?.type) return null;
|
||||
const item = new Item(this.copyBuffer.type, x, y);
|
||||
const snapped = global.TarinaiPlacementPreviewSystem?.snapPoint?.(this, x, y) || { x, y };
|
||||
const item = new Item(this.copyBuffer.type, snapped.x, snapped.y);
|
||||
applyCopyBufferToItem(item, this.copyBuffer);
|
||||
item.x = x; item.y = y;
|
||||
item.x = snapped.x; item.y = snapped.y;
|
||||
item.world = this;
|
||||
return item;
|
||||
},
|
||||
|
|
@ -1195,11 +1229,11 @@
|
|||
const pad = CONFIG.worldPadding || 30;
|
||||
if (rects.length) {
|
||||
for (const rect of rects) {
|
||||
if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true;
|
||||
if (rect.left < pad || rect.top < 0 || rect.right > this.w - pad || rect.bottom > this.h - pad) return true;
|
||||
}
|
||||
} else {
|
||||
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
|
||||
if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true;
|
||||
if (item.x - radius < pad || item.y - radius < 0 || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true;
|
||||
}
|
||||
}
|
||||
if (item.type === "grass" && (this.grassBlockedAt?.(item.x, item.y, item, { ignoreSoftPlacement: true }) || this.grassOnTarinaiAt?.(item.x, item.y))) return true;
|
||||
|
|
@ -1235,7 +1269,7 @@
|
|||
}
|
||||
return {
|
||||
x: clamp(x, pad + left, this.w - pad - right),
|
||||
y: clamp(y, pad + top, this.h - pad - bottom),
|
||||
y: clamp(y, top, this.h - pad - bottom),
|
||||
};
|
||||
}
|
||||
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
|
||||
|
|
@ -1333,6 +1367,7 @@
|
|||
const directTarget = directFeeding.findTargetAt?.(this, item.x, item.y) || null;
|
||||
const given = directTarget ? directFeeding.give?.(this, directTarget, item, { source: "placement" }) : null;
|
||||
if (given) {
|
||||
global.TarinaiAchievements?.recordIntervention?.({ world: this, item, type: item.type, source: "direct-feed-placement" });
|
||||
this.drawListDirty = true;
|
||||
if (typeof global.render === "function") global.render();
|
||||
return item;
|
||||
|
|
@ -1367,6 +1402,7 @@
|
|||
else if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
|
||||
return null;
|
||||
}
|
||||
global.TarinaiAchievements?.recordPlayerPlacement?.({ world: this, item, type: item.type, dropped, tool: item.type });
|
||||
this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type });
|
||||
this.drawListDirty = true;
|
||||
this.ensureSpatial?.(`place:${item.type}`);
|
||||
|
|
@ -1374,6 +1410,49 @@
|
|||
return item;
|
||||
},
|
||||
|
||||
findPokeTargetAt(x, y) {
|
||||
const items = this.items || [];
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (!item || item.dead || item.type !== "plushie") continue;
|
||||
const owner = item.carriedById ? (this.liveTarinaiById?.(item.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === item.carriedById)) : null;
|
||||
if (owner && this.isTarinaiHiddenInNestBox?.(owner)) continue;
|
||||
const cx = Number(item.x ?? owner?.x ?? 0) || 0;
|
||||
const cy = Number(item.y ?? (owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : 0)) || 0;
|
||||
const rx = Math.max(30, (item.r || 7) * 4.8, (owner?.radius || 0) * 1.05);
|
||||
const ry = Math.max(34, (item.r || 7) * 5.6, (owner?.radius || 0) * 1.18);
|
||||
const dx = (x - cx) / rx;
|
||||
const dy = (y - cy) / ry;
|
||||
if (dx * dx + dy * dy <= 1) return item;
|
||||
}
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (!item || item.dead || (item.type !== "ball" && item.type !== "balloon")) continue;
|
||||
if (distXY(x, y, item.x, item.y) <= Math.max(30, (item.r || 18) * 1.8)) return item;
|
||||
}
|
||||
let mechanical = null;
|
||||
let mechanicalDistance = Infinity;
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (!item || item.dead || !["rotator", "reciprocator", "poison_block"].includes(item.type)) continue;
|
||||
const hit = global.TarinaiMechanicalSystem.hitTest(this, item, x, y, { padding: 12 });
|
||||
if (hit?.hit && Number(hit.distance || 0) < mechanicalDistance) {
|
||||
mechanical = item;
|
||||
mechanicalDistance = Number(hit.distance || 0);
|
||||
}
|
||||
}
|
||||
if (mechanical) return mechanical;
|
||||
for (let i = (this.tarinai || []).length - 1; i >= 0; i--) {
|
||||
const tarinai = this.tarinai[i];
|
||||
if (!tarinai || tarinai.dead || this.isTarinaiHiddenInNestBox?.(tarinai)) continue;
|
||||
const r = Math.max(18, Number(tarinai.radius || 22) || 22);
|
||||
const dx = (x - tarinai.x) / (r * 1.12);
|
||||
const dy = (y - tarinai.y) / (r * 0.94);
|
||||
if (tarinai.contains?.(x, y) || dx * dx + dy * dy <= 1) return tarinai;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
handleClick(x, y) {
|
||||
const tool = this.tool;
|
||||
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new"].includes(tool)) {
|
||||
|
|
@ -1403,95 +1482,45 @@
|
|||
}
|
||||
|
||||
if (tool === "poke") {
|
||||
let mechanismTarget = null;
|
||||
let mechanismTargetD = Infinity;
|
||||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
const it = this.items[i];
|
||||
if (!it || it.dead || !global.TarinaiMechanicalSystem.isMechanicalType(it.type)) continue;
|
||||
const result = global.TarinaiMechanicalSystem.hitTest(this, it, x, y, { padding: 20 });
|
||||
const extent = global.TarinaiMechanicalSystem.reach(it) || (it.r || 64);
|
||||
const d = Math.min(distXY(x, y, it.x, it.y), result.distance ?? Infinity);
|
||||
if ((result.hit || distXY(x, y, it.x, it.y) <= Math.max(46, extent + 22)) && d < mechanismTargetD) { mechanismTarget = it; mechanismTargetD = d; }
|
||||
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "poke", x, y });
|
||||
const target = this.findPokeTargetAt?.(x, y) || null;
|
||||
if (!target) return;
|
||||
if (target.type === "plushie" && typeof target.fling === "function") {
|
||||
const owner = target.carriedById ? (this.liveTarinaiById?.(target.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === target.carriedById)) : null;
|
||||
let dx = (Number(target.x || owner?.x || 0) || 0) - (Number(x) || 0);
|
||||
let dy = (Number(target.y || owner?.y || 0) || 0) - (Number(y) || 0);
|
||||
if (Math.hypot(dx, dy) < 8) {
|
||||
dx = Number(owner?.facing || owner?.faceDir || 1) || 1;
|
||||
dy = -0.46;
|
||||
}
|
||||
const length = Math.max(1, Math.hypot(dx, dy));
|
||||
const speed = 520;
|
||||
const vx = dx / length * speed;
|
||||
const vy = Math.min(dy / length * speed - 95, -145);
|
||||
if (target.fling(this, x, y, { source: "poke", vx, vy, speed, life: 5.2, destroyOffscreen: true })) {
|
||||
this.effects?.push(new Effect("ring", target.x, target.y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" }));
|
||||
this.drawListDirty = true;
|
||||
audio.poke?.();
|
||||
global.TarinaiAchievements?.recordPlushiePokeFling?.({ world: this, item: target, owner });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mechanismTarget) {
|
||||
if (global.TarinaiMechanicalSystem.applyPokeImpulse(mechanismTarget, x, y, this)) {
|
||||
const label = mechanismTarget.type === "rotator" ? "\u56de\u8ee2\u4f53" : (mechanismTarget.type === "poison_block" ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u5f80\u5fa9\u4f53");
|
||||
const color = mechanismTarget.type === "rotator" ? "rgba(184,132,230,0.40)" : (mechanismTarget.type === "poison_block" ? "rgba(92,188,68,0.40)" : "rgba(90,150,214,0.40)");
|
||||
if (target.type === "ball" || target.type === "balloon") {
|
||||
this.pokeBall(target, x, y);
|
||||
return;
|
||||
}
|
||||
if (["rotator", "reciprocator", "poison_block"].includes(target.type)) {
|
||||
if (global.TarinaiMechanicalSystem.applyPokeImpulse(target, x, y, this)) {
|
||||
const label = target.type === "rotator" ? "\u56de\u8ee2\u4f53" : (target.type === "poison_block" ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u5f80\u5fa9\u4f53");
|
||||
const color = target.type === "rotator" ? "rgba(184,132,230,0.40)" : (target.type === "poison_block" ? "rgba(92,188,68,0.40)" : "rgba(90,150,214,0.40)");
|
||||
this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color }));
|
||||
this.drawListDirty = true;
|
||||
audio.poke?.();
|
||||
this.log(`${label}\u3092\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f\u3002`, "observe", { eventType: "mechanism_poke" });
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let plushieTarget = null;
|
||||
let plushieTargetD = Infinity;
|
||||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
const it = this.items[i];
|
||||
if (!it || it.dead || it.type !== "plushie") continue;
|
||||
const owner = it.carriedById ? this.liveTarinaiById?.(it.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById) : null;
|
||||
const cx = owner ? owner.x : it.x;
|
||||
const cy = owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : it.y;
|
||||
const hitX = Math.max(72, (owner?.radius || 24) * 1.45, (it.r || 7) * 9.0);
|
||||
const hitY = Math.max(86, (owner?.radius || 24) * 1.75, (it.r || 7) * 10.5);
|
||||
const ndx = (x - cx) / hitX;
|
||||
const ndy = (y - cy) / hitY;
|
||||
const elliptical = ndx * ndx + ndy * ndy;
|
||||
const d = Math.sqrt(Math.max(0, elliptical));
|
||||
if (elliptical <= 1.0 && d < plushieTargetD) {
|
||||
plushieTarget = it;
|
||||
plushieTargetD = d;
|
||||
}
|
||||
}
|
||||
if (plushieTarget) {
|
||||
const owner = plushieTarget.carriedById ? this.liveTarinaiById?.(plushieTarget.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === plushieTarget.carriedById) : null;
|
||||
if (owner && typeof plushieTarget.fling === "function") {
|
||||
const side = Math.sign((owner.x || 0) - ((this.w || 0) / 2)) || Number(owner.facing || owner.faceDir || 1) || 1;
|
||||
const speed = 1550 / 3;
|
||||
const vx = side * speed;
|
||||
const vy = -560 / 3;
|
||||
if (plushieTarget.fling(this, x, y, { source: "poke", vx, vy, speed, life: 5.2, destroyOffscreen: true })) {
|
||||
this.effects?.push(new Effect("ring", x, y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" }));
|
||||
this.drawListDirty = true;
|
||||
audio.poke?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof plushieTarget.fling === "function") {
|
||||
const cx = owner ? owner.x : plushieTarget.x;
|
||||
const cy = owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : plushieTarget.y;
|
||||
let dx = (Number(cx || 0) || 0) - (Number(x || 0) || 0);
|
||||
let dy = (Number(cy || 0) || 0) - (Number(y || 0) || 0);
|
||||
if (Math.hypot(dx, dy) < 12 || Math.abs(dx) < Math.abs(dy) * 0.36) {
|
||||
dx = Math.sign(dx) || Number(owner?.facing || owner?.faceDir || 1) || 1;
|
||||
dy = -0.42;
|
||||
}
|
||||
const len = Math.max(1, Math.hypot(dx, dy));
|
||||
const speed = 1350 / 3;
|
||||
const vx = dx / len * speed;
|
||||
const vy = Math.min(dy / len * speed - 80, -120);
|
||||
if (plushieTarget.fling(this, x, y, { source: "poke", vx, vy, speed, life: 4.8, destroyOffscreen: true })) {
|
||||
this.effects?.push(new Effect("ring", x, y, { size: 24, life: 0.18, color: "rgba(190,142,92,0.34)" }));
|
||||
this.drawListDirty = true;
|
||||
audio.poke?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ballTarget = null;
|
||||
let ballTargetD = Infinity;
|
||||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
const it = this.items[i];
|
||||
if (!it || it.dead || (it.type !== "ball" && it.type !== "balloon")) continue;
|
||||
const hit = Math.max(72, (it.r || 18) * 4.0);
|
||||
const d = distXY(x, y, it.x, it.y);
|
||||
if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; }
|
||||
}
|
||||
if (ballTarget && this.pokeBall(ballTarget, x, y)) return;
|
||||
|
||||
const target = [...this.tarinai].reverse().find(t => !this.isTarinaiHiddenInNestBox?.(t) && t.contains(x, y));
|
||||
if (target) {
|
||||
if (typeof target.poke === "function") {
|
||||
target.poke();
|
||||
if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] });
|
||||
}
|
||||
|
|
@ -1514,7 +1543,8 @@
|
|||
// In placement mode, clicks always mean placement.
|
||||
// Editing rotators / reciprocators is reserved for observe or non-placement tools.
|
||||
if (itemType) {
|
||||
let px = x, py = y;
|
||||
const snapped = global.TarinaiPlacementPreviewSystem?.snapPoint?.(this, x, y) || { x, y };
|
||||
let px = snapped.x, py = snapped.y;
|
||||
const rawItem = this.applyToolSize(new Item(itemType, px, py));
|
||||
if (isRotatableItemType(itemType)) {
|
||||
const requestedAngle = this.toolAngleFor ? this.toolAngleFor(itemType) : defaultItemAngle(itemType);
|
||||
|
|
@ -1526,6 +1556,8 @@
|
|||
: null;
|
||||
// A Tarinai under the pointer takes precedence over a duplicator load slot.
|
||||
if (directTarget) {
|
||||
rawItem.x = x;
|
||||
rawItem.y = y;
|
||||
const given = this.placeItem(rawItem, true);
|
||||
if (given) this._forceImmediateToolVisualRefresh?.("tool-change");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@
|
|||
const fanRange = 320;
|
||||
for (const it of this.nearbyItems(x, y, Math.max(range + 8, fanRange + 40))) {
|
||||
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove" && it.type !== "fan")) continue;
|
||||
if (global.TarinaiSignalSystem?.powerOverride?.(it) === false) continue;
|
||||
if (it.type === "fan") {
|
||||
const wind = global.TarinaiItemDynamicBallSystem?.fanWindAt?.(it, x, y, { range: fanRange }) || null;
|
||||
if (!wind) continue;
|
||||
|
|
@ -152,7 +153,7 @@
|
|||
break;
|
||||
}
|
||||
const parasolSystem = global.TarinaiParasolSystem || global.TarinaiRainShelterSystem;
|
||||
const weatherBlocked = Boolean(actor && parasolSystem?.blocksWeatherAt?.(this, x, y));
|
||||
const weatherBlocked = Boolean(actor && parasolSystem?.blocksWeatherAt?.(this, x, y, actor));
|
||||
temp += weatherBlocked ? 0 : (this.weatherFeltTemperatureOffsetAt?.(x, y, actor) || 0);
|
||||
if (actor && !weatherBlocked && (actor.state === "sunbath" || (actor.sunbathTimer || 0) > 0.04)) temp += 3;
|
||||
return this.temperatureNumber(temp);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
},
|
||||
|
||||
deleteItemAt(x, y) {
|
||||
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "delete", x, y });
|
||||
const target = this.findDeleteToolTargetAt?.(x, y);
|
||||
if (!target) { showToast("\u6d88\u305b\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return true; }
|
||||
if (isPinType(target.type) && target.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3001\u3064\u307e\u3093\u3067\u629c\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return true; }
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
wake: true,
|
||||
});
|
||||
if (!removed) return true;
|
||||
global.TarinaiAchievements?.recordPlayerDeletion?.({ world: this, item: target, tool: "delete" });
|
||||
audio.delete?.();
|
||||
this.compactItems?.();
|
||||
this.updateItemCounts?.();
|
||||
|
|
@ -65,6 +67,7 @@
|
|||
},
|
||||
|
||||
deleteItemsInArea(x, y, options = {}) {
|
||||
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "area_delete", x, y });
|
||||
const radius = this.areaDeleteRadius?.() || 138;
|
||||
const candidates = [];
|
||||
const itemCandidates = this.nearbyItems?.(x, y, 190, true) || this.items || [];
|
||||
|
|
@ -97,6 +100,7 @@
|
|||
wake: true,
|
||||
});
|
||||
if (!removed) continue;
|
||||
global.TarinaiAchievements?.recordPlayerDeletion?.({ world: this, item: target, tool: "area_delete" });
|
||||
removedCount += 1;
|
||||
const label = toolLabel(target.type);
|
||||
kindCounts.set(label, (kindCounts.get(label) || 0) + 1);
|
||||
|
|
@ -201,6 +205,7 @@
|
|||
},
|
||||
|
||||
useLinkToolAt(x, y) {
|
||||
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: this.tool, x, y });
|
||||
const endpoint = this.findLinkEndpointAt?.(x, y);
|
||||
const linkNames = { rope: "\u30d2\u30e2", rod: "\u68d2", spring: "\u30d0\u30cd", wire: "\u96fb\u7dda", insulated_wire: "\u7d76\u7e01\u96fb\u7dda" };
|
||||
const toolName = linkNames[this.tool] || "\u63a5\u7d9a";
|
||||
|
|
@ -240,6 +245,7 @@
|
|||
const placed = this.addItem?.(item, constraintType);
|
||||
this.pendingLinkEndpoint = null;
|
||||
if (!placed) { showToast(`${toolName}\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002`); return true; }
|
||||
global.TarinaiAchievements?.recordLinkPlaced?.(constraintType, { world: this, item, type: constraintType });
|
||||
global.TarinaiPhysicsSoftClear.clearForItem(this, item, `${constraintType}-soft-overlap-clear`);
|
||||
this._forceImmediateToolVisualRefresh?.("tool-action");
|
||||
this.log?.(`${toolName}\u3092\u3064\u306a\u3044\u3060\u3002`, "observe");
|
||||
|
|
@ -294,11 +300,11 @@
|
|||
const d = distXY(x, y, t.x, t.y);
|
||||
if (hit && d < bestD) { best = { kind: "tarinai", target: t, distance: d }; bestD = d; }
|
||||
}
|
||||
const antCandidates = this.nearbyAnts?.(x, y, 44, true) || this.ants || [];
|
||||
const antCandidates = this.nearbyAnts?.(x, y, 32, true) || this.ants || [];
|
||||
for (let i = antCandidates.length - 1; i >= 0; i -= 1) {
|
||||
const ant = antCandidates[i];
|
||||
if (!ant || ant.dead) continue;
|
||||
const hitR = Math.max(10, (ant.r || 6) * 1.85);
|
||||
const hitR = Math.max(6, (ant.r || 6) * 1.35);
|
||||
const d = distXY(x, y, ant.x, ant.y);
|
||||
if (d <= hitR && d < bestD) { best = { kind: "ant", target: ant, distance: d }; bestD = d; }
|
||||
}
|
||||
|
|
@ -387,6 +393,7 @@
|
|||
fireShootTool(x = null, y = null) {
|
||||
const aim = (x == null || y == null) ? this.shootAimPoint?.() : { x: Number(x) || 0, y: Number(y) || 0, inside: true };
|
||||
if (!aim?.inside) return false;
|
||||
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "shoot", x: aim.x, y: aim.y });
|
||||
const hit = this.findShootToolTargetAt?.(aim.x, aim.y);
|
||||
audio.shoot?.();
|
||||
this.effects?.push(new Effect("shoot_impact", aim.x, aim.y, { size: hit ? 22 : 16, life: hit ? 0.28 : 0.22, color: hit ? "rgba(255,208,116,0.96)" : "rgba(190,212,240,0.64)" }));
|
||||
|
|
@ -395,7 +402,7 @@
|
|||
if (hit?.kind === "tarinai") {
|
||||
const t = hit.target;
|
||||
const damage = 16;
|
||||
t.damage?.(damage, "\u5c04\u6483");
|
||||
t.damage?.(damage, "\u5c04\u6483", { source: { id: "shoot", type: "shoot" } });
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.35);
|
||||
t.pokeFlashTimer = Math.max(t.pokeFlashTimer || 0, 0.22);
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 2.8);
|
||||
|
|
@ -404,6 +411,7 @@
|
|||
} else if (hit?.kind === "ant") {
|
||||
const ant = hit.target;
|
||||
const damage = ant.kind === "queen" ? 24 : 34;
|
||||
global.TarinaiAchievements?.recordAntDamageSource?.(ant, { id: "shoot", type: "shoot" }, { world: this });
|
||||
ant.hp = Math.max(0, (Number(ant.hp) || Number(ant.maxHp) || 0) - damage);
|
||||
ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.3);
|
||||
if (ant.hp <= 0) ant.dieAsCorpse?.(ant.homeNest?.() || null);
|
||||
|
|
|
|||
539
scripts/achievement_audit.js
Normal file
539
scripts/achievement_audit.js
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, "..", "js", "achievements.js"), "utf8");
|
||||
|
||||
class ClassList {
|
||||
constructor() { this.values = new Set(["hidden"]); }
|
||||
add(...names) { names.forEach(name => this.values.add(name)); }
|
||||
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||||
contains(name) { return this.values.has(name); }
|
||||
toggle(name, force) {
|
||||
const next = force === undefined ? !this.values.has(name) : Boolean(force);
|
||||
if (next) this.values.add(name); else this.values.delete(name);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
class Element {
|
||||
constructor(id = "", tagName = "div") {
|
||||
this.tagName = String(tagName || "div").toUpperCase();
|
||||
this.open = false;
|
||||
this.id = id;
|
||||
this.className = "";
|
||||
this.classList = new ClassList();
|
||||
this.dataset = {};
|
||||
this.styles = new Map();
|
||||
this.style = {
|
||||
setProperty: (name, value) => this.styles.set(name, value),
|
||||
removeProperty: name => this.styles.delete(name),
|
||||
};
|
||||
this.children = [];
|
||||
this.textContent = "";
|
||||
this.tabIndex = -1;
|
||||
this.isConnected = true;
|
||||
}
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = children; }
|
||||
setAttribute() {}
|
||||
addEventListener() {}
|
||||
focus() {}
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
|
||||
get offsetWidth() { return 100; }
|
||||
}
|
||||
|
||||
function createHarness(existingStorage = null, search = "", options = {}) {
|
||||
const storage = existingStorage || new Map();
|
||||
const elements = new Map();
|
||||
const ids = [
|
||||
"achievementsBtn", "achievementButtonCount", "achievementsDialog", "achievementDialogCount",
|
||||
"achievementsCloseBtn", "achievementsCloseIconBtn", "achievementRefreshBtn", "achievementResetBtn", "achievementUnlockAllBtn",
|
||||
"achievementList", "achievementSharedStatus", "achievementToast", "achievementToastTitle", "pauseBtn",
|
||||
];
|
||||
ids.forEach(id => elements.set(id, new Element(id)));
|
||||
elements.get("achievementsDialog").classList.add("hidden");
|
||||
elements.get("achievementToast").classList.add("hidden");
|
||||
let timerId = 0;
|
||||
const context = {
|
||||
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise,
|
||||
Number, String, Boolean, Error, AbortController, encodeURIComponent, URLSearchParams,
|
||||
location: { protocol: options.protocol || "file:", search },
|
||||
crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
|
||||
localStorage: {
|
||||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||
setItem(key, value) { storage.set(key, String(value)); },
|
||||
removeItem(key) { storage.delete(key); },
|
||||
},
|
||||
document: {
|
||||
hidden: false,
|
||||
getElementById(id) { return elements.get(id) || null; },
|
||||
createElement(tagName) { return new Element("", tagName); },
|
||||
addEventListener() {},
|
||||
},
|
||||
TarinaiEvents: { emit() {} },
|
||||
showToast() {},
|
||||
audio: { uiClick() {} },
|
||||
setTimeout() { timerId += 1; return timerId; },
|
||||
clearTimeout() {},
|
||||
setInterval() { timerId += 1; return timerId; },
|
||||
clearInterval() {},
|
||||
fetch: options.fetch || (async () => { throw new Error("offline"); }),
|
||||
TARINAI_VERSION: "39.16.56",
|
||||
TarinaiGameDialogs: { confirm: async () => true },
|
||||
};
|
||||
context.window = context;
|
||||
context.globalThis = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context, { filename: "achievements.js" });
|
||||
return { context, api: context.TarinaiAchievements, storage, elements };
|
||||
}
|
||||
|
||||
function assert(condition, message) { if (!condition) throw new Error(message); }
|
||||
function unlocked(h, id) { return h.api.isUnlocked(id); }
|
||||
|
||||
const expectedIds = [
|
||||
"first_birth", "natural_zunchi_slave", "natural_tarinai_king", "self_zunchi_death",
|
||||
"natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations",
|
||||
"death_50_in_10_seconds", "birth_50_in_60_seconds", "great_mother_1000_births", "lifespan_completed",
|
||||
"soccer_ball_death", "fight_pair_danger_kill", "all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
|
||||
"ignite_during_birth_ritual", "laxative_starvation", "idle_observer_5_minutes",
|
||||
"placed_objects_100", "mechanized_industry", "safe_colony_25_5_minutes",
|
||||
"ants_alive_25", "ants_killed_100", "secret_collection_9_slots",
|
||||
"pause_spam_4_in_1_second", "continuous_play_1_hour", "below_absolute_zero_item",
|
||||
"undo_mass_revival", "robot_cleaner_100", "held_30_seconds", "minimalist_happy",
|
||||
"overprotective", "self_sufficient", "unplanned_city_30", "sauna_cold_plunge",
|
||||
"rain_shelter_all", "medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend",
|
||||
"revolution", "fuel_to_fire", "undo_20", "redo_20",
|
||||
"eternal_history_generation_10", "king_full_satisfaction", "slave_zero_satisfaction", "town_doctor_50", "poke_plushie_fling",
|
||||
"chaos_seeker_666_fights", "clean_freak_robot_only", "true_tarinai_observer",
|
||||
];
|
||||
|
||||
(async function run() {
|
||||
let h = createHarness();
|
||||
let storage;
|
||||
assert(h.api.definitions.length === 51, "definition count must be 51");
|
||||
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
|
||||
const categorizedIds = h.api.categories.flatMap(category => category.ids);
|
||||
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
|
||||
assert(new Set(categorizedIds).size === expectedIds.length, "achievement category contains duplicates");
|
||||
assert(expectedIds.every(id => categorizedIds.includes(id)), "achievement category misses an ID");
|
||||
h.api.open();
|
||||
const titles = Object.fromEntries(h.api.definitions.map(def => [def.id, def.title]));
|
||||
assert(titles.natural_zunchi_slave === "ずんちどれい", "slave title mismatch");
|
||||
assert(titles.natural_tarinai_king === "たりない王", "king title mismatch");
|
||||
assert(titles.self_zunchi_death === "あぁ^~ たまらねぇぜ", "self-zunchi title mismatch");
|
||||
assert(titles.natural_zunchi_slave_5_generations === "末代までの恥", "five-generation title mismatch");
|
||||
assert(titles.natural_tarinai_king_3_generations === "華麗なる王族", "three-generation title mismatch");
|
||||
assert(titles.death_50_in_10_seconds === "6番目の大量絶滅", "mass-extinction title mismatch");
|
||||
assert(titles.birth_50_in_60_seconds === "ベビーブーム", "baby-boom title mismatch");
|
||||
assert(titles.idle_observer_5_minutes === "監視カメラ", "observer title mismatch");
|
||||
assert(titles.laxative_starvation === "内臓全部出た", "laxative title mismatch");
|
||||
assert(titles.colony_happy === "管理された楽園", "happy colony title mismatch");
|
||||
assert(titles.soccer_ball_death === "豆野、殺ッカーやろうぜ!", "soccer title mismatch");
|
||||
assert(titles.ants_alive_25 === "アリの惑星", "ant planet title mismatch");
|
||||
assert(titles.all_non_sleep_diseased_25 === "緊急事態宣言", "emergency title mismatch");
|
||||
assert(titles.secret_collection_9_slots === "秘密のコレクション", "save-slot title mismatch");
|
||||
assert(titles.pause_spam_4_in_1_second === "何やってるの?", "pause-spam title mismatch");
|
||||
assert(titles.continuous_play_1_hour === "めっちゃたりない観察", "continuous-play title mismatch");
|
||||
assert(titles.below_absolute_zero_item === "物理法則を下側に超越", "absolute-zero title mismatch");
|
||||
assert(titles.ignite_during_birth_ritual === "燃え上がる恋", "ritual ignition title mismatch");
|
||||
assert(titles.safe_colony_25_5_minutes === "安全第一", "safe colony title mismatch");
|
||||
assert(titles.great_mother_1000_births === "大いなる母", "great mother title mismatch");
|
||||
assert(titles.fight_pair_danger_kill === "喧嘩両成敗", "fight-pair title mismatch");
|
||||
assert(titles.direct_feed_33 === "餌やりじいさん", "feeding title mismatch");
|
||||
assert(titles.eternal_history_generation_10 === "悠久の歴史", "generation title mismatch");
|
||||
assert(titles.king_full_satisfaction === "五体満足", "king effect title mismatch");
|
||||
assert(titles.slave_zero_satisfaction === "零体満足", "slave effect title mismatch");
|
||||
assert(titles.chaos_seeker_666_fights === "混沌ヲ希求スル者", "chaos seeker title mismatch");
|
||||
assert(titles.clean_freak_robot_only === "潔癖症", "clean freak title mismatch");
|
||||
assert(titles.true_tarinai_observer === "真・たりない観察", "completionist title mismatch");
|
||||
const descriptions = Object.fromEntries(h.api.definitions.map(def => [def.id, def.description]));
|
||||
assert(descriptions.natural_zunchi_slave === "ずんちどれいが出現する。", "slave description mismatch");
|
||||
assert(descriptions.natural_tarinai_king === "たりない王が出現する。", "king description mismatch");
|
||||
assert(descriptions.self_zunchi_death === "おしり鋲を抜いた時、自分のずんちで自爆する。", "self-zunchi description mismatch");
|
||||
assert(descriptions.natural_zunchi_slave_5_generations === "5代連続でずんちどれい(鎖なし)", "five-generation description mismatch");
|
||||
assert(descriptions.natural_tarinai_king_3_generations === "3代連続でたりない王(王冠なし)", "three-generation description mismatch");
|
||||
assert(descriptions.great_mother_1000_births.includes("3333体"), "great mother description threshold mismatch");
|
||||
assert(descriptions.robot_cleaner_100.includes("200個"), "cleaner description threshold mismatch");
|
||||
assert(descriptions.minimalist_happy.includes("6日目以降"), "minimalist start-day description mismatch");
|
||||
assert(descriptions.chaos_seeker_666_fights.includes("666回"), "chaos seeker description mismatch");
|
||||
assert(!source.includes("TarinaiTooltips"), "achievement tooltips were not removed");
|
||||
assert(source.includes("PRE_UNLOCK_DESCRIPTION_IDS"), "pre-unlock disclosure allowlist is missing");
|
||||
function walk(node, out = []) { for (const child of node?.children || []) { out.push(child); walk(child, out); } return out; }
|
||||
const cards = walk(h.elements.get("achievementList")).filter(node => node.dataset?.achievementId);
|
||||
const firstBirthCard = cards.find(node => node.dataset.achievementId === "first_birth");
|
||||
const disclosedSlaveCard = cards.find(node => node.dataset.achievementId === "natural_zunchi_slave");
|
||||
const disclosedKingCard = cards.find(node => node.dataset.achievementId === "natural_tarinai_king");
|
||||
const disclosedRevolutionCard = cards.find(node => node.dataset.achievementId === "revolution");
|
||||
const disclosedLaxativeCard = cards.find(node => node.dataset.achievementId === "laxative_starvation");
|
||||
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
|
||||
const hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
|
||||
assert(firstBirthCard.children[1].children[0].children[1].textContent === descriptions.first_birth, "disclosed locked condition is not shown in the UI");
|
||||
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard]) {
|
||||
assert(card.children[1].children[0].children[1].textContent !== "???", "requested pre-unlock condition is hidden");
|
||||
}
|
||||
assert(hiddenCard.children[1].children[0].children[1].textContent === "???", "undisclosed locked condition is not hidden as question marks");
|
||||
const groups = h.elements.get("achievementList").children;
|
||||
assert(groups.length === 4, "achievement UI must contain exactly four category groups");
|
||||
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "experiment", "construction", "operation"]), "achievement category order mismatch");
|
||||
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
|
||||
const categoryTitles = h.api.categories.map(category => category.title);
|
||||
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "実験", "建築", "その他"]), "achievement category titles mismatch");
|
||||
h.api.unlock("natural_zunchi_slave");
|
||||
const rerenderedGroups = h.elements.get("achievementList").children;
|
||||
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
||||
const allRenderedCards = rerenderedGroups.flatMap(group => walk(group).filter(node => node.dataset?.achievementId));
|
||||
assert(ecologyCards[0]?.dataset.achievementId === "natural_zunchi_slave", "unlocked achievement was not moved to the top of its category");
|
||||
assert(allRenderedCards.filter(card => card.dataset.achievementId === "natural_zunchi_slave").length === 1, "unlocked achievement is duplicated across categories");
|
||||
assert(await h.api.reset(), "in-game achievement reset confirmation was not accepted");
|
||||
assert(!h.api.isUnlocked("natural_zunchi_slave"), "achievement reset did not clear unlocked state");
|
||||
assert(source.includes('card.style.setProperty("--achievement-rate"'), "achievement percentage bar is not wired");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordBirth({ world: { time: 0 } });
|
||||
assert(unlocked(h, "first_birth"), "first birth did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const birthWorld = { time: 0 };
|
||||
for (let i = 0; i < 29; i += 1) { birthWorld.time = i; h.api.recordBirth({ world: birthWorld }); }
|
||||
assert(!unlocked(h, "birth_50_in_60_seconds"), "rapid birth unlocked before 30");
|
||||
birthWorld.time = 59;
|
||||
h.api.recordBirth({ world: birthWorld });
|
||||
assert(unlocked(h, "birth_50_in_60_seconds"), "rapid birth did not unlock at 30");
|
||||
|
||||
storage = new Map();
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 2000; i += 1) h.api.recordBirth({ world: { time: i % 61 } });
|
||||
assert(!unlocked(h, "great_mother_1000_births"), "great mother unlocked before 3333");
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 1332; i += 1) h.api.recordBirth({ world: { time: i % 61 } });
|
||||
assert(!unlocked(h, "great_mother_1000_births"), "great mother unlocked at 3332");
|
||||
h.api.recordBirth({ world: { time: 1 } });
|
||||
assert(unlocked(h, "great_mother_1000_births"), "great mother did not persist to 3333");
|
||||
|
||||
h = createHarness();
|
||||
const deathWorld = { time: 0 };
|
||||
for (let i = 0; i < 49; i += 1) { deathWorld.time = i * 0.19; h.api.recordDeath({ world: deathWorld, reason: "accident" }); }
|
||||
assert(!unlocked(h, "death_50_in_10_seconds"), "rapid death unlocked before 50");
|
||||
deathWorld.time = 9.5;
|
||||
h.api.recordDeath({ world: deathWorld, reason: "accident" });
|
||||
assert(unlocked(h, "death_50_in_10_seconds"), "rapid death did not unlock at 50");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordDeath({ world: { time: 1 }, tarinai: {}, reason: "天寿を全うした" });
|
||||
assert(unlocked(h, "lifespan_completed"), "lifespan did not unlock");
|
||||
h = createHarness();
|
||||
h.api.recordDeath({ world: { time: 1 }, tarinai: { _achievementLaxativeActiveThisFrame: true }, reason: "飢餓" });
|
||||
assert(unlocked(h, "laxative_starvation"), "laxative starvation did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const slaveWorld = {};
|
||||
[1, 2, 4, 5, 6].forEach(generation => h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation } }));
|
||||
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "slave run ignored generation gap");
|
||||
h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation: 3 } });
|
||||
assert(unlocked(h, "natural_zunchi_slave_5_generations"), "slave five-generation run did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const kingWorld = {};
|
||||
[4, 5, 6].forEach(generation => h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: { generation } }));
|
||||
assert(unlocked(h, "natural_tarinai_king_3_generations"), "king three-generation run did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const feedWorldA = { time: 0, achievementDirectFeedCount: 0 };
|
||||
for (let i = 0; i < 32; i += 1) h.api.recordDirectFeed({ world: feedWorldA });
|
||||
assert(!unlocked(h, "direct_feed_33"), "direct feed unlocked before 33");
|
||||
const feedWorldB = { time: 0, achievementDirectFeedCount: 0 };
|
||||
h.api.recordDirectFeed({ world: feedWorldB });
|
||||
assert(!unlocked(h, "direct_feed_33"), "direct-feed count survived a field reset");
|
||||
h.api.recordDirectFeed({ world: feedWorldA });
|
||||
assert(unlocked(h, "direct_feed_33"), "direct feed did not unlock at 33 in one field");
|
||||
|
||||
h = createHarness();
|
||||
const diseased = Array.from({ length: 25 }, () => ({ fightDisease: true, dead: false }));
|
||||
h.api.evaluateWorld({ tarinai: diseased, ants: [], time: 0 }, { id: "relaxed" });
|
||||
assert(unlocked(h, "all_non_sleep_diseased_25"), "disease colony did not unlock");
|
||||
h = createHarness();
|
||||
const happyWorld = { tarinai: [{ dead: false }], ants: [], items: [], time: 600, day: 6 };
|
||||
h.api.evaluateWorld(happyWorld, { id: "happy" });
|
||||
happyWorld.time = 719.99;
|
||||
h.api.evaluateWorld(happyWorld, { id: "happy" });
|
||||
assert(!unlocked(h, "colony_happy"), "happy colony unlocked before one day");
|
||||
happyWorld.time = 720;
|
||||
h.api.evaluateWorld(happyWorld, { id: "happy" });
|
||||
assert(unlocked(h, "colony_happy"), "happy colony did not unlock after one day from day 6");
|
||||
|
||||
h = createHarness();
|
||||
const interruptedHappyWorld = { tarinai: [{ dead: false }], ants: [], items: [], time: 600, day: 6 };
|
||||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||||
interruptedHappyWorld.time = 650;
|
||||
h.api.evaluateWorld(interruptedHappyWorld, { id: "normal" });
|
||||
interruptedHappyWorld.time = 700;
|
||||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||||
interruptedHappyWorld.time = 819.99;
|
||||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||||
assert(!unlocked(h, "colony_happy"), "interrupted happiness did not restart the one-day timer");
|
||||
interruptedHappyWorld.time = 820;
|
||||
h.api.evaluateWorld(interruptedHappyWorld, { id: "happy" });
|
||||
assert(unlocked(h, "colony_happy"), "happy colony did not unlock one full day after restart");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordIgnition({});
|
||||
h.api.recordSelfZunchiDeath({});
|
||||
h.api.recordSoccerBallDeath({});
|
||||
assert(unlocked(h, "ignite_during_birth_ritual"), "ritual ignition failed");
|
||||
assert(unlocked(h, "self_zunchi_death"), "self zunchi failed");
|
||||
assert(unlocked(h, "soccer_ball_death"), "soccer death failed");
|
||||
|
||||
|
||||
h = createHarness();
|
||||
const dangerItem = { id: "danger-1", type: "firecracker", roles: { danger: true } };
|
||||
const fighterA = { id: "a", dead: true, fightTimer: 2, fightTargetId: "b", fightTargetIds: ["b"] };
|
||||
const fighterB = { id: "b", dead: false, fightTimer: 2, fightTargetId: "a", fightTargetIds: ["a"] };
|
||||
const fightWorld = {
|
||||
time: 5,
|
||||
tarinai: [fighterA, fighterB],
|
||||
fightPairKey(a, b) { return [a.id, b.id].sort().join(":"); },
|
||||
isAlreadyFightingPair() { return true; },
|
||||
};
|
||||
fighterA.world = fightWorld; fighterB.world = fightWorld;
|
||||
h.api.recordDamageSource(fighterA, dangerItem, { world: fightWorld });
|
||||
h.api.recordDeath({ world: fightWorld, tarinai: fighterA, reason: "爆竹", fromDamage: true });
|
||||
assert(!unlocked(h, "fight_pair_danger_kill"), "fight pair unlocked after only one death");
|
||||
fightWorld.time = 6;
|
||||
fighterB.dead = true; fighterB.fightTimer = 0; fighterB.fightTargetId = null; fighterB.fightTargetIds = [];
|
||||
h.api.recordDamageSource(fighterB, dangerItem, { world: fightWorld });
|
||||
h.api.recordDeath({ world: fightWorld, tarinai: fighterB, reason: "爆竹", fromDamage: true });
|
||||
assert(unlocked(h, "fight_pair_danger_kill"), "fight pair danger kill did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const falsePositiveA = { id: "fa", dead: true, fightTimer: 2, fightTargetId: "fb", fightTargetIds: ["fb"] };
|
||||
const falsePositiveB = { id: "fb", dead: false, fightTimer: 2, fightTargetId: "fa", fightTargetIds: ["fa"] };
|
||||
const falsePositiveWorld = {
|
||||
time: 3,
|
||||
tarinai: [falsePositiveA, falsePositiveB],
|
||||
fightPairKey(a, b) { return [a.id, b.id].sort().join(":"); },
|
||||
isAlreadyFightingPair() { return true; },
|
||||
};
|
||||
falsePositiveA.world = falsePositiveWorld; falsePositiveB.world = falsePositiveWorld;
|
||||
h.api.recordDamageSource(falsePositiveA, dangerItem, { world: falsePositiveWorld });
|
||||
h.api.recordDeath({ world: falsePositiveWorld, tarinai: falsePositiveA, reason: "飢餓", fromDamage: false });
|
||||
falsePositiveWorld.time = 4;
|
||||
falsePositiveB.dead = true;
|
||||
h.api.recordDamageSource(falsePositiveB, dangerItem, { world: falsePositiveWorld });
|
||||
h.api.recordDeath({ world: falsePositiveWorld, tarinai: falsePositiveB, reason: "爆竹", fromDamage: true });
|
||||
assert(!unlocked(h, "fight_pair_danger_kill"), "non-lethal danger damage was misclassified as a danger kill");
|
||||
|
||||
h = createHarness();
|
||||
const generationWorld = { time: 0, maxGeneration: 9 };
|
||||
h.api.recordBirth({ world: generationWorld, child: { generation: 9, world: generationWorld } });
|
||||
assert(!unlocked(h, "eternal_history_generation_10"), "generation history unlocked before generation 10");
|
||||
generationWorld.maxGeneration = 10;
|
||||
h.api.recordBirth({ world: generationWorld, child: { generation: 10, world: generationWorld } });
|
||||
assert(unlocked(h, "eternal_history_generation_10"), "generation history did not unlock at generation 10");
|
||||
|
||||
h = createHarness();
|
||||
const king = { dead: false, isTarinaiChampion: true, isZunchiSlave: false, powerItemMode: "protein", sizeItemMode: "", world: {} };
|
||||
h.api.recordConsumableUse({ tarinai: king, world: king.world, type: "protein" });
|
||||
assert(!unlocked(h, "king_full_satisfaction"), "king satisfaction unlocked without giant drug");
|
||||
king.sizeItemMode = "giant_drug";
|
||||
h.api.recordConsumableUse({ tarinai: king, world: king.world, type: "giant_drug" });
|
||||
assert(unlocked(h, "king_full_satisfaction"), "king satisfaction did not unlock with both effects");
|
||||
|
||||
h = createHarness();
|
||||
const slave = { dead: false, isTarinaiChampion: false, isZunchiSlave: true, powerItemMode: "niteropu", sizeItemMode: "", world: {} };
|
||||
h.api.recordConsumableUse({ tarinai: slave, world: slave.world, type: "niteropu" });
|
||||
assert(!unlocked(h, "slave_zero_satisfaction"), "slave satisfaction unlocked without dwarf drug");
|
||||
slave.sizeItemMode = "dwarf_drug";
|
||||
h.api.recordConsumableUse({ tarinai: slave, world: slave.world, type: "dwarf_drug" });
|
||||
assert(unlocked(h, "slave_zero_satisfaction"), "slave satisfaction did not unlock with both effects");
|
||||
|
||||
h = createHarness();
|
||||
const allExceptCompletionist = expectedIds.filter(id => id !== "true_tarinai_observer");
|
||||
for (let i = 0; i < allExceptCompletionist.length - 1; i += 1) h.api.unlock(allExceptCompletionist[i]);
|
||||
assert(!unlocked(h, "true_tarinai_observer"), "completionist unlocked before every other achievement");
|
||||
h.api.unlock(allExceptCompletionist[allExceptCompletionist.length - 1]);
|
||||
assert(unlocked(h, "true_tarinai_observer"), "completionist did not unlock with every other achievement");
|
||||
|
||||
h = createHarness();
|
||||
const idleWorld = { time: 300, tarinai: [], ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||
h.api.evaluateWorld(idleWorld, { id: "relaxed" });
|
||||
assert(unlocked(h, "idle_observer_5_minutes"), "idle observer did not unlock");
|
||||
|
||||
storage = new Map();
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 60; i += 1) h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
|
||||
assert(!unlocked(h, "placed_objects_100"), "placement unlocked before 100");
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 39; i += 1) h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
|
||||
assert(!unlocked(h, "placed_objects_100"), "placement unlocked at 99");
|
||||
h.api.recordPlayerPlacement({ world: { time: 1 }, item: { type: "grass" } });
|
||||
assert(unlocked(h, "placed_objects_100"), "placement did not persist across field reset/reload to 100");
|
||||
|
||||
storage = new Map();
|
||||
h = createHarness(storage);
|
||||
h.api.recordLinkPlaced("wire", { world: { time: 1 }, item: { type: "wire" } });
|
||||
h.api.recordLinkPlaced("insulated_wire", { world: { time: 2 }, item: { type: "insulated_wire" } });
|
||||
const wireProgress = JSON.parse(storage.get("tarinai_achievements_v2")).progress;
|
||||
assert(wireProgress.placementCount === 2, "wire placements were not counted toward placed objects");
|
||||
assert(wireProgress.linkTypes.length === 0, "wire placements incorrectly advanced mechanized link types");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordLinkPlaced("rope", { world: { time: 1 } });
|
||||
h.api.recordLinkPlaced("rod", { world: { time: 1 } });
|
||||
h.api.recordLinkPlaced("spring", { world: { time: 1 } });
|
||||
assert(!unlocked(h, "mechanized_industry"), "mechanized industry unlocked without signal");
|
||||
h.api.recordSignalActivation({ world: {}, target: { type: "gate_fence" } });
|
||||
assert(unlocked(h, "mechanized_industry"), "mechanized industry did not unlock after logical AND");
|
||||
assert(!unlocked(h, "signal_automation_first") && !unlocked(h, "link_craftsman"), "removed achievements remain accessible");
|
||||
|
||||
h = createHarness();
|
||||
const safeAlive = Array.from({ length: 25 }, () => ({ dead: false }));
|
||||
const safeWorld = { time: 0, tarinai: safeAlive, ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
|
||||
safeWorld.time = 300;
|
||||
h.api.evaluateWorld(safeWorld, { id: "relaxed" });
|
||||
assert(unlocked(h, "safe_colony_25_5_minutes"), "safe colony did not unlock");
|
||||
|
||||
h = createHarness();
|
||||
const antWorld = { time: 1, tarinai: [], ants: Array.from({ length: 25 }, () => ({ dead: false })), achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||
h.api.evaluateWorld(antWorld, { id: "relaxed" });
|
||||
assert(unlocked(h, "ants_alive_25"), "ant herd did not unlock at 25");
|
||||
|
||||
storage = new Map();
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 65; i += 1) h.api.recordAntKilled({ world: {} });
|
||||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked before 100");
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 34; i += 1) h.api.recordAntKilled({ world: {} });
|
||||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked at 99");
|
||||
h.api.recordAntKilled({ world: {} });
|
||||
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordSaveSlotsFilled({ filled: 8 });
|
||||
assert(!unlocked(h, "secret_collection_9_slots"), "save-slot collection unlocked before 9");
|
||||
h.api.recordSaveSlotsFilled({ filled: 9 });
|
||||
assert(unlocked(h, "secret_collection_9_slots"), "save-slot collection did not unlock at 9");
|
||||
|
||||
h = createHarness();
|
||||
[0, 250, 500].forEach(now => h.api.recordPauseClick({ now }));
|
||||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked before four clicks");
|
||||
h.api.recordPauseClick({ now: 999 });
|
||||
assert(unlocked(h, "pause_spam_4_in_1_second"), "pause-spam did not unlock within one second");
|
||||
h = createHarness();
|
||||
[0, 400, 800, 1201].forEach(now => h.api.recordPauseClick({ now }));
|
||||
assert(!unlocked(h, "pause_spam_4_in_1_second"), "pause-spam unlocked outside one-second window");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordContinuousPlay({ elapsedMs: 3599999 });
|
||||
assert(!unlocked(h, "continuous_play_1_hour"), "continuous play unlocked before one hour");
|
||||
h.api.recordContinuousPlay({ elapsedMs: 3600000 });
|
||||
assert(unlocked(h, "continuous_play_1_hour"), "continuous play did not unlock at one hour");
|
||||
|
||||
h = createHarness();
|
||||
const thermalWorld = {
|
||||
time: 0, tarinai: [], ants: [], items: [{ id: "cold", x: 10, y: 20, dead: false }],
|
||||
temperatureAt() { return -273.15; }, achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1,
|
||||
};
|
||||
h.api.evaluateWorld(thermalWorld, { id: "relaxed" });
|
||||
assert(!unlocked(h, "below_absolute_zero_item"), "absolute-zero achievement unlocked at exactly absolute zero");
|
||||
thermalWorld.temperatureAt = () => -273.16;
|
||||
h.api.evaluateWorld(thermalWorld, { id: "relaxed" });
|
||||
assert(unlocked(h, "below_absolute_zero_item"), "absolute-zero achievement did not unlock below absolute zero");
|
||||
|
||||
storage = new Map();
|
||||
storage.set("tarinai_achievements_v2", JSON.stringify({
|
||||
unlocked: { signal_automation_first: 1000, link_craftsman: 2000 },
|
||||
pending: ["signal_automation_first", "link_craftsman"],
|
||||
progress: { linkTypes: ["rope", "rod", "spring"] },
|
||||
}));
|
||||
h = createHarness(storage);
|
||||
assert(unlocked(h, "mechanized_industry"), "legacy AND achievements were not migrated");
|
||||
|
||||
storage = new Map();
|
||||
storage.set("tarinai_achievements_v2", JSON.stringify({
|
||||
unlocked: { link_craftsman: 2000 },
|
||||
pending: [],
|
||||
progress: {},
|
||||
}));
|
||||
h = createHarness(storage);
|
||||
h.api.recordSignalActivation({ world: {}, target: { type: "gate_fence" } });
|
||||
assert(unlocked(h, "mechanized_industry"), "legacy link-craftsman progress was not reconstructed before a later signal");
|
||||
|
||||
storage = new Map();
|
||||
h = createHarness(storage, "?debug=1");
|
||||
assert(h.elements.get("achievementUnlockAllBtn").hidden === false, "debug all-achievements button was not shown");
|
||||
h.api.debugUnlockAll();
|
||||
assert(h.api.definitions.every(def => unlocked(h, def.id)), "debug all-achievements button did not unlock all");
|
||||
const debugState = JSON.parse(storage.get("tarinai_achievements_v2"));
|
||||
assert(debugState.pending.length === 0, "debug unlocks were queued for shared statistics");
|
||||
assert(debugState.debugUnlocked.length === h.api.definitions.length, "debug unlocks were not marked as local-only");
|
||||
h = createHarness(storage, "");
|
||||
assert(h.api.definitions.every(def => !unlocked(h, def.id)), "debug-only unlocks leaked into normal play");
|
||||
|
||||
storage = new Map();
|
||||
storage.set("tarinai_achievements_v2", JSON.stringify({
|
||||
unlocked: { first_birth: 1700000000123, lifespan_completed: 1700000000456 },
|
||||
pending: [],
|
||||
progress: {},
|
||||
}));
|
||||
const recoveryRequests = [];
|
||||
h = createHarness(storage, "", {
|
||||
protocol: "https:",
|
||||
fetch: async (_url, options = {}) => {
|
||||
recoveryRequests.push(JSON.parse(options.body || "{}"));
|
||||
return { ok: true, async json() { return { ok: true, totalPlayers: 1, achievements: {} }; } };
|
||||
},
|
||||
});
|
||||
await h.api.refresh();
|
||||
assert(recoveryRequests.length === 1, "recovery sync did not issue exactly one request");
|
||||
assert(recoveryRequests[0].action === "sync", "recovery request did not use bulk sync");
|
||||
assert(recoveryRequests[0].unlocked.first_birth === 1700000000123, "recovery sync omitted first birth timestamp");
|
||||
assert(recoveryRequests[0].unlocked.lifespan_completed === 1700000000456, "recovery sync omitted lifespan timestamp");
|
||||
|
||||
h = createHarness();
|
||||
for (let i = 0; i < 49; i += 1) h.api.recordFirstAidRecovery({ recovered: 10 });
|
||||
assert(!unlocked(h, "town_doctor_50"), "doctor unlocked before 50 recoveries");
|
||||
h.api.recordFirstAidRecovery({ recovered: 0 });
|
||||
assert(!unlocked(h, "town_doctor_50"), "zero recovery counted for doctor");
|
||||
h.api.recordFirstAidRecovery({ recovered: 1 });
|
||||
assert(unlocked(h, "town_doctor_50"), "doctor did not unlock at 50 recoveries");
|
||||
|
||||
h = createHarness();
|
||||
h.api.recordPlushiePokeFling({});
|
||||
assert(unlocked(h, "poke_plushie_fling"), "plushie poke achievement did not unlock");
|
||||
|
||||
storage = new Map();
|
||||
h = createHarness(storage);
|
||||
const chaosWorld = { time: 0 };
|
||||
for (let i = 0; i < 400; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
|
||||
h = createHarness(storage);
|
||||
for (let i = 0; i < 265; i += 1) h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
|
||||
assert(!unlocked(h, "chaos_seeker_666_fights"), "chaos seeker unlocked at 665 fights");
|
||||
h.api.recordFightStarted({ world: chaosWorld, a: {}, b: {} });
|
||||
assert(unlocked(h, "chaos_seeker_666_fights"), "chaos seeker did not persist to 666 fights");
|
||||
|
||||
h = createHarness();
|
||||
const cleaner = { type: "robot_cleaner", dead: false, robotCleanerMask: 0x3f, robotCleanerHighSpeed: true, _signalDriven: true, _signalOn: false };
|
||||
const cleanWorld = { items: [cleaner], tarinai: [], ants: [], time: 0 };
|
||||
h.api.evaluateCleanFreak(cleanWorld);
|
||||
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak unlocked while signal-powered cleaner was off");
|
||||
cleaner._signalOn = true;
|
||||
cleanWorld.tarinai.push({ dead: false });
|
||||
h.api.evaluateCleanFreak(cleanWorld);
|
||||
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak ignored a living tarinai");
|
||||
cleanWorld.tarinai.length = 0;
|
||||
cleanWorld.items.push({ type: "grass", dead: false });
|
||||
h.api.evaluateCleanFreak(cleanWorld);
|
||||
assert(!unlocked(h, "clean_freak_robot_only"), "clean freak ignored another field item");
|
||||
cleanWorld.items.pop();
|
||||
h.api.evaluateCleanFreak(cleanWorld);
|
||||
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
|
||||
|
||||
console.log("[OK] all 51 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
106
scripts/achievement_integration_audit.py
Normal file
106
scripts/achievement_integration_audit.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def read(name):
|
||||
return (ROOT / name).read_text()
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
ach = read('js/achievements.js')
|
||||
api = read('achievement_api.php')
|
||||
css = read('css/components.css')
|
||||
|
||||
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
|
||||
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
|
||||
require(len(ids_js) == 51, 'JS achievement count is not 51')
|
||||
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
|
||||
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
|
||||
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
|
||||
require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing')
|
||||
require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing')
|
||||
require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing')
|
||||
require(all(item in ids_js for item in ['eternal_history_generation_10','king_full_satisfaction','slave_zero_satisfaction','town_doctor_50','poke_plushie_fling','chaos_seeker_666_fights','clean_freak_robot_only','true_tarinai_observer']), 'v56 achievement IDs are missing')
|
||||
require(all(item in ids_js for item in ['undo_mass_revival','robot_cleaner_100','held_30_seconds','minimalist_happy','overprotective','self_sufficient','unplanned_city_30','sauna_cold_plunge','rain_shelter_all','medicine_ledger_all','mercury_lifespan','enemy_enemy_friend','revolution','fuel_to_fire','undo_20','redo_20']), 'playstyle achievement IDs are missing')
|
||||
|
||||
checks = {
|
||||
'birth event': 'recordBirth?.({ world: this, child, parents: [a, b] })' in read('js/world_family_social.js'),
|
||||
'death event after final reason': 'recordDeath?.({ world: this.world, tarinai: this, reason: finalReason, fromDamage: Boolean(opts.fromDamage), achievementDangerKill: Boolean(opts.achievementDangerKill) })' in read('js/tarinai_social_move_life.js'),
|
||||
'natural slave event only in fight-record path': 'recordNaturalZunchiSlave?.({ world: this, tarinai, other, wins, losses, total, winRate })' in read('js/world_combat_effects.js'),
|
||||
'natural king event only in fight-record path': 'recordNaturalTarinaiKing?.({ world: this, tarinai, other, wins, losses, total, winRate })' in read('js/world_combat_effects.js'),
|
||||
'direct feed is food-only': 'if (foodGift) {' in read('js/tarinai_direct_feeding_system.js') and 'recordDirectFeed?.' in read('js/tarinai_direct_feeding_system.js'),
|
||||
'ritual ignition checks state before mutation': 'const ignitedDuringBirthRitual = !already' in read('js/tarinai_disease_nest.js'),
|
||||
'soccer unlock follows fatal ball impact': 'if (t.dead && ball.type === "ball")' in read('js/collision_response_system.js'),
|
||||
'self-zunchi unlock checks owner and never-stopped': 'item.achievementBurstOwnerId === t.id && item.achievementBurstNeverStopped !== false' in read('js/item_dynamic_zunchi_system.js'),
|
||||
'held launched zunchi is disqualified': 'if (item.playerHeld || item._heldByPlayer)' in read('js/item_dynamic_zunchi_system.js') and 'target.achievementBurstNeverStopped = false' in read('js/ui_input_shared.js'),
|
||||
'generation runs persist in saves': 'achievementNaturalSlaveGenerations' in read('js/snapshot_system.js') and 'achievementNaturalKingGenerations' in read('js/snapshot_system.js'),
|
||||
'direct-feed field progress persists in saves': 'achievementDirectFeedCount' in read('js/snapshot_system.js'),
|
||||
'direct-feed field progress resets with world': 'this.achievementDirectFeedCount = 0;' in read('js/world.js'),
|
||||
'simultaneous toast queue exists': 'let toastQueue = [];' in ach and 'runNextUnlockToast' in ach,
|
||||
'achievement toast is top-left': 'top: 18px;' in css and 'left: 18px;' in css and 'right: auto;' in css,
|
||||
'laxative frame-edge marker exists': '_achievementLaxativeActiveThisFrame' in read('js/tarinai_update_step_frame.js') and '_achievementLaxativeActiveThisFrame' in ach,
|
||||
'disease condition excludes sleep-only': 't.zunchiDisease || t.explosionDisease || t.fightDisease' in ach,
|
||||
'locked condition display policy': 'lockedDescriptionText' in ach and '\\uff1f\\uff1f\\uff1f' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach,
|
||||
'condition shown beside title': 'heading.append(title, description);' in ach,
|
||||
'achievement tooltips removed': 'TarinaiTooltips' not in ach and 'dataset.tip' not in ach and 'achievementTooltipText' not in ach,
|
||||
'percentage background bar including unlocked cards': '--achievement-rate' in ach and '.achievement-entry::before' in css and 'width: var(--achievement-rate);' in css and '.achievement-entry > *' in css and 'z-index: 0;' in css,
|
||||
'achievement category folds': 'ACHIEVEMENT_CATEGORIES' in ach and 'createAchievementGroup' in ach and 'achievement-group' in css,
|
||||
'category-local unlocked ordering': 'orderedDefinitions = [...unlockedDefinitions, ...lockedDefinitions]' in ach and 'id: "completed"' not in ach,
|
||||
'requested pre-unlock disclosures': all(item in ach.split('const PRE_UNLOCK_DESCRIPTION_IDS', 1)[1].split(']);', 1)[0] for item in ['natural_zunchi_slave','natural_tarinai_king','revolution','laxative_starvation']),
|
||||
'native browser confirmations removed': not re.search(r'\b(?:alert|confirm|prompt)\s*\(', ach + read('js/save_system.js')) and 'TarinaiGameDialogs' in ach and 'TarinaiGameDialogs' in read('js/save_system.js'),
|
||||
'achiever count beside percentage': 'unlockedPlayers' in ach and 'sharedAchievementText' in ach and '\\u9054\\u6210\\u8005' in ach,
|
||||
'persistent placement count': 'progress.placementCount' in ach and 'unlock("placed_objects_100"' in ach,
|
||||
'mechanized logical AND': 'progress.signalActivated && hasAllLinks' in ach,
|
||||
'persistent ant kill count': 'progress.antKills' in ach and 'unlock("ants_killed_100"' in ach,
|
||||
'persistent reproduction count': 'progress.birthCount' in ach and 'progress.birthCount >= 3333' in ach and 'unlock("great_mother_1000_births"' in ach,
|
||||
'danger damage source pipeline': 'recordDamageSource' in ach and 'options.source' in read('js/health.js') and '{ source: it }' in read('js/world_combat_effects.js') and 'type: "shoot"' in read('js/world_tool_actions.js') and '_achievementDangerSource = pin' in read('js/world_combat_effects.js') and 'recordDamageSource?.(target, home' in read('js/world_ants_system.js'),
|
||||
'fight-pair danger tracker': 'recordFightPairDangerDeath' in ach and 'fight_pair_danger_kill' in ach and 'detail.fromDamage || detail.achievementDangerKill' in ach,
|
||||
'debug unlock-all button': 'achievementUnlockAllBtn' in read('index.html') and 'unlockAllAchievementsForDebug' in ach and 'DEBUG_MODE' in ach and 'debugUnlocked' in ach,
|
||||
'idle intervention hooks': 'recordIntervention?.({ world: this, tool: "poke"' in read('js/world_placement_log.js') and 'recordIntervention?.({ world, tool: "pinch"' in read('js/ui_input_shared.js') and 'recordIntervention?.({ world: this, tool: "shoot"' in read('js/world_tool_actions.js'),
|
||||
'link placement hook': 'recordLinkPlaced?.(constraintType' in read('js/world_tool_actions.js') and 'insulated_wire' in ach and 'recordPlayerPlacement(detail);' in ach,
|
||||
'signal activation hook': 'recordSignalActivation?.({ world: worldRef' in read('js/signal_system.js'),
|
||||
'ant kill hook': 'recordAntKilled?.({ world: this.world, ant: this' in read('js/ants.js'),
|
||||
'ant population spawn hook': read('js/world_ants_system.js').count('recordAntPopulation?.(this)') >= 2,
|
||||
'ant herd threshold': 'if (aliveAnts >= 25)' in ach,
|
||||
'safe colony timer': 'now - safeStart >= 300' in ach,
|
||||
'idle observer timer': 'now - lastInterventionAt >= 300' in ach,
|
||||
'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" in api,
|
||||
'automatic aggregate recovery sync': 'localUnlockSnapshot' in ach and 'action: "sync"' in ach and "['session', 'unlock', 'sync', 'reset', 'summary']" in api and "$action === 'sync'" in api,
|
||||
'save-slot collection hook': 'recordSaveSlotsFilled' in ach and 'recordSaveSlotsFilled?.' in read('js/save_storage.js'),
|
||||
'pause-spam hook': 'pauseButton' in ach and 'recordPauseClick' in ach and 'pauseBtn' in read('index.html'),
|
||||
'continuous play heartbeat': 'CONTINUOUS_PLAY_TARGET_MS' in ach and 'recordContinuousPlay' in ach and 'visibilitychange' in ach,
|
||||
'absolute-zero item evaluation': 'ABSOLUTE_ZERO_C' in ach and 'recordBelowAbsoluteZeroItem(worldRef)' in ach and 'temperatureAt?.(item.x, item.y, null)' in ach,
|
||||
'recovery sync is additive': "if (isset($state['unlocks'][$achievementId][$playerId])) continue;" in api,
|
||||
'debug unlocks excluded from recovery': 'state.debugUnlocked?.includes?.(definition.id)' in ach,
|
||||
'hidden achievement UI skips list rebuild': 'dom.dialog?.classList.contains("hidden")' in ach and 'achievementListSignature' in ach,
|
||||
'achievement category definitions are cached': 'CATEGORY_DEFINITIONS' in ach,
|
||||
'achievement sync is debounced and pending-only': 'SYNC_DEBOUNCE_MS' in ach and 'schedulePendingSync' in ach and 'pendingIds' in ach,
|
||||
'completionist excludes itself': 'definition.id === COMPLETIONIST_ID' in ach and 'allOtherAchievementsUnlocked' in ach,
|
||||
'generation ten is event-driven': 'generation >= GENERATION_TARGET' in ach and 'eternal_history_generation_10' in ach,
|
||||
'status effect combinations are event-driven': 'evaluateStatusEffectAchievements' in ach and 'king_full_satisfaction' in ach and 'slave_zero_satisfaction' in ach,
|
||||
'doctor counts actual recovery only': 'recordFirstAidRecovery' in ach and 'recovered > 0.0001' in read('js/item_effect_definitions.js'),
|
||||
'plushie poke uses shared target and overlay': 'findPokeTargetAt' in read('js/world_placement_log.js') and 'hoverPokeTarget' in read('js/ui_input_shared.js') and 'recordPlushiePokeFling' in ach,
|
||||
'persistent chaos fight counter': 'progress.fightCount' in ach and 'CHAOS_FIGHT_TARGET = 666' in ach and 'recordFightStarted' in ach,
|
||||
'clean-freak robot-only evaluation': 'evaluateCleanFreak' in ach and 'ROBOT_ALL_TARGET_MASK = 0x3f' in ach and 'robotCleanerHighSpeed' in ach and 'signal-power' in read('js/signal_system.js'),
|
||||
'medicine ledger excludes chain and crown': 'slave_chain' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0] and 'golden_crown' not in ach.split('const MEDICINE_LEDGER_TYPES', 1)[1].split(']);', 1)[0],
|
||||
'cleaner target raised to 200': 'ROBOT_CLEAN_TARGET = 200' in ach and r'\u7d2f\u8a08200\u500b' in ach,
|
||||
'minimalist starts on day six': 'day >= 6 && !state.unlocked.minimalist_happy' in ach,
|
||||
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
|
||||
'compact server state v2': 'function compact_state' in api and "['v' => 2, 'p' => $players, 'u' => $unlocks]" in api,
|
||||
}
|
||||
for name, ok in checks.items():
|
||||
require(ok, name)
|
||||
|
||||
social = read('js/world_family_social.js') + read('js/tarinai_social_action_runtime.js')
|
||||
require('!!a.isZunchiSlave !== !!b.isZunchiSlave' in social or '!!t.isZunchiSlave === !!other.isZunchiSlave' in social, 'slave mating rule not found')
|
||||
require('recordNaturalGeneration(world, tarinai, "achievementNaturalSlaveGenerations", 5)' in ach, 'slave achievement still uses impossible direct-line logic')
|
||||
require('\\u52dd\\u738730%\\u4ee5\\u4e0b' in read('js/world_combat_effects.js'), 'slave fight-record text does not state the 30% threshold')
|
||||
require('legacyLinkTypes' in ach, 'legacy link-craftsman partial migration is missing')
|
||||
|
||||
print('[OK] achievement integration audit passed')
|
||||
for name in checks:
|
||||
print(f'[OK] {name}')
|
||||
58
scripts/achievement_save_metadata_audit.js
Normal file
58
scripts/achievement_save_metadata_audit.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vm = require("vm");
|
||||
const root = path.join(__dirname, "..");
|
||||
const context = {
|
||||
console,
|
||||
Date,
|
||||
Math,
|
||||
JSON,
|
||||
Object,
|
||||
Array,
|
||||
Map,
|
||||
Set,
|
||||
Uint8Array,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
URLSearchParams,
|
||||
performance,
|
||||
location: { search: "" },
|
||||
CompressionStream: undefined,
|
||||
DecompressionStream: undefined,
|
||||
TarinaiSnapshot: { version: 35 },
|
||||
TarinaiSaveSchema: {
|
||||
BINARY_SCHEMA_VERSION: 41,
|
||||
FIELD_IDS: ["garden"],
|
||||
itemTypeValue(_id, fallback = "") { return fallback; },
|
||||
},
|
||||
};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(root, "js", "save_codec.js"), "utf8"), context, { filename: "save_codec.js" });
|
||||
|
||||
(async () => {
|
||||
const metadata = {
|
||||
w: [[1, 2], [3], 12, 87, 6, 145.5, 20, 18, 100, 90],
|
||||
t: [[10, 3, 120, 130]],
|
||||
i: [[1, 121]],
|
||||
};
|
||||
const snapshot = {
|
||||
v: 35,
|
||||
a: "tj1",
|
||||
m: [1, 0, "garden"],
|
||||
w: [0, 0, 0, 120, 0, -9990, 1, 0, "seed", 0, 0, 0],
|
||||
t: [],
|
||||
i: [],
|
||||
g: metadata,
|
||||
};
|
||||
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(snapshot);
|
||||
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
|
||||
if (JSON.stringify(decoded.g) !== JSON.stringify(metadata)) throw new Error("achievement metadata changed during binary save roundtrip");
|
||||
console.log("[OK] achievement metadata survives current binary save roundtrip");
|
||||
})().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
186
scripts/playstyle_achievement_audit.js
Normal file
186
scripts/playstyle_achievement_audit.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
const source = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
|
||||
|
||||
class ClassList {
|
||||
constructor() { this.values = new Set(["hidden"]); }
|
||||
add(...names) { names.forEach(name => this.values.add(name)); }
|
||||
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||||
contains(name) { return this.values.has(name); }
|
||||
toggle(name, force) { const next = force === undefined ? !this.values.has(name) : Boolean(force); if (next) this.values.add(name); else this.values.delete(name); return next; }
|
||||
}
|
||||
class Element {
|
||||
constructor(id = "") { this.id = id; this.className = ""; this.classList = new ClassList(); this.dataset = {}; this.styles = new Map(); this.style = { setProperty: (n,v)=>this.styles.set(n,v), removeProperty:n=>this.styles.delete(n) }; this.children=[]; this.textContent=""; this.tabIndex=-1; this.isConnected=true; this.hidden=true; }
|
||||
append(...children){this.children.push(...children);} replaceChildren(...children){this.children=children;} setAttribute(){} addEventListener(){} focus(){} getBoundingClientRect(){return{left:0,top:0,width:100,height:30};} get offsetWidth(){return 100;}
|
||||
}
|
||||
function harness(storage = new Map()) {
|
||||
const elements = new Map();
|
||||
["achievementsBtn","achievementButtonCount","achievementsDialog","achievementDialogCount","achievementsCloseBtn","achievementsCloseIconBtn","achievementRefreshBtn","achievementResetBtn","achievementUnlockAllBtn","achievementList","achievementSharedStatus","achievementToast","achievementToastTitle","pauseBtn"].forEach(id=>elements.set(id,new Element(id)));
|
||||
let timer=0;
|
||||
const c={console,Date,Intl,Math,JSON,Object,Array,Map,Set,WeakMap,Promise,Number,String,Boolean,Error,AbortController,encodeURIComponent,URLSearchParams,
|
||||
location:{protocol:"file:",search:""},crypto:{randomUUID:()=>"11111111-1111-4111-8111-111111111111"},
|
||||
localStorage:{getItem:k=>storage.has(k)?storage.get(k):null,setItem:(k,v)=>storage.set(k,String(v)),removeItem:k=>storage.delete(k)},
|
||||
document:{hidden:false,getElementById:id=>elements.get(id)||null,createElement:()=>new Element(),addEventListener(){}},
|
||||
TarinaiEvents:{emit(){}},showToast(){},TarinaiGameDialogs:{confirm:async()=>true},audio:{uiClick(){}},setTimeout(){return ++timer;},clearTimeout(){},setInterval(){return ++timer;},clearInterval(){},fetch:async()=>{throw new Error("offline");},TARINAI_VERSION:"39.16.56"};
|
||||
c.window=c;c.globalThis=c;vm.createContext(c);vm.runInContext(source,c,{filename:"achievements.js"});
|
||||
return {c,api:c.TarinaiAchievements,storage,elements};
|
||||
}
|
||||
function assert(v,m){if(!v)throw new Error(m);} function off(h,id){return !h.api.isUnlocked(id);} function on(h,id){return h.api.isUnlocked(id);}
|
||||
function alive(n){return Array.from({length:n},(_,i)=>({id:`t${i}`,dead:false,x:i,y:0}));}
|
||||
function findCard(h,id){const stack=[h.elements.get("achievementList")];while(stack.length){const node=stack.shift();if(node?.dataset?.achievementId===id)return node;stack.push(...(node?.children||[]));}return null;}
|
||||
|
||||
(function run(){
|
||||
let h=harness();
|
||||
assert(h.api.definitions.length===51,"must have 51 achievements");
|
||||
h.api.open();
|
||||
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
||||
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
|
||||
assert(title.mercury_lifespan==="不老不死まであと一歩","mercury title");
|
||||
const description=(id)=>findCard(h,id).children[1].children[0].children[1].textContent;
|
||||
assert(description("robot_cleaner_100").includes("ずんち・死骸・汚れを累計200個処理させる。"),"cleaner condition disclosed in UI");
|
||||
assert(description("mercury_lifespan").includes("水銀を使用したたりないが天寿を全うする。"),"mercury condition disclosed in UI");
|
||||
assert(findCard(h,"enemy_enemy_friend").children[1].children.some(node=>node.textContent.includes("現在の達成率 0.0%")),"enemy progress shown in UI");
|
||||
assert(description("overprotective")==="???","other new condition hidden");
|
||||
|
||||
// One Undo only: multiple smaller Undo operations never aggregate.
|
||||
h.api.recordHistoryAction("undo",{deadRestored:9}); h.api.recordHistoryAction("undo",{deadRestored:1});
|
||||
assert(off(h,"undo_mass_revival"),"mass undo aggregated across operations");
|
||||
h.api.recordHistoryAction("undo",{deadRestored:10}); assert(on(h,"undo_mass_revival"),"mass undo boundary");
|
||||
|
||||
// Undo/Redo counters persist across field resets and reloads.
|
||||
let storage=new Map(); h=harness(storage);
|
||||
for(let i=0;i<12;i++)h.api.recordHistoryAction("undo",{});
|
||||
h.api.resetWorldProgress({});
|
||||
h=harness(storage); for(let i=0;i<7;i++)h.api.recordHistoryAction("undo",{});
|
||||
assert(off(h,"undo_20"),"undo unlocked at 19"); h.api.recordHistoryAction("undo",{}); assert(on(h,"undo_20"),"undo did not persist to 20");
|
||||
for(let i=0;i<11;i++)h.api.recordHistoryAction("redo",{}); h=harness(storage); for(let i=0;i<8;i++)h.api.recordHistoryAction("redo",{});
|
||||
assert(off(h,"redo_20"),"redo unlocked at 19"); h.api.recordHistoryAction("redo",{}); assert(on(h,"redo_20"),"redo did not persist to 20");
|
||||
|
||||
// Cleaner is field-local.
|
||||
h=harness(); const wa={time:0},wb={time:0}; h.api.recordRobotClean({world:wa,type:"tarinai"}); assert((wa.achievementRobotCleanCount||0)===0,"cleaner counted non-cleanup target");
|
||||
for(let i=0;i<199;i++)h.api.recordRobotClean({world:wa,type:i%2?"zunchi":"trace"});
|
||||
assert(off(h,"robot_cleaner_100"),"cleaner before 200"); h.api.recordRobotClean({world:wb,type:"ant_corpse"}); assert(off(h,"robot_cleaner_100"),"cleaner crossed field reset");
|
||||
h.api.recordRobotClean({world:wa,type:"splat"}); assert(on(h,"robot_cleaner_100"),"cleaner at 200");
|
||||
|
||||
// Continuous pinch must be uninterrupted and same individual.
|
||||
h=harness(); const held={dead:false,playerHeld:true}; const hw={tarinai:[held]};
|
||||
h.api.recordHeldTarinai(hw,1000); h.api.recordHeldTarinai(hw,30999); assert(off(h,"held_30_seconds"),"held before 30s");
|
||||
held.playerHeld=false; h.api.recordHeldTarinai(hw,31000); held.playerHeld=true; h.api.recordHeldTarinai(hw,40000); h.api.recordHeldTarinai(hw,69999); assert(off(h,"held_30_seconds"),"held did not reset after release");
|
||||
h.c.document.hidden=true; h.api.recordHeldTarinai(hw,70000); h.c.document.hidden=false;
|
||||
h.api.recordHeldTarinai(hw,70001); h.api.recordHeldTarinai(hw,100000); assert(off(h,"held_30_seconds"),"hidden tab time counted toward hold");
|
||||
h.api.recordHeldTarinai(hw,100001); assert(on(h,"held_30_seconds"),"held at 30s after visible reset");
|
||||
|
||||
// Minimalist counts only surviving player placements.
|
||||
h=harness(); let mw={time:0,day:5,tarinai:alive(1),ants:[],items:Array.from({length:11},()=>({_achievementPlayerPlaced:true,dead:false}))};
|
||||
h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist allowed 11 items");
|
||||
mw.items[10].dead=true; h.api.evaluateWorld(mw,{id:"happy"}); assert(off(h,"minimalist_happy"),"minimalist unlocked before day 6");
|
||||
mw.day=6; h.api.evaluateWorld(mw,{id:"happy"}); assert(on(h,"minimalist_happy"),"minimalist at day 6 with 10 items");
|
||||
|
||||
// Overprotective requires both thresholds on the same individual.
|
||||
h=harness(); const cared={dead:false}; const cw={time:0};
|
||||
for(let i=0;i<9;i++)h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"});
|
||||
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",treatment:true});
|
||||
assert(off(h,"overprotective"),"overprotective before 10 feeds"); h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"}); assert(on(h,"overprotective"),"overprotective thresholds");
|
||||
|
||||
// Self-sufficient: 20+, no direct feed, no duplicator, 10 game minutes.
|
||||
h=harness(); let sw={time:0,tarinai:alive(20),ants:[],items:[]}; h.api.evaluateWorld(sw,{}); sw.time=599.99;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient early");
|
||||
sw.items.push({type:"duplicator",dead:false});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"self sufficient with duplicator");
|
||||
sw.items=[];sw.time=700;h.api.evaluateWorld(sw,{});sw.time=1300;h.api.evaluateWorld(sw,{});assert(on(h,"self_sufficient"),"self sufficient after clean 10m");
|
||||
h=harness(); sw={time:0,tarinai:alive(20),ants:[],items:[]};h.api.evaluateWorld(sw,{});sw.time=500;h.api.recordDirectFeed({world:sw,tarinai:sw.tarinai[0],type:"food"});sw.time=600;h.api.evaluateWorld(sw,{});assert(off(h,"self_sufficient"),"direct feed did not reset self sufficient");
|
||||
|
||||
// Unplanned city is persistent and deletion must be within 30 seconds.
|
||||
storage=new Map(); h=harness(storage); let qw={time:0};
|
||||
for(let i=0;i<15;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
|
||||
h=harness(storage);qw={time:0};for(let i=0;i<14;i++){const item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});qw.time=0;}
|
||||
assert(off(h,"unplanned_city_30"),"unplanned at 29");let item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30.001;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"late deletion counted");
|
||||
item={_achievementPlayerPlaced:true,_achievementPlacedAt:null};qw.time=0;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"missing placement timestamp counted");
|
||||
item={};qw.time=0;h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});assert(on(h,"unplanned_city_30"),"unplanned at 30");
|
||||
|
||||
// Sauna -> cold within 15 seconds; cold alone must never unlock.
|
||||
h=harness(); const st={dead:false,x:0,y:0}; let temp=0; const tw={time:0,tarinai:[st],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":v<10?"cold":"comfort",comfortable:v>=10&&v<=25};}};
|
||||
temp=0;h.api.evaluateWorld(tw,{});assert(off(h,"sauna_cold_plunge"),"cold alone unlocked sauna");temp=40;h.api.evaluateWorld(tw,{});tw.time=15;temp=0;h.api.evaluateWorld(tw,{});assert(on(h,"sauna_cold_plunge"),"sauna boundary 15s");
|
||||
h=harness(); const st2={dead:false,x:0,y:0};temp=40;const tw2={time:0,tarinai:[st2],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":"cold",comfortable:false};}};h.api.evaluateWorld(tw2,{});tw2.time=15.01;temp=0;h.api.evaluateWorld(tw2,{});assert(off(h,"sauna_cold_plunge"),"sauna allowed over 15s");
|
||||
|
||||
// Rain shelter requires all 20 and accepts nest/pipe via shelter system.
|
||||
h=harness(); h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};
|
||||
const rw={time:0,weather:"light_rain",tarinai:alive(20).map(t=>({...t,sheltered:true})),ants:[],items:[]};h.api.evaluateWorld(rw,{});assert(on(h,"rain_shelter_all"),"all sheltered rain achievement");
|
||||
h=harness();h.c.TarinaiRainShelterSystem={isSheltered(_w,_x,_y,t){return Boolean(t.sheltered);}};rw.tarinai[19].sheltered=false;h.api.evaluateWorld(rw,{});assert(off(h,"rain_shelter_all"),"rain achievement allowed one exposed");
|
||||
|
||||
// Nest boxes and pipes are genuine weather blockers, not only achievement stubs.
|
||||
h=harness();
|
||||
vm.runInContext(fs.readFileSync(path.join(root,"js","tarinai_local_environment_system.js"),"utf8"),h.c,{filename:"tarinai_local_environment_system.js"});
|
||||
const nest={id:"n1",type:"nest_box",dead:false};
|
||||
const pipe={id:"p1",type:"pipe",dead:false};
|
||||
const shelterWorld={items:[nest,pipe],nestBoxById(id){return this.items.find(x=>x.id===id)||null;}};
|
||||
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"n1"}),"nest box did not block weather");
|
||||
assert(h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{insideNestBoxId:"p1"}),"pipe did not block weather");
|
||||
assert(!h.c.TarinaiRainShelterSystem.isSheltered(shelterWorld,0,0,{}),"unsheltered target treated as sheltered");
|
||||
|
||||
// Nest-box and pipe occupants must also be insulated from weather temperature offsets.
|
||||
{
|
||||
class FakeWorld {}
|
||||
const tc={console,Math,Number,Object,Array,Set,Map,WeakMap,Date,CONFIG:{standardTemperature:15,temperatureItemRange:190},
|
||||
World:FakeWorld,clamp:(v,a,b)=>Math.max(a,Math.min(b,v)),distXY:(x1,y1,x2,y2)=>Math.hypot(x1-x2,y1-y2),
|
||||
TarinaiParasolSystem:h.c.TarinaiRainShelterSystem};
|
||||
tc.window=tc;tc.globalThis=tc;vm.createContext(tc);
|
||||
vm.runInContext(fs.readFileSync(path.join(root,"js","world_temperature_system.js"),"utf8"),tc,{filename:"world_temperature_system.js"});
|
||||
const box={id:"box",type:"nest_box",dead:false,x:0,y:0,r:42};
|
||||
const pipeItem={id:"pipe",type:"pipe",dead:false,x:0,y:0,r:42};
|
||||
const weatherWorld=new FakeWorld();Object.assign(weatherWorld,{currentTemperature:15,temperatureAuto:true,groundType:"soil",weather:"light_rain",items:[box,pipeItem],
|
||||
nearbyItems(x,y,r){return this.items.filter(it=>Math.hypot(it.x-x,it.y-y)<=r);},nestBoxById(id){return this.items.find(x=>x.id===id)||null;},nestBoxBaseRect(){return null;}});
|
||||
const outside={x:500,y:0}; const inBox={x:0,y:0,insideNestBoxId:"box"}; const inPipe={x:0,y:0,insideNestBoxId:"pipe"};
|
||||
assert(weatherWorld.feltTemperatureFor(outside)<15,"rain weather offset missing outside");
|
||||
assert(weatherWorld.feltTemperatureFor(inBox)>=15,"nest box still received rain temperature offset");
|
||||
assert(weatherWorld.feltTemperatureFor(inPipe)>=15,"pipe still received rain temperature offset");
|
||||
}
|
||||
|
||||
// Every medicine item once; chain and crown are explicitly excluded.
|
||||
h=harness(); const meds=["first_aid","sedative","sleep_drug","laxative","protein","niteropu","ammo","mystery_drug","mercury","giant_drug","dwarf_drug"];
|
||||
meds.slice(0,-1).forEach(type=>h.api.recordConsumableUse({type}));assert(off(h,"medicine_ledger_all"),"medicine ledger early");h.api.recordConsumableUse({type:meds.at(-1)});assert(on(h,"medicine_ledger_all"),"medicine ledger complete");
|
||||
|
||||
// Mercury lifespan, revolution, fight mochi.
|
||||
h=harness();h.api.recordDeath({world:{time:1,tarinai:[]},tarinai:{lifeItemMode:"mercury"},reason:"天寿を全うした"});assert(on(h,"mercury_lifespan"),"mercury lifespan");
|
||||
h=harness();const slave={id:"s",isZunchiSlave:true,dead:false};const king={id:"k",isTarinaiChampion:true,dead:true,_achievementLastFightAttackerId:"s",_achievementLastFightDamageAt:5};const revWorld={time:5,tarinai:[slave,king]};h.api.recordDeath({world:revWorld,tarinai:king,reason:"喧嘩",fromDamage:true});assert(on(h,"revolution"),"revolution");
|
||||
h=harness();const oil={};const fw={time:0};h.api.recordConsumableUse({world:fw,tarinai:oil,type:"fight_mochi"});fw.time=30;h.api.recordFightStarted({world:fw,a:oil,b:{}});assert(on(h,"fuel_to_fire"),"fight mochi at 30s");
|
||||
h=harness();const noOil={};h.api.recordFightStarted({world:{time:0},a:noOil,b:{}});assert(off(h,"fuel_to_fire"),"fight without mochi unlocked at time zero");
|
||||
h=harness();const oil2={};const fw2={time:0};h.api.recordConsumableUse({world:fw2,tarinai:oil2,type:"fight_mochi"});fw2.time=30.01;h.api.recordFightStarted({world:fw2,a:oil2,b:{}});assert(off(h,"fuel_to_fire"),"fight mochi over 30s");
|
||||
|
||||
// Enemy's enemy: 30 alive, no tarinai damage for 10 sec, danger source, field-local count.
|
||||
h=harness(); const ew={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};
|
||||
for(let i=0;i<9;i++){const ant={world:ew};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant});}
|
||||
assert(off(h,"enemy_enemy_friend"),"enemy achievement early");const ant10={world:ew};h.api.recordAntDamageSource(ant10,{type:"firecracker"},{world:ew});h.api.recordAntKilled({world:ew,ant:ant10});assert(on(h,"enemy_enemy_friend"),"enemy achievement at 10");
|
||||
h=harness();const ew2={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:11};for(let i=0;i<10;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}assert(off(h,"enemy_enemy_friend"),"enemy counted within 10s of tarinai damage");
|
||||
ew2.time=22;ew2.achievementLastTarinaiDamageAt=11;const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert(off(h,"enemy_enemy_friend"),"non-danger ant kill counted");
|
||||
const noCollateral={time:50,tarinai:alive(30),achievementLastTarinaiDamageAt:-Infinity};
|
||||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"food"},{world:noCollateral});
|
||||
assert(noCollateral.achievementLastTarinaiDamageAt===-Infinity,"ordinary damage incorrectly blocked enemy achievement");
|
||||
h.api.recordDamageSource(noCollateral.tarinai[0],{type:"firecracker"},{world:noCollateral});
|
||||
assert(noCollateral.achievementLastTarinaiDamageAt===50,"danger collateral was not recorded");
|
||||
h=harness();const ew3={time:20,tarinai:alive(30),achievementLastTarinaiDamageAt:0};const overwritten={world:ew3};h.api.recordAntDamageSource(overwritten,{type:"firecracker"},{world:ew3});h.api.recordAntDamageSource(overwritten,{id:"t0"},{world:ew3});h.api.recordAntKilled({world:ew3,ant:overwritten});assert((ew3.achievementEnemyAntKills||0)===0,"stale danger attribution survived later non-danger damage");
|
||||
|
||||
// Static integration guards for all production hook points.
|
||||
const checks={
|
||||
"js/command_dispatcher.js":["deadRestored","recordHistoryAction"],
|
||||
"js/robot_cleaner_system.js":["recordRobotClean"],
|
||||
"js/world_tool_actions.js":["recordPlayerDeletion","recordAntDamageSource"],
|
||||
"js/world_family_social.js":["resetWorldProgress","recordFightStarted"],
|
||||
"js/tarinai_social_move_life.js":["_achievementLastFightAttackerId"],
|
||||
"js/tarinai_direct_feeding_system.js":["recordDirectCare"],
|
||||
"js/tarinai_consumable_behavior.js":["recordConsumableUse"],
|
||||
"js/tarinai_contact_item_system.js":["recordConsumableUse", "slave_chain", "golden_crown"],
|
||||
"js/world_combat_effects.js":["recordAntDamageSource","source: it"],
|
||||
"js/impact_response_system.js":["source: it"],
|
||||
"js/ants.js":["recordAntDamageSource", "tarinai_struggle"],
|
||||
"js/signal_system.js":["recordAntDamageSource", "electric_wire"],
|
||||
"js/tarinai_item_effects.js":["recordAntDamageSource", "tarinai_ammo_collision"],
|
||||
"js/tarinai_local_environment_system.js":["enclosedShelterFor","nest_box","pipe"],
|
||||
"js/tarinai_update_step_frame.js":["isSheltered?.(t.world, t.x, t.y, t)"],
|
||||
"js/tarinai_disease_nest.js":["isSheltered?.(this.world, this.x, this.y, this)"],
|
||||
};
|
||||
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
|
||||
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
|
||||
})();
|
||||
|
|
@ -2104,12 +2104,16 @@ def check_connection_overlay_and_time_detector() -> None:
|
|||
fail(f"detector dual time slider is missing {control_id}")
|
||||
if '.pressure-time-thumb-start' not in styles or '.pressure-time-thumb-end' not in styles:
|
||||
fail("detector dual-thumb slider styling is missing")
|
||||
if 'item.pressureTarget === "time" ? 2' not in snapshot or 'item.pressureTimeStart' not in snapshot or 'item.pressureTimeEnd' not in snapshot:
|
||||
fail("time detector snapshot persistence is missing")
|
||||
if 'data.pressureTarget = item.pressureTarget === "time"' not in placement or 'item.pressureTarget = data.pressureTarget === "time"' not in placement:
|
||||
fail("time detector copy persistence is missing")
|
||||
if 'item.pressureTarget === "time") continue' not in render:
|
||||
fail("time detectors still render a spatial detection rectangle")
|
||||
if 'const targets = ["tarinai", "item", "time", "hunger_avg"' not in snapshot or 'item.pressureTimeStart' not in snapshot or 'item.pressureTimeEnd' not in snapshot:
|
||||
fail("expanded detector snapshot persistence is missing")
|
||||
if 'data.pressureTarget = item.pressureTarget || "tarinai"' not in placement or 'targets.has(data.pressureTarget)' not in placement or 'data.pressureComparator' not in placement:
|
||||
fail("expanded detector copy persistence is missing")
|
||||
if 'item.pressureTarget === "time" || item.pressureTarget === "temperature"' not in render:
|
||||
fail("non-spatial detectors still render a detection rectangle")
|
||||
removed = ["bio_sensor", "power_relay", "automationItemEditor"]
|
||||
for token in removed:
|
||||
if token in placement or token in snapshot or token in signal:
|
||||
fail(f"removed automation item remains in runtime source: {token}")
|
||||
|
||||
runtime = f"""
|
||||
const vm = require('vm');
|
||||
|
|
|
|||
19
scripts/ui_dialog_achievement_audit.js
Normal file
19
scripts/ui_dialog_achievement_audit.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
const achievement = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
|
||||
const save = fs.readFileSync(path.join(root, "js", "save_system.js"), "utf8");
|
||||
const dialog = fs.readFileSync(path.join(root, "js", "game_dialogs.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "index.html"), "utf8");
|
||||
function assert(value, message) { if (!value) throw new Error(message); }
|
||||
assert(!/\b(?:window\.|global\.)?(?:alert|confirm|prompt)\s*\(/.test(achievement + save), "native browser popup call remains");
|
||||
assert(achievement.includes("TarinaiGameDialogs") && save.includes("TarinaiGameDialogs"), "in-game confirmation API is not used");
|
||||
assert(dialog.includes('role", "alertdialog"') && dialog.includes("data-game-dialog-confirm"), "shared in-game confirmation dialog is incomplete");
|
||||
assert(!achievement.includes("TarinaiTooltips") && !achievement.includes("dataset.tip"), "achievement tooltip binding remains");
|
||||
assert(achievement.includes("ACHIEVEMENT_CATEGORIES") && achievement.includes("createAchievementGroup"), "achievement category rendering is missing");
|
||||
assert(!achievement.includes('id: "completed"'), "separate completed group still exists");
|
||||
assert(achievement.includes("orderedDefinitions = [...unlockedDefinitions, ...lockedDefinitions]"), "unlocked achievements are not sorted to the top inside categories");
|
||||
assert(["\\u751f\\u614b", "\\u5b9f\\u9a13", "\\u5efa\\u7bc9", "\\u305d\\u306e\\u4ed6"].every(label => achievement.includes(`title: "${label}"`)), "category titles are incorrect");
|
||||
assert(index.includes('placeholder="検索"'), "search placeholder was not shortened");
|
||||
console.log("[OK] achievement grouping, inline disclosures, and in-game confirmation dialogs passed");
|
||||
64
scripts/v391655_gameplay_fix_audit.js
Normal file
64
scripts/v391655_gameplay_fix_audit.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use strict";
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
function assert(v, m) { if (!v) throw new Error(m); }
|
||||
|
||||
// Verify the manual push direction for all three passive mechanical types.
|
||||
const context = {
|
||||
console, Math, Number, Object, Array, Set, Map, WeakMap, Date,
|
||||
clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); },
|
||||
TarinaiCollisionFootprints: { hitTestItem() { return { hit: false, distance: Infinity }; } },
|
||||
TarinaiPhysicsBodySystem: {
|
||||
ensureBody(item) { return item.physicsBody; },
|
||||
scalar(item, key, fallback = 0) {
|
||||
const b = item.physicsBody || {};
|
||||
if (key === "spin") return b.velocity?.angular ?? fallback;
|
||||
if (key === "slideSpeed") return b.velocity?.linear ?? fallback;
|
||||
if (key === "xv") return b.velocity?.x ?? fallback;
|
||||
if (key === "yv") return b.velocity?.y ?? fallback;
|
||||
if (key === "motorOn") return b.motor?.powered ?? fallback;
|
||||
if (key === "railOn") return b.motor?.powered ?? fallback;
|
||||
return b.state?.[key] ?? fallback;
|
||||
},
|
||||
setScalar(item, key, value) {
|
||||
const b = item.physicsBody;
|
||||
b.velocity ||= { x: 0, y: 0, angular: 0, linear: 0 };
|
||||
b.state ||= {};
|
||||
if (key === "spin") b.velocity.angular = value;
|
||||
else if (key === "slideSpeed") b.velocity.linear = value;
|
||||
else if (key === "xv") b.velocity.x = value;
|
||||
else if (key === "yv") b.velocity.y = value;
|
||||
else b.state[key] = value;
|
||||
return true;
|
||||
},
|
||||
applyBodyState() { return true; },
|
||||
},
|
||||
TarinaiSignalSystem: { powerOverride() { return null; } },
|
||||
};
|
||||
context.window = context; context.globalThis = context;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(root, "js", "mechanical_system.js"), "utf8"), context, { filename: "mechanical_system.js" });
|
||||
const body = (type) => ({
|
||||
type, x: 0, y: 0, angle: 0, r: 40, world: { time: 0, markSpatialDirty() {} },
|
||||
physicsBody: { type, pose: { x: 0, y: 0, angle: 0 }, velocity: { x: 0, y: 0, angular: 0, linear: 0 }, motor: { powered: false }, rail: { axisAngle: 0 }, state: { mass: 1, inertia: 3200 } },
|
||||
});
|
||||
let item = body("reciprocator");
|
||||
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 30, 0, item.world);
|
||||
assert(item.physicsBody.velocity.linear < 0, "reciprocator moved toward the poke point");
|
||||
item = body("poison_block");
|
||||
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 30, 0, item.world);
|
||||
assert(item.physicsBody.velocity.x < 0, "poison block moved toward the poke point");
|
||||
item = body("rotator");
|
||||
context.TarinaiMechanicalSystem.applyPokeImpulse(item, 0, -30, item.world);
|
||||
assert(item.physicsBody.velocity.angular > 0, "rotator poke torque is reversed");
|
||||
|
||||
const placement = fs.readFileSync(path.join(root, "js", "world_placement_log.js"), "utf8");
|
||||
const input = fs.readFileSync(path.join(root, "js", "ui_input_shared.js"), "utf8");
|
||||
const render = fs.readFileSync(path.join(root, "js", "render.js"), "utf8");
|
||||
assert(placement.includes("findPokeTargetAt"), "shared poke target finder is missing");
|
||||
assert(placement.indexOf('item.type !== "plushie"') < placement.indexOf('item.type !== "ball"'), "plushie is not prioritized by poke targeting");
|
||||
assert(placement.includes("recordPlushiePokeFling"), "plushie achievement hook is missing");
|
||||
assert(input.includes("hoverPokeTarget") && render.includes('label: "\\u3064\\u3064\\u304f"'), "poke overlay is missing");
|
||||
console.log("[OK] poke targeting, overlay, plushie fling, and passive mechanism push directions passed");
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
"use strict";
|
||||
|
||||
const APP_VERSION = "39.16.31";
|
||||
const APP_VERSION = "39.16.56";
|
||||
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||
const v = `v=${APP_VERSION}`;
|
||||
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
|
||||
const coreScriptNames = [
|
||||
"version", "event_bus", "domain_ids", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "signal_system", "assets", "audio", "perf_profiler", "display_helpers", "render", "sim_core", "tarinai_seed_factory", "structures", "item_type_catalog", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_direct_feeding_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
|
||||
"version", "event_bus", "domain_ids", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "signal_system", "assets", "audio", "perf_profiler", "display_helpers", "render", "sim_core", "tarinai_seed_factory", "structures", "item_type_catalog", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_direct_feeding_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "game_dialogs", "achievements", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
|
||||
];
|
||||
const CORE_ASSETS = [
|
||||
"./",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue